From 590b45e7146ef5d3769a535e8ffadb5b994b56e7 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 11:07:38 +0800 Subject: [PATCH 1/9] fix(vscode): detect rslint configs above the workspace folder --- CONTEXT.md | 2 +- packages/vscode/AGENTS.md | 1 + packages/vscode/README.md | 2 +- .../ancestor/packages/app/src/index.ts | 3 + .../ancestor/packages/app/tsconfig.json | 8 ++ .../lint/fixtures/ancestor/rslint.config.mjs | 10 ++ packages/vscode/e2e/lint/runTest.ts | 8 ++ .../e2e/lint/suite-ancestor/ancestor.test.ts | 131 ++++++++++++++++++ .../vscode/e2e/lint/suite-ancestor/index.ts | 5 + .../e2e/lint/suite-noconfig/noconfig.test.ts | 15 +- packages/vscode/src/detection.ts | 62 +++++++-- packages/vscode/src/stacks/lint/Rslint.ts | 11 +- packages/vscode/src/stacks/lint/index.ts | 38 +++++ packages/vscode/src/stacks/lint/resolution.ts | 63 ++++++++- packages/vscode/src/types.ts | 7 + .../tests/stacks/lint/resolution.test.ts | 81 +++++++++++ 16 files changed, 421 insertions(+), 26 deletions(-) create mode 100644 packages/vscode/e2e/lint/fixtures/ancestor/packages/app/src/index.ts create mode 100644 packages/vscode/e2e/lint/fixtures/ancestor/packages/app/tsconfig.json create mode 100644 packages/vscode/e2e/lint/fixtures/ancestor/rslint.config.mjs create mode 100644 packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts create mode 100644 packages/vscode/e2e/lint/suite-ancestor/index.ts diff --git a/CONTEXT.md b/CONTEXT.md index ad75d43..4bf7343 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -6,7 +6,7 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **Stack** — one tool integration (lint, test, fmt) hosted by the extension shell. A stack registers against the shell and reports status through it; stacks never own UI chrome. - **Shell** — the always-activating extension core: detection, status bar, output channels, stack lifecycle. -- **Detection** — the per-workspace-folder scan deciding which stacks a folder lights up. Detection signals are config files and installed tool binaries, never user settings. +- **Detection** — the per-workspace-folder scan deciding which stacks a folder lights up. The decision is per folder, but native lint's signal may lie above it: a folder with no Rslint config of its own uses the nearest `rslint.config.*` above it, as the rslint CLI does. Detection signals are config files and installed tool binaries, never user settings. - **Gate** — the per-stack activation condition: detected, workspace trusted, and that stack's enable setting on. ## Runtimes diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index f394e08..c30bc15 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -47,6 +47,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Why test file ownership exists** (adaptation 10): native configs can overlap upstream too, and the Rstack bridge adds a root contender. VS Code merges same-location items into one gutter action; routing chooses among the request's published items, which already reflect each config's include/exclude rules. - fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. +- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config (`findCandidateUp`, plus ancestor config watchers), so opening `repo/packages/app` with the only `rslint.config.*` at `repo/` must lint, as the retired `rstack.rslint` did. fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The walk (`findAncestorRslintConfig`) runs only when the folder has no `rslint.config.*` and no root `rstack.config.*`, so an ancestor config never displaces an in-folder one or turns a bridged folder native. It skips directories under `node_modules`, as Go does, and an ancestor hit counts only while `@rslint/core` resolves from the folder (`findPackageJsonUncached`), so a stray `~/rslint.config.*` does not light every folder below it with a not-installed status. `rstack.rslint.corePath` does not satisfy that gate. The lint Output channel logs the path once per change. Folder-relative watchers cannot see ancestors, so detection adds one non-recursive watcher per ancestor directory for the four config names plus the lockfiles; an ancestor lockfile event only re-runs detection, without the dependency-change notification a folder lockfile sends. Nothing downstream changes: the worker gets no `--config` in native mode and Go finds the ancestor config itself. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index c7357da..2338b8d 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -22,7 +22,7 @@ The extension activates on startup, then decides **per workspace folder** which | Tool | Started when the folder contains | | --- | --- | -| Rslint | `rslint.config.{js,mjs,ts,mts}` anywhere in the folder, or `rstack.config.*` at the folder root when no native config exists | +| Rslint | `rslint.config.{js,mjs,ts,mts}` anywhere in the folder, or `rstack.config.*` at the folder root when no native config exists. A folder with neither uses the nearest `rslint.config.*` above it, as `rslint` in a terminal there would, provided `@rslint/core` is installed for the folder | | Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` | | rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` | diff --git a/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/src/index.ts b/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/src/index.ts new file mode 100644 index 0000000..6844496 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/src/index.ts @@ -0,0 +1,3 @@ +export function pause(): void { + debugger; +} diff --git a/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/tsconfig.json b/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/tsconfig.json new file mode 100644 index 0000000..99878f0 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "strict": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/e2e/lint/fixtures/ancestor/rslint.config.mjs b/packages/vscode/e2e/lint/fixtures/ancestor/rslint.config.mjs new file mode 100644 index 0000000..a89a670 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/ancestor/rslint.config.mjs @@ -0,0 +1,10 @@ +// The only Rslint config in this fixture. The opened workspace folder is +// `packages/app`, so detection must find this file above the folder. +export default [ + { + files: ['**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]; diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index 5866978..b5bea90 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -268,6 +268,14 @@ async function main(): Promise { workspaceEntry: 'nested-initial.code-workspace', workspaceFolders: ['parent', 'parent/nested', 'sentinel'], }, + { + // The opened folder is a sub-package; the only config is above it. + name: 'Ancestor config tests', + workspace: fixture('ancestor'), + tests: suiteDir('suite-ancestor'), + workspaceEntry: 'packages/app', + workspaceFolders: ['packages/app'], + }, { name: 'No config tests', workspace: fixture('noconfig'), diff --git a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts new file mode 100644 index 0000000..3a3020f --- /dev/null +++ b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts @@ -0,0 +1,131 @@ +// The opened workspace folder is `packages/app`; the fixture's only +// `rslint.config.*` sits two levels above it. The rslint CLI and its Go server +// walk up to the filesystem root for a config, so native lint detection must +// light this folder, and its ancestor watchers must keep that decision fresh. +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import fs from 'node:fs'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics as waitForDiagnostics, +} from '../utils/diagnostics'; +import { + extensionExports, + waitForLintStackRegistration, +} from '../utils/extension'; + +const pollIntervalMs = 100; + +function configWithNoDebugger(severity: 'error' | 'off'): string { + return `export default [ + { + files: ['**/*.ts'], + rules: { + 'no-debugger': '${severity}', + }, + }, +]; +`; +} + +suite('rslint config above the workspace folder', function () { + this.timeout(120000); + + function workspaceFolder(): vscode.WorkspaceFolder { + return vscode.workspace.workspaceFolders![0]; + } + + const folderPath = (): string => workspaceFolder().uri.fsPath; + const rootConfigPath = (): string => + path.join(folderPath(), '..', '..', 'rslint.config.mjs'); + const nearerConfigPath = (): string => + path.join(folderPath(), '..', 'rslint.config.mjs'); + + function ancestorConfigPath(): string | undefined { + const exports = extensionExports().getStackExports('rslint') as + | { getAncestorConfigPaths?: () => ReadonlyMap } + | undefined; + return exports + ?.getAncestorConfigPaths?.() + .get(workspaceFolder().uri.toString()); + } + + async function waitForAncestorConfigPath( + expected: string, + timeoutMs = 60_000, + ): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ancestorConfigPath() === path.resolve(expected)) return; + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + assert.strictEqual(ancestorConfigPath(), path.resolve(expected)); + } + + async function openIndex(): Promise { + const document = await vscode.workspace.openTextDocument( + path.join(folderPath(), 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + return document; + } + + const hasNoDebugger = (diagnostics: vscode.Diagnostic[]): boolean => + diagnostics.some((diagnostic) => + diagnosticRuleIdIncludes(diagnostic, 'no-debugger'), + ); + + teardown(async () => { + fs.rmSync(nearerConfigPath(), { force: true }); + fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error')); + await waitForAncestorConfigPath(rootConfigPath()); + }); + + test('lints the folder with the config above it', async () => { + await waitForAncestorConfigPath(rootConfigPath()); + const document = await openIndex(); + await waitForDiagnostics(document, hasNoDebugger); + }); + + test('refreshes diagnostics when the config above the folder changes', async () => { + const document = await openIndex(); + await waitForDiagnostics(document, hasNoDebugger); + + fs.writeFileSync(rootConfigPath(), configWithNoDebugger('off')); + await waitForDiagnostics( + document, + (diagnostics) => !hasNoDebugger(diagnostics), + ); + + fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error')); + await waitForDiagnostics(document, hasNoDebugger); + }); + + test('re-runs detection for a nearer config created above the folder', async () => { + const document = await openIndex(); + await waitForDiagnostics(document, hasNoDebugger); + + fs.writeFileSync(nearerConfigPath(), configWithNoDebugger('off')); + await waitForAncestorConfigPath(nearerConfigPath()); + await waitForDiagnostics( + document, + (diagnostics) => !hasNoDebugger(diagnostics), + ); + + fs.rmSync(nearerConfigPath()); + await waitForAncestorConfigPath(rootConfigPath()); + await waitForDiagnostics(document, hasNoDebugger); + }); + + test('stops linting when the config above the folder is deleted', async () => { + fs.rmSync(rootConfigPath()); + await waitForLintStackRegistration(false); + + fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error')); + await waitForLintStackRegistration(true); + await waitForAncestorConfigPath(rootConfigPath()); + const document = await openIndex(); + await waitForDiagnostics(document, hasNoDebugger); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-ancestor/index.ts b/packages/vscode/e2e/lint/suite-ancestor/index.ts new file mode 100644 index 0000000..da8bd3e --- /dev/null +++ b/packages/vscode/e2e/lint/suite-ancestor/index.ts @@ -0,0 +1,5 @@ +import { createRun } from '../runSuite'; + +// The opened folder has no config of its own; the one above it must light the +// lint stack. +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts b/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts index 0382afc..bfe5e7a 100644 --- a/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts +++ b/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts @@ -3,12 +3,15 @@ // (origin/main), with the expectations rewritten for this extension's // detection rules: // -// - Upstream treated `rslint.json` as a working fallback config: creating it -// produced diagnostics, and the suite walked a JSON → JS → JSON lifecycle. -// - This extension does not support `rslint.json` at all. It is not a -// detection signal, so a folder with only `rslint.json` stays -// `not detected`, the lint stack is never registered, and zero diagnostics -// is the *designed* outcome — not a fallback state. +// - Upstream's suite walked a JSON → JS → JSON lifecycle from when +// `rslint.json` was a working fallback config. Upstream main no longer +// loads legacy JSON configs, and this extension never supported them. +// `rslint.json` is not a detection signal, so a folder with only +// `rslint.json` stays `not detected`, the lint stack is never registered, +// and zero diagnostics is the *designed* outcome — not a fallback state. +// - Native detection also looks above the folder (`suite-ancestor`). The +// sandbox's ancestors hold no `rslint.config.*`, so this suite doubles as +// the negative case: no config anywhere still means `not detected`. // - `rslint.config.*` remains a live detection signal: creating one must // register the stack without a window reload, and deleting the last one // must deregister it (the config-glob + lockfile detection watcher). diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index d0ffd90..e76060f 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -6,7 +6,13 @@ import { type StackId, STACK_IDS, } from './types'; -import { decideRslintMode } from './stacks/lint/resolution'; +import { findPackageJsonUncached } from './shared/packageResolve'; +import { + ancestorDirectories, + decideRslintMode, + findAncestorRslintConfig, + RSLINT_CONFIG_NAMES, +} from './stacks/lint/resolution'; /** * `rstack.config.*` is a config source for all three tool stacks. Lint only @@ -26,7 +32,7 @@ export const RSTACK_CONFIG_GLOB = '**/rstack.config.{ts,js,mts,mjs}'; * JSON configs (`rslint.json` / `rslint.jsonc`) are deliberately not detection * signals — upstream deprecated them and ships `rslint --init` to migrate. */ -export const RSLINT_CONFIG_GLOB = '**/rslint.config.{js,mjs,ts,mts}'; +export const RSLINT_CONFIG_GLOB = `**/{${RSLINT_CONFIG_NAMES.join(',')}}`; export const DEFAULT_RSTEST_CONFIG_GLOBS = [ '**/rstest.config.{mjs,ts,js,cjs,mts,cts}', @@ -81,10 +87,7 @@ export const emptySnapshot = (): DetectionSnapshot => new Snapshot([]); * and the Rslint/Rstest package resolution, so it counts as a detection input. */ export const DETECTION_WATCH_NAMES = [ - 'rslint.config.js', - 'rslint.config.mjs', - 'rslint.config.ts', - 'rslint.config.mts', + ...RSLINT_CONFIG_NAMES, ...RSTACK_CONFIG_NAMES, ...LOCKFILE_NAMES, ] as const; @@ -107,6 +110,17 @@ export const detectionWatchPatterns = ( ...new Set([`**/{${DETECTION_WATCH_NAMES.join(',')}}`, ...rstestGlobs]), ]; +/** + * The non-recursive pattern watched in every ancestor of a folder, so native + * lint's ancestor signal stays fresh. Folder-relative watchers cannot see + * these directories. The lockfiles are here because the signal also needs + * `@rslint/core` to resolve, and a monorepo installs at an ancestor. + */ +export const ANCESTOR_WATCH_PATTERN = `{${[ + ...RSLINT_CONFIG_NAMES, + ...LOCKFILE_NAMES, +].join(',')}}`; + const readRstestGlobs = (folder: vscode.WorkspaceFolder): readonly string[] => { const configured = vscode.workspace .getConfiguration('rstack.rstest', folder.uri) @@ -188,9 +202,22 @@ export const detectFolder = async ( ).find((candidate) => rstackConfigFiles.some((uri) => uri.toString() === candidate.toString()), )?.fsPath; + // The Go server walks up past the folder for its config; mirror that only + // when nothing inside the folder decides ownership, and only when a core is + // installed to run it, so a stray config in a home directory stays silent. + const ancestorConfigPath = + rslintConfigFiles.length === 0 && rootRstackConfigPath === undefined + ? findAncestorRslintConfig(folder.uri.fsPath) + : undefined; + const ancestorSignal = + ancestorConfigPath !== undefined && + findPackageJsonUncached('@rslint/core', folder.uri.fsPath) !== undefined + ? ancestorConfigPath + : undefined; const rslintMode = decideRslintMode({ nativeConfigPaths: rslintConfigFiles.map((uri) => uri.fsPath), rootRstackConfigPath, + ancestorConfigPath: ancestorSignal, }); const stacks: Record = { @@ -199,6 +226,7 @@ export const detectFolder = async ( configFiles: rslintConfigFiles, rstackConfigFiles, mode: rslintMode, + ancestorConfigPath: ancestorSignal, }, rstest: { detected: rstestConfigFiles.length > 0 || rstackConfigFiles.length > 0, @@ -225,7 +253,7 @@ const signatureOf = (snapshot: DetectionSnapshot): string => .map((uri) => uri.toString()) .sort() .join(','); - return `${stack}:${detection.detected ? 1 : 0}:${detection.mode ?? ''}:${detection.binPath ?? ''}:${files}`; + return `${stack}:${detection.detected ? 1 : 0}:${detection.mode ?? ''}:${detection.binPath ?? ''}:${detection.ancestorConfigPath ?? ''}:${files}`; }).join('|'); return `${entry.folder.uri.toString()}=>${stacks}`; }) @@ -235,7 +263,8 @@ const signatureOf = (snapshot: DetectionSnapshot): string => /** * Runs detection over every workspace folder and keeps it fresh without a * window reload: one `FileSystemWatcher` per folder and watch pattern covers - * the config globs plus the lockfiles. + * the config globs plus the lockfiles, and one non-recursive watcher per + * ancestor directory covers native lint's ancestor signal. */ export class DetectionService implements vscode.Disposable { #snapshot: DetectionSnapshot = emptySnapshot(); @@ -390,6 +419,23 @@ export class DetectionService implements vscode.Disposable { watcher.onDidDelete(onEvent), ); } + // An ancestor lockfile only re-runs detection: it must not notify the + // other stacks as a dependency change, which is the folder lockfile's job. + for (const directory of ancestorDirectories(folder.uri.fsPath)) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern( + vscode.Uri.file(directory), + ANCESTOR_WATCH_PATTERN, + ), + ); + const schedule = () => this.schedule(); + this.#watchers.push( + watcher, + watcher.onDidCreate(schedule), + watcher.onDidChange(schedule), + watcher.onDidDelete(schedule), + ); + } } } diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 30b1ded..94f45b5 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -39,7 +39,7 @@ import type { StackState } from '../../types'; import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; import type { Logger } from './logger'; -import type { RslintMode } from './resolution'; +import { type RslintMode, RSLINT_CONFIG_NAMES } from './resolution'; import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, isConfigSourceChangeDuringTransaction, @@ -68,15 +68,8 @@ const LOCKFILE_NAMES = [ 'yarn.lock', ] as const; -const RSLINT_CONFIG_WATCH_NAMES = [ - 'rslint.config.js', - 'rslint.config.mjs', - 'rslint.config.ts', - 'rslint.config.mts', -] as const; - export const CONFIG_REFRESH_WATCH_GLOB = `**/{${[ - ...RSLINT_CONFIG_WATCH_NAMES, + ...RSLINT_CONFIG_NAMES, ...LOCKFILE_NAMES, ].join(',')}}`; diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 4639551..88a282c 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -75,12 +75,15 @@ class RslintController implements StackController { // router ownership; each runtime removes itself only when it is still the // entry for its key, so an overlapping replacement survives the old close. readonly #runtimes = new Map(); + /** Folder URI → the ancestor config last logged for it. */ + readonly #loggedAncestorConfigs = new Map(); #disposed = false; async register(context: StackContext): Promise> { this.#context = context; this.#snapshot = context.detection; this.#logger = new Logger(context.output); + this.logAncestorConfigs(); this.startRuntimeManager(); this.#subscriptions.push( @@ -95,6 +98,7 @@ class RslintController implements StackController { this.#subscriptions.push( context.onDidChangeDetection((snapshot) => { this.#snapshot = snapshot; + this.logAncestorConfigs(); this.pruneDepartedFolders(); for (const runtime of this.#runtimes.values()) { runtime.setBridgeConfigPath( @@ -169,6 +173,16 @@ class RslintController implements StackController { ...states.runtimes, ]), ), + /** Folders lit by a config above them, by folder key. */ + getAncestorConfigPaths: (): ReadonlyMap => + new Map( + this.detectedFolders().flatMap((entry) => { + const configPath = entry.stacks.rslint.ancestorConfigPath; + return configPath === undefined + ? [] + : [[folderKeyOf(entry.folder), configPath] as const]; + }), + ), }; } @@ -286,6 +300,29 @@ class RslintController implements StackController { return runtime; } + /** + * One line per folder whose config sits above it, so a stray + * `rslint.config.*` in an ancestor such as the home directory can be traced. + */ + private logAncestorConfigs(): void { + const current = new Map(); + for (const entry of this.detectedFolders()) { + const configPath = entry.stacks.rslint.ancestorConfigPath; + if (configPath === undefined) continue; + const key = folderKeyOf(entry.folder); + current.set(key, configPath); + if (this.#loggedAncestorConfigs.get(key) !== configPath) { + this.#logger?.info( + `Using ${configPath} for ${entry.folder.name}: the folder has no rslint.config.* of its own`, + ); + } + } + this.#loggedAncestorConfigs.clear(); + for (const [key, configPath] of current) { + this.#loggedAncestorConfigs.set(key, configPath); + } + } + private detectedFolders() { return (this.#snapshot?.foldersFor('rslint') ?? []).filter( (entry) => entry.stacks.rslint.mode !== undefined, @@ -417,6 +454,7 @@ class RslintController implements StackController { this.#logger = undefined; this.#context = undefined; this.#snapshot = undefined; + this.#loggedAncestorConfigs.clear(); } } diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index f774e63..dbd6091 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -40,18 +40,79 @@ export class RslintResolutionError extends Error { } } +/** Upstream's discovery order within one directory (rslint `config_init.go`). */ +export const RSLINT_CONFIG_NAMES = [ + 'rslint.config.js', + 'rslint.config.mjs', + 'rslint.config.ts', + 'rslint.config.mts', +] as const; + +const hasNodeModulesSegment = (directory: string): boolean => + directory.split(/[\\/]/).includes('node_modules'); + +/** + * The strict ancestors of `folderPath`, nearest first, up to the filesystem + * root. Directories under a `node_modules` segment are skipped, as Go's + * `isDefaultDiscoveryExcluded` skips their config candidates. + */ +export function ancestorDirectories(folderPath: string): string[] { + const directories: string[] = []; + let directory = path.resolve(folderPath); + for (;;) { + const parent = path.dirname(directory); + if (parent === directory) return directories; + directory = parent; + if (!hasNodeModulesSegment(directory)) directories.push(directory); + } +} + +const isFileSync = (filePath: string): boolean => { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +}; + +/** + * The nearest `rslint.config.*` above the workspace folder: the config the Go + * server's upward discovery (`findCandidateUp`) loads for a folder that has + * none of its own. + */ +export function findAncestorRslintConfig( + folderPath: string, + isFile: (filePath: string) => boolean = isFileSync, +): string | undefined { + for (const directory of ancestorDirectories(folderPath)) { + for (const name of RSLINT_CONFIG_NAMES) { + const candidate = path.join(directory, name); + if (isFile(candidate)) return candidate; + } + } + return undefined; +} + export interface RslintModeSignals { readonly nativeConfigPaths: readonly string[]; readonly rootRstackConfigPath?: string; + /** A native config above the folder, for a folder with no config of its own. */ + readonly ancestorConfigPath?: string; } -/** Native ownership wins; only a root Rstack config can bridge a folder. */ +/** + * Native ownership wins; only a root Rstack config can bridge a folder. A + * native config above the folder ranks below that root Rstack config, so it + * never turns a bridged folder native. + */ export function decideRslintMode({ nativeConfigPaths, rootRstackConfigPath, + ancestorConfigPath, }: RslintModeSignals): RslintMode | undefined { if (nativeConfigPaths.length > 0) return 'native'; if (rootRstackConfigPath !== undefined) return 'bridged'; + if (ancestorConfigPath !== undefined) return 'native'; return undefined; } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index baacaa3..8b7eecf 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -83,6 +83,13 @@ export interface StackDetection { readonly configFiles: readonly vscode.Uri[]; /** Rslint's per-folder ownership choice; undefined for other stacks. */ readonly mode?: 'native' | 'bridged'; + /** + * Rslint only: the nearest `rslint.config.*` above a folder that has no + * config of its own and no root `rstack.config.*`, counted only while + * `@rslint/core` resolves from the folder. The Go server finds this config + * itself; detection records it to light the folder and log the path. + */ + readonly ancestorConfigPath?: string; /** * `rstack.config.*` files governing this folder. A folder can be detected * through these alone, in which case the stack has to go through the rstack diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts index 8a651ca..5720dd0 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -1,6 +1,8 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; import { + decideRslintMode, + findAncestorRslintConfig, resolveRslint, RslintResolutionError, } from '../../../src/stacks/lint/resolution'; @@ -138,3 +140,82 @@ describe('resolveRslint', () => { } }); }); + +describe('findAncestorRslintConfig', () => { + const root = path.parse(process.cwd()).root; + const repo = path.join(root, 'repo'); + const app = path.join(repo, 'packages', 'app'); + const existing = + (...files: string[]) => + (filePath: string): boolean => + files.includes(filePath); + + it('finds a config in an ancestor directory', () => { + const config = path.join(repo, 'rslint.config.ts'); + expect(findAncestorRslintConfig(app, existing(config))).toBe(config); + }); + + it('prefers the nearest ancestor', () => { + const nearer = path.join(repo, 'packages', 'rslint.config.mjs'); + const farther = path.join(repo, 'rslint.config.js'); + expect(findAncestorRslintConfig(app, existing(farther, nearer))).toBe( + nearer, + ); + }); + + it('takes the names in upstream order within one directory', () => { + const js = path.join(repo, 'rslint.config.js'); + const ts = path.join(repo, 'rslint.config.ts'); + expect(findAncestorRslintConfig(app, existing(ts, js))).toBe(js); + }); + + it('never looks inside the folder itself', () => { + const own = path.join(app, 'rslint.config.js'); + expect(findAncestorRslintConfig(app, existing(own))).toBeUndefined(); + }); + + it('skips ancestors under node_modules', () => { + const vendored = path.join(repo, 'node_modules', 'pkg'); + const inside = path.join(repo, 'node_modules', 'rslint.config.js'); + const above = path.join(repo, 'rslint.config.js'); + expect( + findAncestorRslintConfig(path.join(vendored, 'src'), existing(inside)), + ).toBeUndefined(); + expect( + findAncestorRslintConfig( + path.join(vendored, 'src'), + existing(inside, above), + ), + ).toBe(above); + }); + + it('reaches the filesystem root and stops there', () => { + const atRoot = path.join(root, 'rslint.config.mts'); + expect(findAncestorRslintConfig(app, existing(atRoot))).toBe(atRoot); + expect(findAncestorRslintConfig(root, existing(atRoot))).toBeUndefined(); + }); +}); + +describe('decideRslintMode', () => { + it('ranks an ancestor config below in-folder configs and a root Rstack config', () => { + const ancestorConfigPath = '/repo/rslint.config.js'; + expect( + decideRslintMode({ + nativeConfigPaths: ['/repo/app/rslint.config.js'], + rootRstackConfigPath: '/repo/app/rstack.config.ts', + ancestorConfigPath, + }), + ).toBe('native'); + expect( + decideRslintMode({ + nativeConfigPaths: [], + rootRstackConfigPath: '/repo/app/rstack.config.ts', + ancestorConfigPath, + }), + ).toBe('bridged'); + expect( + decideRslintMode({ nativeConfigPaths: [], ancestorConfigPath }), + ).toBe('native'); + expect(decideRslintMode({ nativeConfigPaths: [] })).toBeUndefined(); + }); +}); From 707a78f07fccd291004a9dd7361f3d50cce1bf9b Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 11:13:50 +0800 Subject: [PATCH 2/9] refactor(vscode): simplify ancestor rslint config detection --- CONTEXT.md | 2 +- packages/vscode/AGENTS.md | 2 +- packages/vscode/README.md | 2 +- .../e2e/lint/suite-ancestor/ancestor.test.ts | 68 ++---------- packages/vscode/src/detection.ts | 89 ++++++++------- packages/vscode/src/stacks/lint/index.ts | 33 +----- packages/vscode/src/stacks/lint/resolution.ts | 35 +++--- packages/vscode/src/types.ts | 7 +- .../tests/stacks/lint/resolution.test.ts | 101 ++++++------------ 9 files changed, 103 insertions(+), 236 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4bf7343..17d5822 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -6,7 +6,7 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **Stack** — one tool integration (lint, test, fmt) hosted by the extension shell. A stack registers against the shell and reports status through it; stacks never own UI chrome. - **Shell** — the always-activating extension core: detection, status bar, output channels, stack lifecycle. -- **Detection** — the per-workspace-folder scan deciding which stacks a folder lights up. The decision is per folder, but native lint's signal may lie above it: a folder with no Rslint config of its own uses the nearest `rslint.config.*` above it, as the rslint CLI does. Detection signals are config files and installed tool binaries, never user settings. +- **Detection** — the per-workspace-folder scan deciding which stacks a folder lights up. The decision is per folder, but native lint's config signal may come from an ancestor directory. Detection signals are config files and installed tool binaries, never user settings. - **Gate** — the per-stack activation condition: detected, workspace trusted, and that stack's enable setting on. ## Runtimes diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index c30bc15..1f8130f 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -47,7 +47,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Why test file ownership exists** (adaptation 10): native configs can overlap upstream too, and the Rstack bridge adds a root contender. VS Code merges same-location items into one gutter action; routing chooses among the request's published items, which already reflect each config's include/exclude rules. - fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. -- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config (`findCandidateUp`, plus ancestor config watchers), so opening `repo/packages/app` with the only `rslint.config.*` at `repo/` must lint, as the retired `rstack.rslint` did. fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The walk (`findAncestorRslintConfig`) runs only when the folder has no `rslint.config.*` and no root `rstack.config.*`, so an ancestor config never displaces an in-folder one or turns a bridged folder native. It skips directories under `node_modules`, as Go does, and an ancestor hit counts only while `@rslint/core` resolves from the folder (`findPackageJsonUncached`), so a stray `~/rslint.config.*` does not light every folder below it with a not-installed status. `rstack.rslint.corePath` does not satisfy that gate. The lint Output channel logs the path once per change. Folder-relative watchers cannot see ancestors, so detection adds one non-recursive watcher per ancestor directory for the four config names plus the lockfiles; an ancestor lockfile event only re-runs detection, without the dependency-change notification a folder lockfile sends. Nothing downstream changes: the worker gets no `--config` in native mode and Go finds the ancestor config itself. +- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config, so a folder without its own `rslint.config.*` and without a root `rstack.config.*` uses the nearest one above it (skipping `node_modules`); fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The ancestor counts only while `@rslint/core` resolves from the folder, so a stray `~/rslint.config.*` does not put every folder below it into the not-installed state (`corePath` does not satisfy this). Folder-relative watchers cannot see ancestors, so detection watches each ancestor directory non-recursively for the config names and lockfiles; those lockfile events re-run detection without the dependency-change notification. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 2338b8d..4cf1cd3 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -22,7 +22,7 @@ The extension activates on startup, then decides **per workspace folder** which | Tool | Started when the folder contains | | --- | --- | -| Rslint | `rslint.config.{js,mjs,ts,mts}` anywhere in the folder, or `rstack.config.*` at the folder root when no native config exists. A folder with neither uses the nearest `rslint.config.*` above it, as `rslint` in a terminal there would, provided `@rslint/core` is installed for the folder | +| Rslint | `rslint.config.{js,mjs,ts,mts}` anywhere in the folder, or `rstack.config.*` at the folder root when no native config exists. A folder with neither uses the nearest `rslint.config.*` above it, provided `@rslint/core` is installed for the folder | | Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` | | rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` | diff --git a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts index 3a3020f..ed1d97c 100644 --- a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts +++ b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts @@ -2,7 +2,6 @@ // `rslint.config.*` sits two levels above it. The rslint CLI and its Go server // walk up to the filesystem root for a config, so native lint detection must // light this folder, and its ancestor watchers must keep that decision fresh. -import * as assert from 'assert'; import * as vscode from 'vscode'; import path from 'node:path'; import fs from 'node:fs'; @@ -10,12 +9,7 @@ import { diagnosticRuleIdIncludes, waitForRslintDiagnostics as waitForDiagnostics, } from '../utils/diagnostics'; -import { - extensionExports, - waitForLintStackRegistration, -} from '../utils/extension'; - -const pollIntervalMs = 100; +import { waitForLintStackRegistration } from '../utils/extension'; function configWithNoDebugger(severity: 'error' | 'off'): string { return `export default [ @@ -32,36 +26,10 @@ function configWithNoDebugger(severity: 'error' | 'off'): string { suite('rslint config above the workspace folder', function () { this.timeout(120000); - function workspaceFolder(): vscode.WorkspaceFolder { - return vscode.workspace.workspaceFolders![0]; - } - - const folderPath = (): string => workspaceFolder().uri.fsPath; + const folderPath = (): string => + vscode.workspace.workspaceFolders![0].uri.fsPath; const rootConfigPath = (): string => path.join(folderPath(), '..', '..', 'rslint.config.mjs'); - const nearerConfigPath = (): string => - path.join(folderPath(), '..', 'rslint.config.mjs'); - - function ancestorConfigPath(): string | undefined { - const exports = extensionExports().getStackExports('rslint') as - | { getAncestorConfigPaths?: () => ReadonlyMap } - | undefined; - return exports - ?.getAncestorConfigPaths?.() - .get(workspaceFolder().uri.toString()); - } - - async function waitForAncestorConfigPath( - expected: string, - timeoutMs = 60_000, - ): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (ancestorConfigPath() === path.resolve(expected)) return; - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - assert.strictEqual(ancestorConfigPath(), path.resolve(expected)); - } async function openIndex(): Promise { const document = await vscode.workspace.openTextDocument( @@ -77,18 +45,11 @@ suite('rslint config above the workspace folder', function () { ); teardown(async () => { - fs.rmSync(nearerConfigPath(), { force: true }); fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error')); - await waitForAncestorConfigPath(rootConfigPath()); - }); - - test('lints the folder with the config above it', async () => { - await waitForAncestorConfigPath(rootConfigPath()); - const document = await openIndex(); - await waitForDiagnostics(document, hasNoDebugger); + await waitForLintStackRegistration(true); }); - test('refreshes diagnostics when the config above the folder changes', async () => { + test('lints with the config above the folder and follows its edits', async () => { const document = await openIndex(); await waitForDiagnostics(document, hasNoDebugger); @@ -102,29 +63,12 @@ suite('rslint config above the workspace folder', function () { await waitForDiagnostics(document, hasNoDebugger); }); - test('re-runs detection for a nearer config created above the folder', async () => { - const document = await openIndex(); - await waitForDiagnostics(document, hasNoDebugger); - - fs.writeFileSync(nearerConfigPath(), configWithNoDebugger('off')); - await waitForAncestorConfigPath(nearerConfigPath()); - await waitForDiagnostics( - document, - (diagnostics) => !hasNoDebugger(diagnostics), - ); - - fs.rmSync(nearerConfigPath()); - await waitForAncestorConfigPath(rootConfigPath()); - await waitForDiagnostics(document, hasNoDebugger); - }); - - test('stops linting when the config above the folder is deleted', async () => { + test('re-runs detection when the config above the folder is deleted or created', async () => { fs.rmSync(rootConfigPath()); await waitForLintStackRegistration(false); fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error')); await waitForLintStackRegistration(true); - await waitForAncestorConfigPath(rootConfigPath()); const document = await openIndex(); await waitForDiagnostics(document, hasNoDebugger); }); diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index e76060f..a3a89e6 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -6,7 +6,6 @@ import { type StackId, STACK_IDS, } from './types'; -import { findPackageJsonUncached } from './shared/packageResolve'; import { ancestorDirectories, decideRslintMode, @@ -111,12 +110,11 @@ export const detectionWatchPatterns = ( ]; /** - * The non-recursive pattern watched in every ancestor of a folder, so native - * lint's ancestor signal stays fresh. Folder-relative watchers cannot see - * these directories. The lockfiles are here because the signal also needs - * `@rslint/core` to resolve, and a monorepo installs at an ancestor. + * Watched non-recursively in every folder ancestor, which folder-relative + * watchers cannot see. The lockfiles are here because native lint's ancestor + * signal needs `@rslint/core`, and a monorepo installs at an ancestor. */ -export const ANCESTOR_WATCH_PATTERN = `{${[ +const ANCESTOR_WATCH_PATTERN = `{${[ ...RSLINT_CONFIG_NAMES, ...LOCKFILE_NAMES, ].join(',')}}`; @@ -202,22 +200,17 @@ export const detectFolder = async ( ).find((candidate) => rstackConfigFiles.some((uri) => uri.toString() === candidate.toString()), )?.fsPath; - // The Go server walks up past the folder for its config; mirror that only - // when nothing inside the folder decides ownership, and only when a core is - // installed to run it, so a stray config in a home directory stays silent. + // Like the Go server, look above the folder, but only when nothing inside + // it decides ownership: an ancestor never displaces a bridged folder. const ancestorConfigPath = rslintConfigFiles.length === 0 && rootRstackConfigPath === undefined ? findAncestorRslintConfig(folder.uri.fsPath) : undefined; - const ancestorSignal = - ancestorConfigPath !== undefined && - findPackageJsonUncached('@rslint/core', folder.uri.fsPath) !== undefined - ? ancestorConfigPath - : undefined; const rslintMode = decideRslintMode({ - nativeConfigPaths: rslintConfigFiles.map((uri) => uri.fsPath), + nativeConfigPaths: ancestorConfigPath + ? [ancestorConfigPath] + : rslintConfigFiles.map((uri) => uri.fsPath), rootRstackConfigPath, - ancestorConfigPath: ancestorSignal, }); const stacks: Record = { @@ -226,7 +219,7 @@ export const detectFolder = async ( configFiles: rslintConfigFiles, rstackConfigFiles, mode: rslintMode, - ancestorConfigPath: ancestorSignal, + ancestorConfigPath, }, rstest: { detected: rstestConfigFiles.length > 0 || rstackConfigFiles.length > 0, @@ -398,45 +391,49 @@ export class DetectionService implements vscode.Disposable { watcher.dispose(); } this.#watchers = []; + const onFolderEvent = (uri: vscode.Uri) => { + if (isLockfile(uri)) { + this.#notifyUnchanged = true; + } + this.schedule(); + }; + // An ancestor lockfile only re-runs detection: it must not notify the + // other stacks as a dependency change, which is the folder lockfile's job. + const onAncestorEvent = () => this.schedule(); + const targets: [vscode.RelativePattern, (uri: vscode.Uri) => void][] = []; + const ancestors = new Set(); for (const folder of vscode.workspace.workspaceFolders ?? []) { if (folder.uri.scheme !== 'file') { continue; } - const onEvent = (uri: vscode.Uri) => { - if (isLockfile(uri)) { - this.#notifyUnchanged = true; - } - this.schedule(); - }; for (const pattern of detectionWatchPatterns(readRstestGlobs(folder))) { - const watcher = vscode.workspace.createFileSystemWatcher( + targets.push([ new vscode.RelativePattern(folder, pattern), - ); - this.#watchers.push( - watcher, - watcher.onDidCreate(onEvent), - watcher.onDidChange(onEvent), - watcher.onDidDelete(onEvent), - ); + onFolderEvent, + ]); } - // An ancestor lockfile only re-runs detection: it must not notify the - // other stacks as a dependency change, which is the folder lockfile's job. for (const directory of ancestorDirectories(folder.uri.fsPath)) { - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern( - vscode.Uri.file(directory), - ANCESTOR_WATCH_PATTERN, - ), - ); - const schedule = () => this.schedule(); - this.#watchers.push( - watcher, - watcher.onDidCreate(schedule), - watcher.onDidChange(schedule), - watcher.onDidDelete(schedule), - ); + ancestors.add(directory); } } + for (const directory of ancestors) { + targets.push([ + new vscode.RelativePattern( + vscode.Uri.file(directory), + ANCESTOR_WATCH_PATTERN, + ), + onAncestorEvent, + ]); + } + for (const [pattern, onEvent] of targets) { + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + this.#watchers.push( + watcher, + watcher.onDidCreate(onEvent), + watcher.onDidChange(onEvent), + watcher.onDidDelete(onEvent), + ); + } } dispose(): void { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 88a282c..4c7c0cb 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -75,8 +75,6 @@ class RslintController implements StackController { // router ownership; each runtime removes itself only when it is still the // entry for its key, so an overlapping replacement survives the old close. readonly #runtimes = new Map(); - /** Folder URI → the ancestor config last logged for it. */ - readonly #loggedAncestorConfigs = new Map(); #disposed = false; async register(context: StackContext): Promise> { @@ -173,16 +171,6 @@ class RslintController implements StackController { ...states.runtimes, ]), ), - /** Folders lit by a config above them, by folder key. */ - getAncestorConfigPaths: (): ReadonlyMap => - new Map( - this.detectedFolders().flatMap((entry) => { - const configPath = entry.stacks.rslint.ancestorConfigPath; - return configPath === undefined - ? [] - : [[folderKeyOf(entry.folder), configPath] as const]; - }), - ), }; } @@ -300,26 +288,14 @@ class RslintController implements StackController { return runtime; } - /** - * One line per folder whose config sits above it, so a stray - * `rslint.config.*` in an ancestor such as the home directory can be traced. - */ + /** Makes a stray `rslint.config.*` in an ancestor such as `~` traceable. */ private logAncestorConfigs(): void { - const current = new Map(); for (const entry of this.detectedFolders()) { const configPath = entry.stacks.rslint.ancestorConfigPath; if (configPath === undefined) continue; - const key = folderKeyOf(entry.folder); - current.set(key, configPath); - if (this.#loggedAncestorConfigs.get(key) !== configPath) { - this.#logger?.info( - `Using ${configPath} for ${entry.folder.name}: the folder has no rslint.config.* of its own`, - ); - } - } - this.#loggedAncestorConfigs.clear(); - for (const [key, configPath] of current) { - this.#loggedAncestorConfigs.set(key, configPath); + this.#logger?.info( + `Using ${configPath} for ${entry.folder.name}: the folder has no rslint.config.* of its own`, + ); } } @@ -454,7 +430,6 @@ class RslintController implements StackController { this.#logger = undefined; this.#context = undefined; this.#snapshot = undefined; - this.#loggedAncestorConfigs.clear(); } } diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index dbd6091..6bea0ec 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -67,27 +67,26 @@ export function ancestorDirectories(folderPath: string): string[] { } } -const isFileSync = (filePath: string): boolean => { - try { - return fs.statSync(filePath).isFile(); - } catch { - return false; - } -}; - /** - * The nearest `rslint.config.*` above the workspace folder: the config the Go - * server's upward discovery (`findCandidateUp`) loads for a folder that has - * none of its own. + * The nearest `rslint.config.*` above the folder: the config the Go server's + * upward discovery (`findCandidateUp`) loads for a folder without one of its + * own. It counts only while `@rslint/core` resolves from the folder, so a stray + * config in a home directory does not light every folder below it. */ export function findAncestorRslintConfig( folderPath: string, - isFile: (filePath: string) => boolean = isFileSync, ): string | undefined { for (const directory of ancestorDirectories(folderPath)) { for (const name of RSLINT_CONFIG_NAMES) { const candidate = path.join(directory, name); - if (isFile(candidate)) return candidate; + try { + if (!fs.statSync(candidate).isFile()) continue; + } catch { + continue; + } + return findPackageJsonUncached('@rslint/core', folderPath) + ? candidate + : undefined; } } return undefined; @@ -96,23 +95,15 @@ export function findAncestorRslintConfig( export interface RslintModeSignals { readonly nativeConfigPaths: readonly string[]; readonly rootRstackConfigPath?: string; - /** A native config above the folder, for a folder with no config of its own. */ - readonly ancestorConfigPath?: string; } -/** - * Native ownership wins; only a root Rstack config can bridge a folder. A - * native config above the folder ranks below that root Rstack config, so it - * never turns a bridged folder native. - */ +/** Native ownership wins; only a root Rstack config can bridge a folder. */ export function decideRslintMode({ nativeConfigPaths, rootRstackConfigPath, - ancestorConfigPath, }: RslintModeSignals): RslintMode | undefined { if (nativeConfigPaths.length > 0) return 'native'; if (rootRstackConfigPath !== undefined) return 'bridged'; - if (ancestorConfigPath !== undefined) return 'native'; return undefined; } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index 8b7eecf..d1379b2 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -83,12 +83,7 @@ export interface StackDetection { readonly configFiles: readonly vscode.Uri[]; /** Rslint's per-folder ownership choice; undefined for other stacks. */ readonly mode?: 'native' | 'bridged'; - /** - * Rslint only: the nearest `rslint.config.*` above a folder that has no - * config of its own and no root `rstack.config.*`, counted only while - * `@rslint/core` resolves from the folder. The Go server finds this config - * itself; detection records it to light the folder and log the path. - */ + /** Rslint only: the config above the folder that made it native. */ readonly ancestorConfigPath?: string; /** * `rstack.config.*` files governing this folder. A folder can be detected diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts index 5720dd0..b0855da 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -1,7 +1,7 @@ +import fs from 'node:fs'; import path from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; import { - decideRslintMode, findAncestorRslintConfig, resolveRslint, RslintResolutionError, @@ -142,80 +142,45 @@ describe('resolveRslint', () => { }); describe('findAncestorRslintConfig', () => { - const root = path.parse(process.cwd()).root; - const repo = path.join(root, 'repo'); - const app = path.join(repo, 'packages', 'app'); - const existing = - (...files: string[]) => - (filePath: string): boolean => - files.includes(filePath); - - it('finds a config in an ancestor directory', () => { - const config = path.join(repo, 'rslint.config.ts'); - expect(findAncestorRslintConfig(app, existing(config))).toBe(config); - }); - - it('prefers the nearest ancestor', () => { - const nearer = path.join(repo, 'packages', 'rslint.config.mjs'); - const farther = path.join(repo, 'rslint.config.js'); - expect(findAncestorRslintConfig(app, existing(farther, nearer))).toBe( - nearer, + const writeConfig = (directory: string, name: string): string => { + fs.mkdirSync(directory, { recursive: true }); + const configPath = path.join(directory, name); + fs.writeFileSync(configPath, 'export default [];'); + return configPath; + }; + + it('takes the nearest config above the folder', () => { + const repo = temporaryDirectory(); + installPackage(repo, '@rslint/core', '0.9.4'); + writeConfig(repo, 'rslint.config.ts'); + const nearer = writeConfig( + path.join(repo, 'packages'), + 'rslint.config.mjs', ); - }); + const app = path.join(repo, 'packages', 'app'); + fs.mkdirSync(app, { recursive: true }); - it('takes the names in upstream order within one directory', () => { - const js = path.join(repo, 'rslint.config.js'); - const ts = path.join(repo, 'rslint.config.ts'); - expect(findAncestorRslintConfig(app, existing(ts, js))).toBe(js); + expect(findAncestorRslintConfig(app)).toBe(nearer); }); - it('never looks inside the folder itself', () => { - const own = path.join(app, 'rslint.config.js'); - expect(findAncestorRslintConfig(app, existing(own))).toBeUndefined(); - }); + it('ignores the config while @rslint/core does not resolve', () => { + const home = temporaryDirectory(); + writeConfig(home, 'rslint.config.js'); + const project = path.join(home, 'project'); + fs.mkdirSync(project, { recursive: true }); - it('skips ancestors under node_modules', () => { - const vendored = path.join(repo, 'node_modules', 'pkg'); - const inside = path.join(repo, 'node_modules', 'rslint.config.js'); - const above = path.join(repo, 'rslint.config.js'); - expect( - findAncestorRslintConfig(path.join(vendored, 'src'), existing(inside)), - ).toBeUndefined(); - expect( - findAncestorRslintConfig( - path.join(vendored, 'src'), - existing(inside, above), - ), - ).toBe(above); + expect(findAncestorRslintConfig(project)).toBeUndefined(); }); - it('reaches the filesystem root and stops there', () => { - const atRoot = path.join(root, 'rslint.config.mts'); - expect(findAncestorRslintConfig(app, existing(atRoot))).toBe(atRoot); - expect(findAncestorRslintConfig(root, existing(atRoot))).toBeUndefined(); - }); -}); + it('skips configs under node_modules', () => { + const repo = temporaryDirectory(); + installPackage(repo, '@rslint/core', '0.9.4'); + writeConfig(path.join(repo, 'node_modules'), 'rslint.config.js'); + const vendored = path.join(repo, 'node_modules', 'pkg'); + fs.mkdirSync(vendored, { recursive: true }); + expect(findAncestorRslintConfig(vendored)).toBeUndefined(); -describe('decideRslintMode', () => { - it('ranks an ancestor config below in-folder configs and a root Rstack config', () => { - const ancestorConfigPath = '/repo/rslint.config.js'; - expect( - decideRslintMode({ - nativeConfigPaths: ['/repo/app/rslint.config.js'], - rootRstackConfigPath: '/repo/app/rstack.config.ts', - ancestorConfigPath, - }), - ).toBe('native'); - expect( - decideRslintMode({ - nativeConfigPaths: [], - rootRstackConfigPath: '/repo/app/rstack.config.ts', - ancestorConfigPath, - }), - ).toBe('bridged'); - expect( - decideRslintMode({ nativeConfigPaths: [], ancestorConfigPath }), - ).toBe('native'); - expect(decideRslintMode({ nativeConfigPaths: [] })).toBeUndefined(); + const above = writeConfig(repo, 'rslint.config.js'); + expect(findAncestorRslintConfig(vendored)).toBe(above); }); }); From 588db7cedae73cce7fe427a741cdfbbe013dce96 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 11:20:24 +0800 Subject: [PATCH 3/9] test(vscode): cover a nearer ancestor rslint config created while linting --- .../e2e/lint/suite-ancestor/ancestor.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts index ed1d97c..6413805 100644 --- a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts +++ b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts @@ -30,6 +30,8 @@ suite('rslint config above the workspace folder', function () { vscode.workspace.workspaceFolders![0].uri.fsPath; const rootConfigPath = (): string => path.join(folderPath(), '..', '..', 'rslint.config.mjs'); + const nearerConfigPath = (): string => + path.join(folderPath(), '..', 'rslint.config.mjs'); async function openIndex(): Promise { const document = await vscode.workspace.openTextDocument( @@ -45,6 +47,7 @@ suite('rslint config above the workspace folder', function () { ); teardown(async () => { + fs.rmSync(nearerConfigPath(), { force: true }); fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error')); await waitForLintStackRegistration(true); }); @@ -63,6 +66,24 @@ suite('rslint config above the workspace folder', function () { await waitForDiagnostics(document, hasNoDebugger); }); + // The live runtime keeps its key, and the extension sends no configRefresh + // for ancestors: the Go server's own ancestor watchers + // (`ancestorJSConfigFileWatchers`, registered through the worker) re-run + // its discovery. + test('applies a nearer config created above the folder while linting', async () => { + const document = await openIndex(); + await waitForDiagnostics(document, hasNoDebugger); + + fs.writeFileSync(nearerConfigPath(), configWithNoDebugger('off')); + await waitForDiagnostics( + document, + (diagnostics) => !hasNoDebugger(diagnostics), + ); + + fs.rmSync(nearerConfigPath()); + await waitForDiagnostics(document, hasNoDebugger); + }); + test('re-runs detection when the config above the folder is deleted or created', async () => { fs.rmSync(rootConfigPath()); await waitForLintStackRegistration(false); From b4eee014627cf796f06edd27008ba161facdcb0b Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 11:34:24 +0800 Subject: [PATCH 4/9] fix(vscode): treat ancestor lockfile changes as dependency changes --- packages/vscode/AGENTS.md | 2 +- packages/vscode/src/detection.ts | 25 ++++++++++--------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 1f8130f..8004d43 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -47,7 +47,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Why test file ownership exists** (adaptation 10): native configs can overlap upstream too, and the Rstack bridge adds a root contender. VS Code merges same-location items into one gutter action; routing chooses among the request's published items, which already reflect each config's include/exclude rules. - fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. -- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config, so a folder without its own `rslint.config.*` and without a root `rstack.config.*` uses the nearest one above it (skipping `node_modules`); fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The ancestor counts only while `@rslint/core` resolves from the folder, so a stray `~/rslint.config.*` does not put every folder below it into the not-installed state (`corePath` does not satisfy this). Folder-relative watchers cannot see ancestors, so detection watches each ancestor directory non-recursively for the config names and lockfiles; those lockfile events re-run detection without the dependency-change notification. +- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config, so a folder without its own `rslint.config.*` and without a root `rstack.config.*` uses the nearest one above it (skipping `node_modules`); fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The ancestor counts only while `@rslint/core` resolves from the folder, so a stray `~/rslint.config.*` does not put every folder below it into the not-installed state (`corePath` does not satisfy this). Folder-relative watchers cannot see ancestors, so detection watches each ancestor directory non-recursively for the config names and lockfiles; an ancestor lockfile counts as a dependency change exactly like a folder one, since a monorepo opened at a subdirectory installs at the root. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index a3a89e6..f991360 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -111,8 +111,8 @@ export const detectionWatchPatterns = ( /** * Watched non-recursively in every folder ancestor, which folder-relative - * watchers cannot see. The lockfiles are here because native lint's ancestor - * signal needs `@rslint/core`, and a monorepo installs at an ancestor. + * watchers cannot see: native lint's ancestor config, and the lockfile of a + * monorepo opened at a subdirectory. */ const ANCESTOR_WATCH_PATTERN = `{${[ ...RSLINT_CONFIG_NAMES, @@ -391,41 +391,36 @@ export class DetectionService implements vscode.Disposable { watcher.dispose(); } this.#watchers = []; - const onFolderEvent = (uri: vscode.Uri) => { + // A lockfile event is a dependency change wherever it is watched: a + // monorepo opened at a subdirectory installs at an ancestor. + const onEvent = (uri: vscode.Uri) => { if (isLockfile(uri)) { this.#notifyUnchanged = true; } this.schedule(); }; - // An ancestor lockfile only re-runs detection: it must not notify the - // other stacks as a dependency change, which is the folder lockfile's job. - const onAncestorEvent = () => this.schedule(); - const targets: [vscode.RelativePattern, (uri: vscode.Uri) => void][] = []; + const patterns: vscode.RelativePattern[] = []; const ancestors = new Set(); for (const folder of vscode.workspace.workspaceFolders ?? []) { if (folder.uri.scheme !== 'file') { continue; } for (const pattern of detectionWatchPatterns(readRstestGlobs(folder))) { - targets.push([ - new vscode.RelativePattern(folder, pattern), - onFolderEvent, - ]); + patterns.push(new vscode.RelativePattern(folder, pattern)); } for (const directory of ancestorDirectories(folder.uri.fsPath)) { ancestors.add(directory); } } for (const directory of ancestors) { - targets.push([ + patterns.push( new vscode.RelativePattern( vscode.Uri.file(directory), ANCESTOR_WATCH_PATTERN, ), - onAncestorEvent, - ]); + ); } - for (const [pattern, onEvent] of targets) { + for (const pattern of patterns) { const watcher = vscode.workspace.createFileSystemWatcher(pattern); this.#watchers.push( watcher, From 454d3d391cca89555c5a30c7bb73a9dfa45dd651 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 11:50:52 +0800 Subject: [PATCH 5/9] test(vscode): temporary macOS CI diagnostics for the ancestor suite --- .../packages/app/.vscode/settings.json | 3 +++ packages/vscode/e2e/lint/runTest.ts | 22 +++++++++++++++++++ .../e2e/lint/suite-ancestor/ancestor.test.ts | 17 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 packages/vscode/e2e/lint/fixtures/ancestor/packages/app/.vscode/settings.json diff --git a/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/.vscode/settings.json b/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/.vscode/settings.json new file mode 100644 index 0000000..8359e9a --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rstack.rslint.trace.server": "verbose" +} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index b5bea90..f111cfa 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -147,6 +147,8 @@ async function runIsolatedSuite( // Keep VS Code's CI-only extension inventory and AgentHost info logs // out of test output while preserving an opt-in for diagnosis. `--log=${process.env.VSCODE_TEST_LOG_LEVEL ?? 'warn'}`, + // DEBUG(macos-ci): temporary, removed before merge. + '--log=rstack.rstack:trace', '--disable-extensions', '--disable-updates', // The fixtures spawn project-local binaries, which Restricted Mode @@ -161,6 +163,26 @@ async function runIsolatedSuite( }); } catch (error) { testError = error; + // DEBUG(macos-ci): temporary dump, removed before merge. + const dump = (file: string): void => { + console.log(`----- DEBUG ${file} -----`); + console.log(fs.readFileSync(file, 'utf8')); + }; + const walk = (dir: string): string[] => + fs.existsSync(dir) + ? fs + .readdirSync(dir, { withFileTypes: true }) + .flatMap((e) => + e.isDirectory() + ? walk(path.join(dir, e.name)) + : [path.join(dir, e.name)], + ) + : []; + for (const file of walk(path.join(userDataDir, 'logs'))) { + if (/rslint/i.test(path.basename(file))) dump(file); + } + if (fs.existsSync(path.join(profileRoot, 'probe.log'))) + dump(path.join(profileRoot, 'probe.log')); } let cleanupError: unknown; diff --git a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts index 6413805..9f1fc6f 100644 --- a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts +++ b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts @@ -55,6 +55,23 @@ suite('rslint config above the workspace folder', function () { test('lints with the config above the folder and follows its edits', async () => { const document = await openIndex(); await waitForDiagnostics(document, hasNoDebugger); + // DEBUG(macos-ci): temporary probe, removed before merge. + const rootDir = path.resolve(path.dirname(rootConfigPath())); + const probeLog = path.join(rootDir, '..', 'probe.log'); + fs.appendFileSync( + probeLog, + `folder=${folderPath()} rootDir=${rootDir} real=${fs.realpathSync(rootDir)} PWD=${process.env.PWD} cwd=${process.cwd()}\n`, + ); + for (const base of [rootDir, fs.realpathSync(rootDir)]) { + const w = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(base), 'rslint.config.mjs'), + ); + for (const ev of ['onDidCreate', 'onDidChange', 'onDidDelete'] as const) + w[ev]((u) => + fs.appendFileSync(probeLog, `[base ${base}] ${ev} ${u.toString()}\n`), + ); + } + await new Promise((r) => setTimeout(r, 1000)); fs.writeFileSync(rootConfigPath(), configWithNoDebugger('off')); await waitForDiagnostics( From a5e1c04597c3ae228f6bb639bf1669e91c94842f Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 12:00:55 +0800 Subject: [PATCH 6/9] test(vscode): dump ancestor suite traces without the probe delay --- packages/vscode/e2e/lint/runTest.ts | 2 ++ .../e2e/lint/suite-ancestor/ancestor.test.ts | 17 ----------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index f111cfa..c58b891 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -163,6 +163,8 @@ async function runIsolatedSuite( }); } catch (error) { testError = error; + } + if (suite.name.startsWith('Ancestor')) { // DEBUG(macos-ci): temporary dump, removed before merge. const dump = (file: string): void => { console.log(`----- DEBUG ${file} -----`); diff --git a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts index 9f1fc6f..6413805 100644 --- a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts +++ b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts @@ -55,23 +55,6 @@ suite('rslint config above the workspace folder', function () { test('lints with the config above the folder and follows its edits', async () => { const document = await openIndex(); await waitForDiagnostics(document, hasNoDebugger); - // DEBUG(macos-ci): temporary probe, removed before merge. - const rootDir = path.resolve(path.dirname(rootConfigPath())); - const probeLog = path.join(rootDir, '..', 'probe.log'); - fs.appendFileSync( - probeLog, - `folder=${folderPath()} rootDir=${rootDir} real=${fs.realpathSync(rootDir)} PWD=${process.env.PWD} cwd=${process.cwd()}\n`, - ); - for (const base of [rootDir, fs.realpathSync(rootDir)]) { - const w = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(vscode.Uri.file(base), 'rslint.config.mjs'), - ); - for (const ev of ['onDidCreate', 'onDidChange', 'onDidDelete'] as const) - w[ev]((u) => - fs.appendFileSync(probeLog, `[base ${base}] ${ev} ${u.toString()}\n`), - ); - } - await new Promise((r) => setTimeout(r, 1000)); fs.writeFileSync(rootConfigPath(), configWithNoDebugger('off')); await waitForDiagnostics( From 91ebde0877c2ea8590e8ae575d80870b9b9743e3 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 12:14:54 +0800 Subject: [PATCH 7/9] fix(vscode): refresh rslint config on ancestor edits before the server boots --- packages/vscode/AGENTS.md | 2 +- .../e2e/lint/suite-ancestor/ancestor.test.ts | 7 ++-- packages/vscode/src/stacks/lint/Rslint.ts | 37 ++++++++++++++++--- packages/vscode/src/stacks/lint/index.ts | 3 ++ 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 8004d43..28751a1 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -47,7 +47,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Why test file ownership exists** (adaptation 10): native configs can overlap upstream too, and the Rstack bridge adds a root contender. VS Code merges same-location items into one gutter action; routing chooses among the request's published items, which already reflect each config's include/exclude rules. - fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. -- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config, so a folder without its own `rslint.config.*` and without a root `rstack.config.*` uses the nearest one above it (skipping `node_modules`); fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The ancestor counts only while `@rslint/core` resolves from the folder, so a stray `~/rslint.config.*` does not put every folder below it into the not-installed state (`corePath` does not satisfy this). Folder-relative watchers cannot see ancestors, so detection watches each ancestor directory non-recursively for the config names and lockfiles; an ancestor lockfile counts as a dependency change exactly like a folder one, since a monorepo opened at a subdirectory installs at the root. +- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config, so a folder without its own `rslint.config.*` and without a root `rstack.config.*` uses the nearest one above it (skipping `node_modules`); fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The ancestor counts only while `@rslint/core` resolves from the folder, so a stray `~/rslint.config.*` does not put every folder below it into the not-installed state (`corePath` does not satisfy this). Folder-relative watchers cannot see ancestors, so detection watches each ancestor directory non-recursively for the config names and lockfiles; an ancestor lockfile counts as a dependency change exactly like a folder one, since a monorepo opened at a subdirectory installs at the root. A native runtime using an ancestor config also watches those directories itself and sends `rslint/configRefresh`, installed before the server boots: Go registers its own ancestor watchers only after initialization, and a watcher misses writes made right after it is created (macOS CI lost an edit made ~40 ms after Go's registration). - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. diff --git a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts index 6413805..f5d91a3 100644 --- a/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts +++ b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts @@ -66,10 +66,9 @@ suite('rslint config above the workspace folder', function () { await waitForDiagnostics(document, hasNoDebugger); }); - // The live runtime keeps its key, and the extension sends no configRefresh - // for ancestors: the Go server's own ancestor watchers - // (`ancestorJSConfigFileWatchers`, registered through the worker) re-run - // its discovery. + // The live runtime keeps its key; the extension's ancestor watchers send + // configRefresh, as do the Go server's own (`ancestorJSConfigFileWatchers`, + // registered through the worker) once they are live. test('applies a nearer config created above the folder while linting', async () => { const document = await openIndex(); await waitForDiagnostics(document, hasNoDebugger); diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 94f45b5..8440344 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -39,7 +39,11 @@ import type { StackState } from '../../types'; import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; import type { Logger } from './logger'; -import { type RslintMode, RSLINT_CONFIG_NAMES } from './resolution'; +import { + ancestorDirectories, + type RslintMode, + RSLINT_CONFIG_NAMES, +} from './resolution'; import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, isConfigSourceChangeDuringTransaction, @@ -287,6 +291,8 @@ export interface RslintOptions { * pass paths, because the host never loads project code (ADR 0003). */ readonly installation: CoreInstallation; + /** Detection's `ancestorConfigPath` for this folder, if any. */ + readonly ancestorConfigPath?: string; readonly outputChannel: OutputChannel; readonly lspOutputChannel: OutputChannel; readonly router: WorkspaceDocumentRouter; @@ -304,6 +310,7 @@ export class Rslint implements Disposable { private readonly reportStatus: RslintStatusSink; private bridgeConfigPath: string | undefined; private readonly installation: CoreInstallation; + private readonly ancestorConfigPath: string | undefined; private readonly lspOutputChannel: OutputChannel; private readonly outputChannel: OutputChannel; private readonly onClosed: (() => void) | undefined; @@ -331,6 +338,7 @@ export class Rslint implements Disposable { this.router = options.router; this.reportStatus = options.reportStatus; this.installation = options.installation; + this.ancestorConfigPath = options.ancestorConfigPath; this.logger = options.logger; this.lspOutputChannel = options.lspOutputChannel; this.outputChannel = options.outputChannel; @@ -437,6 +445,9 @@ export class Rslint implements Disposable { const folderRoot = this.workspaceFolder.uri.fsPath; const { mode, packageDirectory, shimPath } = this.installation; + // Before the server boots: a watcher misses writes made right after it is + // created, and Go registers its own watchers only once it is initialized. + this.installConfigRefreshWatchers(mode); this.logger.info( `Rslint ${mode} mode: @rslint/core ${this.installation.version ?? 'unknown'} at ${packageDirectory}`, @@ -558,7 +569,6 @@ export class Rslint implements Disposable { ); }); - this.installConfigRefreshWatchers(mode); const retried = await retryConfigRefreshOnSourceChange( async () => this.requestConfigRefresh('initial'), async () => this.requestConfigRefresh('config-change'), @@ -641,8 +651,25 @@ export class Rslint implements Disposable { const patterns = [ CONFIG_REFRESH_WATCH_GLOB, ...(mode === 'bridged' ? [RSTACK_CONFIG_REFRESH_WATCH_GLOB] : []), - ]; + ].map((pattern) => new RelativePattern(this.workspaceFolder, pattern)); + if (mode === 'native' && this.ancestorConfigPath !== undefined) { + // Go watches the same directories (`ancestorJSConfigFileWatchers`), but + // registers them only once initialized, which loses edits made right + // after startup. + for (const directory of ancestorDirectories( + this.workspaceFolder.uri.fsPath, + )) { + patterns.push( + new RelativePattern( + Uri.file(directory), + `{${RSLINT_CONFIG_NAMES.join(',')}}`, + ), + ); + } + } const refreshConfig = (uri: Uri) => { + // The initial refresh reads whatever is on disk once the server runs. + if (!this.isRunning()) return; const reason = configRefreshReasonForPath(uri.fsPath); this.logger.debug(`${reason}: ${uri.fsPath}`); clearTimeout(this.configReloadTimer); @@ -656,9 +683,7 @@ export class Rslint implements Disposable { }, 300); }; for (const pattern of patterns) { - const watcher = workspace.createFileSystemWatcher( - new RelativePattern(this.workspaceFolder, pattern), - ); + const watcher = workspace.createFileSystemWatcher(pattern); this.configWatchers.push(watcher); watcher.onDidChange(refreshConfig); watcher.onDidCreate(refreshConfig); diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 4c7c0cb..6a10089 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -259,6 +259,9 @@ class RslintController implements StackController { rootKey: resolved.key, workspaceFolder, installation, + ancestorConfigPath: + this.#snapshot?.forFolder(workspaceFolder)?.stacks.rslint + .ancestorConfigPath, outputChannel: context.output, // The extension is capped at four output channels, so the LSP trace // shares the stack's channel instead of opening a fifth. From afacb3c7198d57129f051d7ffacb853b5981778d Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 12:27:16 +0800 Subject: [PATCH 8/9] test(vscode): remove temporary macOS CI diagnostics --- .../packages/app/.vscode/settings.json | 3 --- packages/vscode/e2e/lint/runTest.ts | 24 ------------------- 2 files changed, 27 deletions(-) delete mode 100644 packages/vscode/e2e/lint/fixtures/ancestor/packages/app/.vscode/settings.json diff --git a/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/.vscode/settings.json b/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/.vscode/settings.json deleted file mode 100644 index 8359e9a..0000000 --- a/packages/vscode/e2e/lint/fixtures/ancestor/packages/app/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "rstack.rslint.trace.server": "verbose" -} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index c58b891..b5bea90 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -147,8 +147,6 @@ async function runIsolatedSuite( // Keep VS Code's CI-only extension inventory and AgentHost info logs // out of test output while preserving an opt-in for diagnosis. `--log=${process.env.VSCODE_TEST_LOG_LEVEL ?? 'warn'}`, - // DEBUG(macos-ci): temporary, removed before merge. - '--log=rstack.rstack:trace', '--disable-extensions', '--disable-updates', // The fixtures spawn project-local binaries, which Restricted Mode @@ -164,28 +162,6 @@ async function runIsolatedSuite( } catch (error) { testError = error; } - if (suite.name.startsWith('Ancestor')) { - // DEBUG(macos-ci): temporary dump, removed before merge. - const dump = (file: string): void => { - console.log(`----- DEBUG ${file} -----`); - console.log(fs.readFileSync(file, 'utf8')); - }; - const walk = (dir: string): string[] => - fs.existsSync(dir) - ? fs - .readdirSync(dir, { withFileTypes: true }) - .flatMap((e) => - e.isDirectory() - ? walk(path.join(dir, e.name)) - : [path.join(dir, e.name)], - ) - : []; - for (const file of walk(path.join(userDataDir, 'logs'))) { - if (/rslint/i.test(path.basename(file))) dump(file); - } - if (fs.existsSync(path.join(profileRoot, 'probe.log'))) - dump(path.join(profileRoot, 'probe.log')); - } let cleanupError: unknown; try { From 72aeb0305bf86f3efadd62e3efaeff88519d99be Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 9 Oct 2026 12:41:35 +0800 Subject: [PATCH 9/9] fix(vscode): watch ancestor rslint configs for every native runtime --- packages/vscode/AGENTS.md | 2 +- packages/vscode/src/stacks/lint/Rslint.ts | 13 +++++-------- packages/vscode/src/stacks/lint/index.ts | 3 --- packages/vscode/tests/stacks/lint/start.test.ts | 1 + 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 28751a1..573692a 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -47,7 +47,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Why test file ownership exists** (adaptation 10): native configs can overlap upstream too, and the Rstack bridge adds a root contender. VS Code merges same-location items into one gutter action; routing chooses among the request's published items, which already reflect each config's include/exclude rules. - fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. -- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config, so a folder without its own `rslint.config.*` and without a root `rstack.config.*` uses the nearest one above it (skipping `node_modules`); fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The ancestor counts only while `@rslint/core` resolves from the folder, so a stray `~/rslint.config.*` does not put every folder below it into the not-installed state (`corePath` does not satisfy this). Folder-relative watchers cannot see ancestors, so detection watches each ancestor directory non-recursively for the config names and lockfiles; an ancestor lockfile counts as a dependency change exactly like a folder one, since a monorepo opened at a subdirectory installs at the root. A native runtime using an ancestor config also watches those directories itself and sends `rslint/configRefresh`, installed before the server boots: Go registers its own ancestor watchers only after initialization, and a watcher misses writes made right after it is created (macOS CI lost an edit made ~40 ms after Go's registration). +- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config, so a folder without its own `rslint.config.*` and without a root `rstack.config.*` uses the nearest one above it (skipping `node_modules`); fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The ancestor counts only while `@rslint/core` resolves from the folder, so a stray `~/rslint.config.*` does not put every folder below it into the not-installed state (`corePath` does not satisfy this). Folder-relative watchers cannot see ancestors, so detection watches each ancestor directory non-recursively for the config names and lockfiles; an ancestor lockfile counts as a dependency change exactly like a folder one, since a monorepo opened at a subdirectory installs at the root. Every native runtime also watches the ancestor directories for `rslint.config.*` (the set Go watches) and sends `rslint/configRefresh`; the watchers are installed before the server boots: Go registers its own ancestor watchers only after initialization, and a watcher misses writes made right after it is created (macOS CI lost an edit made ~40 ms after Go's registration). Known gap: a folder whose ancestor config is gated out is not detected, so the ADR 0005 poll never runs for it; an install that leaves the lockfile untouched (`npm ci`, `pnpm install --frozen-lockfile`) is picked up only on the next detection pass or a window reload. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 8440344..03b4935 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -291,8 +291,6 @@ export interface RslintOptions { * pass paths, because the host never loads project code (ADR 0003). */ readonly installation: CoreInstallation; - /** Detection's `ancestorConfigPath` for this folder, if any. */ - readonly ancestorConfigPath?: string; readonly outputChannel: OutputChannel; readonly lspOutputChannel: OutputChannel; readonly router: WorkspaceDocumentRouter; @@ -310,7 +308,6 @@ export class Rslint implements Disposable { private readonly reportStatus: RslintStatusSink; private bridgeConfigPath: string | undefined; private readonly installation: CoreInstallation; - private readonly ancestorConfigPath: string | undefined; private readonly lspOutputChannel: OutputChannel; private readonly outputChannel: OutputChannel; private readonly onClosed: (() => void) | undefined; @@ -338,7 +335,6 @@ export class Rslint implements Disposable { this.router = options.router; this.reportStatus = options.reportStatus; this.installation = options.installation; - this.ancestorConfigPath = options.ancestorConfigPath; this.logger = options.logger; this.lspOutputChannel = options.lspOutputChannel; this.outputChannel = options.outputChannel; @@ -652,10 +648,11 @@ export class Rslint implements Disposable { CONFIG_REFRESH_WATCH_GLOB, ...(mode === 'bridged' ? [RSTACK_CONFIG_REFRESH_WATCH_GLOB] : []), ].map((pattern) => new RelativePattern(this.workspaceFolder, pattern)); - if (mode === 'native' && this.ancestorConfigPath !== undefined) { - // Go watches the same directories (`ancestorJSConfigFileWatchers`), but - // registers them only once initialized, which loses edits made right - // after startup. + if (mode === 'native') { + // Go's discovery can pick a config above the folder, even for part of a + // folder with nested configs. It watches the same directories + // (`ancestorJSConfigFileWatchers`), but registers them only once + // initialized, which loses edits made right after startup. for (const directory of ancestorDirectories( this.workspaceFolder.uri.fsPath, )) { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 6a10089..4c7c0cb 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -259,9 +259,6 @@ class RslintController implements StackController { rootKey: resolved.key, workspaceFolder, installation, - ancestorConfigPath: - this.#snapshot?.forFolder(workspaceFolder)?.stacks.rslint - .ancestorConfigPath, outputChannel: context.output, // The extension is capped at four output channels, so the LSP trace // shares the stack's channel instead of opening a fifth. diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index de9c19e..5f3a583 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -17,6 +17,7 @@ let reconciles = 0; rs.mock('vscode', () => { const api = { RelativePattern: class {}, + Uri: { file: (fsPath: string) => ({ fsPath }) }, workspace: { textDocuments: [], onDidChangeWorkspaceFolders: () => ({ dispose() {} }),