diff --git a/CONTEXT.md b/CONTEXT.md index ad75d43..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. 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 f394e08..573692a 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, 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/README.md b/packages/vscode/README.md index c7357da..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 | +| 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/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..f5d91a3 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts @@ -0,0 +1,95 @@ +// 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 vscode from 'vscode'; +import path from 'node:path'; +import fs from 'node:fs'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics as waitForDiagnostics, +} from '../utils/diagnostics'; +import { waitForLintStackRegistration } from '../utils/extension'; + +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); + + 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'); + + 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 waitForLintStackRegistration(true); + }); + + test('lints with the config above the folder and follows its edits', 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); + }); + + // 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); + + 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); + + fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error')); + await waitForLintStackRegistration(true); + 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..f991360 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -6,7 +6,12 @@ import { type StackId, STACK_IDS, } from './types'; -import { decideRslintMode } from './stacks/lint/resolution'; +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 +31,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 +86,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 +109,16 @@ export const detectionWatchPatterns = ( ...new Set([`**/{${DETECTION_WATCH_NAMES.join(',')}}`, ...rstestGlobs]), ]; +/** + * Watched non-recursively in every folder ancestor, which folder-relative + * 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, + ...LOCKFILE_NAMES, +].join(',')}}`; + const readRstestGlobs = (folder: vscode.WorkspaceFolder): readonly string[] => { const configured = vscode.workspace .getConfiguration('rstack.rstest', folder.uri) @@ -188,8 +200,16 @@ export const detectFolder = async ( ).find((candidate) => rstackConfigFiles.some((uri) => uri.toString() === candidate.toString()), )?.fsPath; + // 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 rslintMode = decideRslintMode({ - nativeConfigPaths: rslintConfigFiles.map((uri) => uri.fsPath), + nativeConfigPaths: ancestorConfigPath + ? [ancestorConfigPath] + : rslintConfigFiles.map((uri) => uri.fsPath), rootRstackConfigPath, }); @@ -199,6 +219,7 @@ export const detectFolder = async ( configFiles: rslintConfigFiles, rstackConfigFiles, mode: rslintMode, + ancestorConfigPath, }, rstest: { detected: rstestConfigFiles.length > 0 || rstackConfigFiles.length > 0, @@ -225,7 +246,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 +256,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(); @@ -369,28 +391,44 @@ export class DetectionService implements vscode.Disposable { watcher.dispose(); } this.#watchers = []; + // 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(); + }; + const patterns: vscode.RelativePattern[] = []; + 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( - new vscode.RelativePattern(folder, pattern), - ); - this.#watchers.push( - watcher, - watcher.onDidCreate(onEvent), - watcher.onDidChange(onEvent), - watcher.onDidDelete(onEvent), - ); + patterns.push(new vscode.RelativePattern(folder, pattern)); + } + for (const directory of ancestorDirectories(folder.uri.fsPath)) { + ancestors.add(directory); } } + for (const directory of ancestors) { + patterns.push( + new vscode.RelativePattern( + vscode.Uri.file(directory), + ANCESTOR_WATCH_PATTERN, + ), + ); + } + for (const pattern of patterns) { + 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/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 30b1ded..03b4935 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 } from './resolution'; +import { + ancestorDirectories, + type RslintMode, + RSLINT_CONFIG_NAMES, +} from './resolution'; import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, isConfigSourceChangeDuringTransaction, @@ -68,15 +72,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(',')}}`; @@ -444,6 +441,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}`, @@ -565,7 +565,6 @@ export class Rslint implements Disposable { ); }); - this.installConfigRefreshWatchers(mode); const retried = await retryConfigRefreshOnSourceChange( async () => this.requestConfigRefresh('initial'), async () => this.requestConfigRefresh('config-change'), @@ -648,8 +647,26 @@ 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') { + // 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, + )) { + 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); @@ -663,9 +680,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 4639551..4c7c0cb 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -81,6 +81,7 @@ class RslintController implements StackController { this.#context = context; this.#snapshot = context.detection; this.#logger = new Logger(context.output); + this.logAncestorConfigs(); this.startRuntimeManager(); this.#subscriptions.push( @@ -95,6 +96,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( @@ -286,6 +288,17 @@ class RslintController implements StackController { return runtime; } + /** Makes a stray `rslint.config.*` in an ancestor such as `~` traceable. */ + private logAncestorConfigs(): void { + for (const entry of this.detectedFolders()) { + const configPath = entry.stacks.rslint.ancestorConfigPath; + if (configPath === undefined) continue; + this.#logger?.info( + `Using ${configPath} for ${entry.folder.name}: the folder has no rslint.config.* of its own`, + ); + } + } + private detectedFolders() { return (this.#snapshot?.foldersFor('rslint') ?? []).filter( (entry) => entry.stacks.rslint.mode !== undefined, diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index f774e63..6bea0ec 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -40,6 +40,58 @@ 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); + } +} + +/** + * 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, +): string | undefined { + for (const directory of ancestorDirectories(folderPath)) { + for (const name of RSLINT_CONFIG_NAMES) { + const candidate = path.join(directory, name); + try { + if (!fs.statSync(candidate).isFile()) continue; + } catch { + continue; + } + return findPackageJsonUncached('@rslint/core', folderPath) + ? candidate + : undefined; + } + } + return undefined; +} + export interface RslintModeSignals { readonly nativeConfigPaths: readonly string[]; readonly rootRstackConfigPath?: string; diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index baacaa3..d1379b2 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -83,6 +83,8 @@ 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 config above the folder that made it native. */ + 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..b0855da 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -1,6 +1,8 @@ +import fs from 'node:fs'; import path from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; import { + findAncestorRslintConfig, resolveRslint, RslintResolutionError, } from '../../../src/stacks/lint/resolution'; @@ -138,3 +140,47 @@ describe('resolveRslint', () => { } }); }); + +describe('findAncestorRslintConfig', () => { + 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 }); + + expect(findAncestorRslintConfig(app)).toBe(nearer); + }); + + 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 }); + + expect(findAncestorRslintConfig(project)).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(); + + const above = writeConfig(repo, 'rslint.config.js'); + expect(findAncestorRslintConfig(vendored)).toBe(above); + }); +}); 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() {} }),