diff --git a/CONTEXT.md b/CONTEXT.md index 17d5822..9733171 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 config signal may come from an ancestor directory. 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, and lint then picks each document's config source by **Ownership**. 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 @@ -25,7 +25,7 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **Shim** — the module rstack-cli ships per tool that loads the Rstack config and exposes that tool's section through the tool's ordinary explicit-config channel. The extension points upstream machinery at the shim rather than re-implementing Rstack config semantics. - **Bridged project** — a test project the extension synthesizes for a directory whose test signal is a Rstack config, wired to the shim. _Avoid_: virtual project, rstack project. - **Config root** — the directory a tool's config is loaded from, which is also the directory the tool's process stands in. For the fmt server the editor anchors it at the workspace folder root, so it loads the config a terminal opened on that folder would, and a subproject that needs its own config becomes its own workspace folder. The test stack does not share this anchor: a project's cwd is set per project (for native configs, upstream's config-file-directory rule). _Avoid_: config directory, project root. -- **Ownership** — the editor-side rule choosing one config source for a tool's unit of work when both a native config and a Rstack config are present: the atomic tool's native config wins and the bridge yields. The unit is the tool's own — a project for test (one per config directory), a workspace folder for lint (one config choice per folder, locked for each lint runtime's lifetime). This rule exists only in the editor; upstream CLIs never face the choice, since each reads only its own config. +- **Ownership** — the editor-side rule choosing one config source for a tool's unit of work when both a native config and a Rstack config are present. The unit is the tool's own — a project for test (one per config directory; the native config wins and the bridge yields), a document for lint (the nearest config on the document's own ancestor chain wins: an `rslint.config.*` up to the folder root, then a root `rstack.config.*`, then an `rslint.config.*` above the folder; a document with none is not linted). A config that is not an ancestor of the document never counts. The choice is locked per lint runtime, not per folder, so one folder can run both kinds. This rule exists only in the editor; upstream CLIs never face the choice, since each reads only its own config. ## test @@ -36,8 +36,8 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **Rslint core** — one `@rslint/core` package directory, identified by its real path (two copies of the same version are two cores; a symlink to one copy is that copy). Everything a lint runtime runs — the Go binary, config host, protocol version, plugin host — derives from one Rslint core. _Avoid_: core (bare), installation, binary. - **Lint runtime** — the lint machinery serving one Rslint core inside one workspace folder: one lint worker, the Go process it spawns, and one language client. Unrelated to the Node runtimes above. _Avoid_: server, instance, coordinator. - **Lint worker** — the process the extension ships and runs for one lint server: it hosts Rslint's JS side (config evaluation, plugin rules) on a User Node runtime with its cwd at the workspace folder root, and fronts the Go `rslint --lsp` process it spawns, so the editor sees one language server. _Avoid_: lint host, lint proxy, lint server (that is what the worker presents, not what it is). -- **Bridged folder** — a workspace folder whose lint runs against the Rstack config: no native `rslint.config.*` anywhere in the folder, a `rstack.config.*` at its root, and the lint worker pinned to rstack's shipped shim for its whole lifetime. _Avoid_: bridged workspace, rstack folder. -- **Native folder** — a workspace folder whose lint runs against its own `rslint.config.*`, exactly as the standalone Rslint extension would. +- **Bridged runtime** — a lint runtime that runs against the folder's root `rstack.config.*`, its lint worker pinned to rstack's shipped shim for its whole lifetime. It serves the documents of its folder that no `rslint.config.*` between them and the folder root governs. _Avoid_: bridged folder, bridged workspace, rstack folder. +- **Native runtime** — a lint runtime that runs against `rslint.config.*` discovery, exactly as the standalone Rslint extension would. It serves the documents whose ancestor chain reaches an `rslint.config.*` first. _Avoid_: native folder. - **Inline directive** — a source comment that toggles lint rules for a scope: `rslint-disable`, `rslint-enable`, `rslint-disable-line`, `rslint-disable-next-line`, with the `eslint-` prefix accepted as an exact equivalent. Rule ids are comma-separated; a bare directive applies to all rules. _Avoid_: disable comment, suppression comment. - **Rule docs link** — the documentation URL derived from a rule id alone (one base URL plus the id, no per-rule data). Best-effort by design: a mistyped or brand-new rule id yields a dead link, never an error. _Avoid_: rule doc URL, docs href. diff --git a/docs/adr/0003-lint-through-editor-worker.md b/docs/adr/0003-lint-through-editor-worker.md index 6698d27..0433fb5 100644 --- a/docs/adr/0003-lint-through-editor-worker.md +++ b/docs/adr/0003-lint-through-editor-worker.md @@ -25,7 +25,7 @@ Rslint's language server is two halves: the Go process (`rslint --lsp`) lints na - **Lint gains a Node floor it never had.** A native folder that lints on VS Code's Node today reports `version mismatch` and starts nothing when no User Node runtime clears the ADR 0001 floor. Accepted deliberately: one worker, one path, one floor (ADR 0001 already rejected per-project floors), and this is the debt that ADR named. - **Resolution follows one chain, mirroring `rs lint`.** For a bridged folder: `rstack` from the folder root → `@rslint/core` from rstack's directory (the transitive copy `rs lint` itself imports; a pnpm project declaring only `rstack` cannot resolve `@rslint/core` from its root) → the Go binary through that core's `resolveRslintBinary()`. For a native folder the chain starts at `@rslint/core` from the **document's own directory** (the #1617 sync; before it, from the folder root). The extension walks the chain as far as the **core directory** — `fs.stat`, `package.json` reads and semver comparisons, no project code loaded, so inside the load bound and legitimately on the VS Code Node runtime, exactly as fmt resolves the `rs` bin — and gates there; the worker receives `--core [--config ]` and takes the last hop itself, calling that core's `resolveRslintBinary()` on the User Node runtime, since it is a JS export of the project's package. Floors follow the evidence in `SUPPORT_MATRIX`: `@rslint/core >= 0.8.0` is the first release with explicit protocol-2 config selection, while `rstack >= 0.7.0` is the first release whose shim supplies `basePath` and itself pins `@rslint/core` 0.9.0; one answer to "which rstack does the extension support" is worth more than keeping older releases running. - **One override, and it names a core, not a binary.** `rstack.rslint.binPath` / `customBinPath` are removed in favour of `rstack.rslint.corePath` — the setting upstream introduced in rslint #1617: a path to an `@rslint/core` package directory (or its `package.json`), resource-scoped, from which the binary, config host, protocol version and plugin host all derive. In a bridged folder it overrides the rstack → `@rslint/core` hop only; the shim stays rstack's. A binary chosen independently of its core cannot be supported: the two must speak the same protocol. The rest of #1617 — per-document core resolution, one runtime per physical installation — has since been synced (issue #13): a **Lint runtime** is now one Rslint core inside one workspace folder, resolved per open document and refcounted by it, so a folder runs as many workers as its files have distinct cores (a bridged folder always exactly one, rstack's) and none at all while nothing is open. The worker never noticed: it still takes explicit `--core` / `--config` paths, which is precisely why that change did not touch it. -- **Ownership is per folder, native wins.** One server holds one config choice for its lifetime (the supported config protocols lock `configPath` per process), and explicit and automatic modes cannot mix, so a folder is bridged only when no `rslint.config.*` exists anywhere in it and a `rstack.config.*` sits at its root; a subdirectory `rstack.config.*` lights nothing (`rs lint` in a terminal reads its cwd only — the same reason ADR 0002 rejected deepest-config-wins for fmt). Detection lights a bridged folder on the file's presence and never reads it: a `rstack.config.*` without `define.lint()` runs an empty config, as `rs lint` does. +- _Superseded by [ADR 0006](0006-per-document-lint-ownership.md): "folder" here is the workspace folder, a narrower unit than the intended one; ownership follows each document's ancestor chain._ **Ownership is per folder, native wins.** One server holds one config choice for its lifetime (the supported config protocols lock `configPath` per process), and explicit and automatic modes cannot mix, so a folder is bridged only when no `rslint.config.*` exists anywhere in it and a `rstack.config.*` sits at its root; a subdirectory `rstack.config.*` lights nothing (`rs lint` in a terminal reads its cwd only — the same reason ADR 0002 rejected deepest-config-wins for fmt). Detection lights a bridged folder on the file's presence and never reads it: a `rstack.config.*` without `define.lint()` runs an empty config, as `rs lint` does. - **Config changes refresh, mode changes restart.** Rslint has a live refresh (`rslint/configRefresh` with the same `configPath`), unlike `rs fmt --lsp`, so the extension keeps its watcher-driven refresh — extended, for a bridged folder, with the root `rstack.config.*` — and the worker re-stamps `protocolVersion` and its `configPath` on every refresh (the extension does not know either). Only a native ↔ bridged flip, or a dependency change the refresh cannot absorb, restarts the server. This is the "diverge only when the tool forces it" rule: rslint can refresh, fmt cannot. - **Failure states mirror fmt.** No `rstack`, no `@rslint/core`, or a config importing an absent package → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Config-import failures are classified where the worker still has the loader's structured error and carried to the editor as data; the live Go server remains available for a later refresh. - The lint copy diverges further from upstream: the reverse-request adapter and plugin pool move into the worker unchanged in logic, and the extension-side `Rslint.ts` keeps only the language-client half. Recorded as an adaptation in `packages/vscode/AGENTS.md`. diff --git a/docs/adr/0006-per-document-lint-ownership.md b/docs/adr/0006-per-document-lint-ownership.md new file mode 100644 index 0000000..29f1bef --- /dev/null +++ b/docs/adr/0006-per-document-lint-ownership.md @@ -0,0 +1,36 @@ +--- +status: accepted +--- + +# Lint ownership follows the document's ancestor chain + +ADR 0003 wrote lint ownership down as a workspace-folder decision: any `rslint.config.*` anywhere in the folder made the whole folder native, and only a folder with none could be bridged to a root `rstack.config.*`. That wording pinned ownership to the editor's folder, which is narrower than the rule it meant to state — a config governs the directory tree below it — and the implementation followed the wording. Issue #85 shows the gap. A root `rstack.config.*` with `define.lint()` stops working as soon as one subdirectory adds its own `rslint.config.*`: every file in the folder goes to the native Go server, the files outside that subdirectory fall outside every config boundary, and they get no diagnostics while the status bar still says `running`. + +The constraint ADR 0003 cited is real but does not force the folder granularity. Rslint's server locks its config choice per **process** (`internal/lsp/server.go`: "Changing that choice requires a new process"), not per folder. Since the rslint #1617 sync, `RuntimeManager` resolves a runtime per document and keys it on `folder + core identity + shim`, so one folder can already run a bridged runtime and a native runtime side by side. The folder-level decision was the only thing preventing it. + +**Decision.** Ownership is decided per document, from the configs on the document's own ancestor chain (`decideDocumentMode` in `stacks/lint/resolution.ts`). Walking up from the document's directory: + +1. The first `rslint.config.*` between the document's directory and the workspace folder root, both included → **native**. +2. Otherwise, a `rstack.config.*` at the folder root → **bridged** (rstack → `@rslint/core` from rstack's directory, shim pinned, as in ADR 0003). +3. Otherwise, the nearest `rslint.config.*` above the folder, skipping `node_modules` and counting only while `@rslint/core` resolves from the folder (#89) → **native**. +4. Otherwise the lint stack does not serve the document: no `didOpen`, no diagnostics. + +A sibling or descendant config never counts. A root `rslint.config.*` beside a root `rstack.config.*` still makes every document native, by rule 1. Detection stays per folder and only collects the signals; there is no folder mode any more. + +Rule 4 matches the `rslint` CLI, which lints only files inside some config's boundary: run at the root of a tree whose only config is `packages/legacy/rslint.config.ts`, it lints the two files under `packages/legacy` and never touches `src/index.ts`. + +## Considered options + +**Keep folder ownership and document the limitation** — leaves #85 as is: a mixed repository gets silent gaps with a healthy status. Rejected. + +**Let the root `rstack.config.*` win whenever it exists** — breaks the documents a nested `rslint.config.*` is meant to govern, and contradicts the `rslint` CLI run in that subdirectory. Rejected. + +**Per-document ownership over the whole folder's configs** (any config in the folder counts, nearest by distance) — a sibling directory's config would decide documents it can never govern in either CLI. Rejected in favour of the ancestor chain. + +## Consequences + +- **One folder, several runtimes of both modes.** A bridged and a native runtime can serve one folder at once. A new `rslint.config.*` in a subdirectory changes the detection signature; the reconcile moves only the documents under it to a native runtime, and the bridged runtime keeps serving the other documents without a restart (`e2e/lint/suite-document-ownership`). +- **Known divergence from `rs lint`.** `rs lint` run at the folder root lints nested files with the root Rstack config. The editor lints a file under `packages/legacy/rslint.config.ts` with that config instead. The editor cannot match both CLIs; the nearest config matches the CLI a user runs in that directory (`rslint` in `packages/legacy`), and it is what the native config's author asked for. Accepted. +- **Documents outside every config are not linted.** Before, a folder with a nested config handed every document to the native server, which linted nothing outside that config anyway; now the editor does not open them on a server at all, as the CLI does not lint them. +- **Detection reads every `rslint.config.*`.** The folder's native config list is now an ownership input rather than a detection signal, so its `findFiles` is unbounded like the Rstack config list. +- ADR 0003's "Ownership is per folder, native wins" consequence is superseded. Its other consequences (resolution chains, refresh vs restart, failure states) are unchanged; a native ↔ bridged flip still replaces the runtime, now for the documents that flipped only. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 573692a..a27a64f 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -17,7 +17,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. Upstream's plugin-host failure toast becomes a status verdict (adaptation 7). Rslint LSP tracing shares the **Rstack: Rslint** Output channel to preserve the four-channel cap; see `stacks/lint/index.ts`. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 6. **Node runtime selection** (lint, test, fmt) — the Node a project-loading child process runs on is a **User Node runtime** chosen by the extension against one uniform floor, never assumed from PATH; the recovery path is the user's own shell, and the dividing line is the **load bound** (terms in CONTEXT.md; the full rule and rationale in `docs/adr/0001-node-runtime-selection.md`). All three callers — the lint worker, the rstest worker and the `rs fmt --lsp` server — take the decision from the one shared module (`shared/nodeResolution.ts`) and share one escape hatch, the resource-scoped `rstack.nodeExecutable` (`shared/nodeExecutableSetting.ts`); each appends its own consequence to the shared preflight message. -7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — CONTEXT.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged folder passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because every supported config protocol locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. Plugin-host startup failures use a separate, unclassified `plugin` verdict on `rstack/rslintConfigDependency`, report `disabled` (a plugin could not be loaded), and recover through the dependency poll. +7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — CONTEXT.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged runtime passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because every supported config protocol locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. Plugin-host startup failures use a separate, unclassified `plugin` verdict on `rstack/rslintConfigDependency`, report `disabled` (a plugin could not be loaded), and recover through the dependency poll. 8. **Self-documenting Rslint diagnostics** — client-side providers parse Inline directives into per-rule hover, DocumentLink and underline-decoration affordances (the hover renders `Rslint(rule-id)`, the shape VS Code gives the published diagnostics), and the router enriches today's `[rule-id] message` diagnostics with a derived Rule docs link. No rule metadata or network lookup is bundled (ADR 0004). The hover provider yields whenever the owning language client's resolved capabilities advertise `hoverProvider`; an optional `Rslint.onClosed` hook identity-safely prunes the controller's capability mirror; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively. 9. **Color env parity with the CLI** (test) — upstream hard-codes `FORCE_COLOR: '1'` into the worker's spawn env; ours mirrors the CLI's `getForceColorEnv` (rstest `packages/core/src/utils/logger.ts`) instead (`stacks/test/shared/colorEnv.ts`): the master injects `FORCE_COLOR=1` into the composed spawn env only when neither `FORCE_COLOR` nor `NO_COLOR` is already set (marking the injection with `RSTACK_FORCE_COLOR_INJECTED`), and the worker retracts the marked injection right after config load if the config set `NO_COLOR` — the CLI's own decision point. Otherwise a project whose config sets `process.env.NO_COLOR` (rstack-cli does) hits Node's "'NO_COLOR' env is ignored" warning in every pool process. A user-set `FORCE_COLOR` beside a config-set `NO_COLOR` still warns, exactly as the bare CLI does. @@ -47,11 +47,12 @@ 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. +- **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 in a folder without a root `rstack.config.*`, a document with no `rslint.config.*` between it and the folder root uses the nearest one above the folder (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. +- **Lint ownership is per document, not per folder** (ADR 0006). The supported config protocols lock the config choice per _process_, and runtimes are keyed `folder + core + shim`, so one folder runs a bridged and a native runtime side by side. `decideDocumentMode` walks the document's ancestor chain only: an `rslint.config.*` between the document and the folder root → native; else a root `rstack.config.*` → bridged; else an `rslint.config.*` above the folder → native; else the document is not served, as the `rslint` CLI skips files outside every config. A sibling or descendant config never counts, so a subdirectory `rslint.config.*` no longer takes the root documents away from `define.lint()` (#85). Known divergence: `rs lint` at the root lints nested files with the root config; the editor uses the nearest `rslint.config.*`, which is what `rslint` run in that directory does. +- The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can bridge a document, 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. -- The lint worker is deliberately vscode-free so it can move upstream whole. It takes explicit `--core` / `--config` native paths, writes logs only to stderr because stdout is LSP, and owns the Go child plus config/plugin lifecycles. Config edits use `rslint/configRefresh` with the same pinned path; a native ↔ bridged ownership change replaces the whole folder runtime because the supported config protocols lock that choice for the process lifetime. +- The lint worker is deliberately vscode-free so it can move upstream whole. It takes explicit `--core` / `--config` native paths, writes logs only to stderr because stdout is LSP, and owns the Go child plus config/plugin lifecycles. Config edits use `rslint/configRefresh` with the same pinned path; a document whose ownership flips native ↔ bridged moves to another runtime, because the supported config protocols lock that choice for the process lifetime; documents whose ownership did not change keep their runtime. - The test × `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Bridged projects resolve `@rstest/core` from the resolved rstack package directory, mirroring lint, so rstack's dependency remains visible under isolated installs. Never re-implement rstack config semantics in the extension. - Rstest's upstream VS Code extension deep-imports `quoteFilter` from core to mark exact file filters. The published package does not export that helper, so our copy lives in `stacks/test/vendored/coreInternals.ts` beside the other core internals; keep it byte-identical when syncing filter behavior. - The fmt stack is an LSP client: one `rs fmt --lsp` server per detected workspace folder, spawned at the **folder root** even when a deeper `rstack.config.*` exists. Deepest-config-wins was removed deliberately — `rs fmt` loads one config from its cwd with no upward walk, so anchoring deeper made the editor disagree with `rs fmt` in a terminal; a subproject that needs its own fmt config becomes its own workspace folder. The stack registers **no** `DocumentFormattingEditProvider`: the client registers the provider from the server's `documentFormattingProvider` capability, and adding one by hand would double-register. A config create/change/delete **restarts** the owning folder's server (the server caches its config for its process lifetime and has no config-change message), which is also why the stack watches `RSTACK_CONFIG_GLOB` itself instead of relying on detection — a detection signature records which config files exist, not their contents. A detection pass keeps healthy servers and restarts failed ones in place (`isFailedFmtState`) — lockfile events notify even when the folder set is unchanged, precisely so a completed install or upgrade is retried without a manual restart. There is no stdin fallback below `SUPPORT_MATRIX.rstack`; that is a version gate, not an omission. **Nested workspace folders are a documented limitation, by decision**: when a folder and its subdirectory are both workspace folders and both detect fmt, the parent's per-folder selector also matches the nested folder's files, and which server VS Code hands the request to is not defined — the supported shape is subprojects as _sibling_ workspace folders (or only the subproject opened), not parent-plus-child. Routing (lint's `WorkspaceDocumentRouter` shape) was considered and deferred. Why all of it: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 4cf1cd3..7baf3bf 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, provided `@rslint/core` is installed for the folder | +| Rslint | `rslint.config.{js,mjs,ts,mts}` anywhere in the folder, `rstack.config.*` at the folder root, or the nearest `rslint.config.*` above the folder, provided `@rslint/core` is installed for the folder. Each file is linted with the nearest of these on its own path: an `rslint.config.*` in its directory or a parent up to the folder root, then the root `rstack.config.*`, then the config above the folder. A file none of them covers is not linted, as with the `rslint` CLI | | 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/document-ownership/.nvmrc b/packages/vscode/e2e/lint/fixtures/document-ownership/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/document-ownership/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/packages/vscode/e2e/lint/fixtures/document-ownership/package.json b/packages/vscode/e2e/lint/fixtures/document-ownership/package.json new file mode 100644 index 0000000..f0410e0 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/document-ownership/package.json @@ -0,0 +1,14 @@ +{ + "name": "rstack-editor-fixture-lint-document-ownership", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: a root rstack.config.ts with define.lint() beside a nested rslint.config.ts; each document is linted by the config on its own ancestor chain.", + "dependencies": { + "@rslint/core": "0.9.4", + "rstack": "0.8.0" + }, + "devDependencies": { + "jiti": "2.7.0" + } +} diff --git a/packages/vscode/e2e/lint/fixtures/document-ownership/packages/legacy/rslint.config.ts b/packages/vscode/e2e/lint/fixtures/document-ownership/packages/legacy/rslint.config.ts new file mode 100644 index 0000000..3c0be79 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/document-ownership/packages/legacy/rslint.config.ts @@ -0,0 +1,9 @@ +// Owns the documents under `packages/legacy` only (ADR 0006). +export default [ + { + files: ['**/*.ts'], + rules: { + 'no-empty': 'error', + }, + }, +]; diff --git a/packages/vscode/e2e/lint/fixtures/document-ownership/packages/legacy/src/index.ts b/packages/vscode/e2e/lint/fixtures/document-ownership/packages/legacy/src/index.ts new file mode 100644 index 0000000..222d61d --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/document-ownership/packages/legacy/src/index.ts @@ -0,0 +1,8 @@ +// Violates both `no-debugger` (root rstack.config.ts) and `no-empty` (nested +// rslint.config.ts); which one reports shows which config owns this file. +export function check(value: boolean): boolean { + debugger; + if (value) { + } + return value; +} diff --git a/packages/vscode/e2e/lint/fixtures/document-ownership/packages/second/src/index.ts b/packages/vscode/e2e/lint/fixtures/document-ownership/packages/second/src/index.ts new file mode 100644 index 0000000..222d61d --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/document-ownership/packages/second/src/index.ts @@ -0,0 +1,8 @@ +// Violates both `no-debugger` (root rstack.config.ts) and `no-empty` (nested +// rslint.config.ts); which one reports shows which config owns this file. +export function check(value: boolean): boolean { + debugger; + if (value) { + } + return value; +} diff --git a/packages/vscode/e2e/lint/fixtures/document-ownership/pnpm-workspace.yaml b/packages/vscode/e2e/lint/fixtures/document-ownership/pnpm-workspace.yaml new file mode 100644 index 0000000..0330b39 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/document-ownership/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +# E2E fixture install config; rationale in packages/vscode/e2e/setupFixtures.mjs. +minimumReleaseAgeExclude: + - rstack + - '@rslint/*' + - '@rstest/core' + - '@rsbuild/core' + - '@rslib/core' + - '@rstackjs/*' + - rsbuild-plugin-dts + - '@rspack/*' diff --git a/packages/vscode/e2e/lint/fixtures/document-ownership/rstack.config.ts b/packages/vscode/e2e/lint/fixtures/document-ownership/rstack.config.ts new file mode 100644 index 0000000..d8e0634 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/document-ownership/rstack.config.ts @@ -0,0 +1,13 @@ +// Lints every document outside `packages/legacy` through the lint bridge. The +// glob deliberately matches nested files too, so a nested document handed to +// the bridged runtime by mistake would report `no-debugger`. +import { define } from 'rstack'; + +define.lint([ + { + files: ['**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]); diff --git a/packages/vscode/e2e/lint/fixtures/document-ownership/src/index.ts b/packages/vscode/e2e/lint/fixtures/document-ownership/src/index.ts new file mode 100644 index 0000000..222d61d --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/document-ownership/src/index.ts @@ -0,0 +1,8 @@ +// Violates both `no-debugger` (root rstack.config.ts) and `no-empty` (nested +// rslint.config.ts); which one reports shows which config owns this file. +export function check(value: boolean): boolean { + debugger; + if (value) { + } + return value; +} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index b5bea90..53cb416 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -326,6 +326,13 @@ async function main(): Promise { workspace: sharedFixture('rstack'), tests: suiteDir('suite-bridge'), }, + { + // A root `rstack.config.ts` and a nested `rslint.config.ts` in one + // folder: one bridged and one native runtime side by side (ADR 0006). + name: 'Per-document ownership tests', + workspace: fixture('document-ownership'), + tests: suiteDir('suite-document-ownership'), + }, { name: 'Missing config dependency tests', workspace: fixture('missing-config-dependency'), diff --git a/packages/vscode/e2e/lint/suite-document-ownership/document-ownership.test.ts b/packages/vscode/e2e/lint/suite-document-ownership/document-ownership.test.ts new file mode 100644 index 0000000..91cd8a7 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-document-ownership/document-ownership.test.ts @@ -0,0 +1,118 @@ +// Per-document lint ownership (ADR 0006, issue #85). The folder holds a root +// `rstack.config.ts` (`no-debugger`) and `packages/legacy/rslint.config.ts` +// (`no-empty`); every fixture source violates both rules, so the rule a +// document reports names the config that owns it. +import * as assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import type { StackState } from '../../../src/types'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics, +} from '../utils/diagnostics'; +import { extensionExports } from '../utils/extension'; + +const nativeConfigSource = `export default [ + { + files: ['**/*.ts'], + rules: { + 'no-empty': 'error', + }, + }, +]; +`; + +function workspaceRoot(): string { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) throw new Error('VS Code test workspace is unavailable'); + return folder.uri.fsPath; +} + +function getRuntimeStates(): ReadonlyMap { + const exports = extensionExports().getStackExports('rslint') as + { getRuntimeStates?: () => ReadonlyMap } | undefined; + assert.ok(exports?.getRuntimeStates, 'lint stack exports are unavailable'); + return exports.getRuntimeStates(); +} + +const hasRule = (diagnostics: readonly vscode.Diagnostic[], rule: string) => + diagnostics.some((diagnostic) => diagnosticRuleIdIncludes(diagnostic, rule)); + +/** Reports `rule` and not `other`: exactly one config owns the document. */ +const ownedBy = + (rule: string, other: string) => + (diagnostics: vscode.Diagnostic[]): boolean => + hasRule(diagnostics, rule) && !hasRule(diagnostics, other); + +const bridged = ownedBy('no-debugger', 'no-empty'); +const native = ownedBy('no-empty', 'no-debugger'); + +async function open(...segments: string[]): Promise { + const document = await vscode.workspace.openTextDocument( + path.join(workspaceRoot(), ...segments, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document, { preview: false }); + return document; +} + +suite('Per-document lint ownership', function () { + this.timeout(120_000); + + const secondConfigPath = path.join( + workspaceRoot(), + 'packages', + 'second', + 'rslint.config.ts', + ); + + teardown(() => { + fs.rmSync(secondConfigPath, { force: true }); + }); + + test('lints each document with its nearest config and moves only the documents under a new one', async () => { + const root = await open(); + const legacy = await open('packages', 'legacy'); + const second = await open('packages', 'second'); + await Promise.all([ + waitForRslintDiagnostics(root, bridged), + waitForRslintDiagnostics(legacy, native), + waitForRslintDiagnostics(second, bridged), + ]); + + // One folder, two runtimes: the bridge and the native config's. + const keys = [...getRuntimeStates().keys()]; + // The bridged runtime's key carries rstack's shim path (`runtimeKey`). + const bridgedKeys = keys.filter((key) => key.includes('rslintConfig.js')); + assert.equal( + bridgedKeys.length, + 1, + `one bridged runtime: ${keys.join(' | ')}`, + ); + assert.equal(keys.length, 2, `one native runtime: ${keys.join(' | ')}`); + const [bridgedKey] = bridgedKeys; + + // A restart, even under the same key, passes through `starting` or drops + // the entry, so sampling the state throughout catches one. + const violations: string[] = []; + const sampler = setInterval(() => { + const state = getRuntimeStates().get(bridgedKey); + if (state?.kind !== 'running') { + violations.push(state === undefined ? 'missing' : state.kind); + } + }, 25); + try { + fs.writeFileSync(secondConfigPath, nativeConfigSource, 'utf8'); + await waitForRslintDiagnostics(second, native); + await waitForRslintDiagnostics(root, bridged); + await waitForRslintDiagnostics(legacy, native); + } finally { + clearInterval(sampler); + } + assert.deepStrictEqual( + violations, + [], + 'the bridged runtime serving the root document must not restart', + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-document-ownership/index.ts b/packages/vscode/e2e/lint/suite-document-ownership/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-document-ownership/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts index a72b34c..974694e 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts @@ -6,10 +6,10 @@ // // Adaptations from upstream (see packages/vscode/AGENTS.md, adaptation 7): // - `RuntimeManager`'s fifth constructor parameter is an options object. It -// carries upstream's `documentIsOpen` plus `folderMode`, the detection gate: -// a folder detection never lit up for Rslint has no mode, and its documents -// are never linted. The fake harness reports every folder as `native`, which -// is upstream's only mode. +// carries upstream's `documentIsOpen` plus `documentMode`, the detection +// gate: a document no detected config governs has no mode and is never +// linted. The fake harness reports every document as `native`, which is +// upstream's only mode. // - `CoreResolver.resolve` takes that mode (and `rstack.rslint.corePath`) as a // third argument; the fake resolver ignores it, exactly as upstream's does. // - `CoreInstallation` carries paths, not module factories (the host never @@ -232,7 +232,7 @@ suite('local-core runtime manager', () => { { // Detection is the gate; every fixture folder here is a native // Rslint folder, which is upstream's only mode. - folderMode: () => 'native', + documentMode: () => 'native', documentIsOpen: (document) => openDocuments.has(document), }, ); diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index d525b56..2adf0e1 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -45,10 +45,15 @@ const SLICES = [ }, { // The ported Rslint suites plus the Rstack lint bridge; the latter uses the - // shared `rstack` fixture. `RSTACK_LINT_E2E_SUITES=` filters + // shared `rstack` fixture, and per-document ownership installs its own. `RSTACK_LINT_E2E_SUITES=` filters // which suites run. name: 'lint', - fixtures: ['lint', 'lint-dependency-recovery', 'rstack'], + fixtures: [ + 'lint', + 'lint-dependency-recovery', + 'lint-document-ownership', + 'rstack', + ], entry: 'tests-dist/e2e/lint/runTest.js', compile: true, }, diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index c135a44..d3cb711 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -57,6 +57,12 @@ export const FIXTURES = { 'fixtures', 'dependency-recovery', ), + 'lint-document-ownership': path.join( + here, + 'lint', + 'fixtures', + 'document-ownership', + ), }; export const FIXTURE_NAMES = Object.keys(FIXTURES); diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index f991360..088d167 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -8,7 +8,6 @@ import { } from './types'; import { ancestorDirectories, - decideRslintMode, findAncestorRslintConfig, RSLINT_CONFIG_NAMES, } from './stacks/lint/resolution'; @@ -140,7 +139,8 @@ const findFiles = async ( // the stacks rescan their own configs, so a truncated list still lights // them up. Rows whose URI list is consumed as-is pass `undefined` // (unbounded): the rstack configs are the sole input to the test stack's - // bridge sync, where silent truncation would drop whole projects. + // bridge sync, where silent truncation would drop whole projects, and the + // rslint configs decide each document's lint ownership. maxResults: number | undefined = MAX_CONFIG_FILES, ): Promise => vscode.workspace.findFiles( @@ -187,7 +187,8 @@ export const detectFolder = async ( const [rstackConfigFiles, rslintConfigFiles, binPath, rstestConfigFiles] = await Promise.all([ findFiles(folder, RSTACK_CONFIG_GLOB, undefined), - findFiles(folder, RSLINT_CONFIG_GLOB), + // Unbounded: per-document ownership reads every native config. + findFiles(folder, RSLINT_CONFIG_GLOB, undefined), probeFmtBin(folder), Promise.all(rstestGlobs.map((glob) => findFiles(folder, glob))).then( (matches) => matches.flat(), @@ -200,25 +201,29 @@ 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. + // Like the Go server, look above the folder for the documents no config + // inside it governs: none can exist under a root `rslint.config.*`, and a + // root `rstack.config.*` claims them first — an ancestor never displaces a + // bridged runtime. Ownership itself is decided per document. + const rootRslintConfigs = new Set( + RSLINT_CONFIG_NAMES.map((name) => + vscode.Uri.joinPath(folder.uri, name).toString(), + ), + ); const ancestorConfigPath = - rslintConfigFiles.length === 0 && rootRstackConfigPath === undefined + rootRstackConfigPath === undefined && + !rslintConfigFiles.some((uri) => rootRslintConfigs.has(uri.toString())) ? findAncestorRslintConfig(folder.uri.fsPath) : undefined; - const rslintMode = decideRslintMode({ - nativeConfigPaths: ancestorConfigPath - ? [ancestorConfigPath] - : rslintConfigFiles.map((uri) => uri.fsPath), - rootRstackConfigPath, - }); const stacks: Record = { rslint: { - detected: rslintMode !== undefined, + detected: + rslintConfigFiles.length > 0 || + rootRstackConfigPath !== undefined || + ancestorConfigPath !== undefined, configFiles: rslintConfigFiles, rstackConfigFiles, - mode: rslintMode, ancestorConfigPath, }, rstest: { @@ -246,7 +251,7 @@ const signatureOf = (snapshot: DetectionSnapshot): string => .map((uri) => uri.toString()) .sort() .join(','); - return `${stack}:${detection.detected ? 1 : 0}:${detection.mode ?? ''}:${detection.binPath ?? ''}:${detection.ancestorConfigPath ?? ''}:${files}`; + return `${stack}:${detection.detected ? 1 : 0}:${detection.binPath ?? ''}:${detection.ancestorConfigPath ?? ''}:${files}`; }).join('|'); return `${entry.folder.uri.toString()}=>${stacks}`; }) diff --git a/packages/vscode/src/stacks/lint/CoreResolver.ts b/packages/vscode/src/stacks/lint/CoreResolver.ts index b3f33c8..d499625 100644 --- a/packages/vscode/src/stacks/lint/CoreResolver.ts +++ b/packages/vscode/src/stacks/lint/CoreResolver.ts @@ -37,14 +37,14 @@ import { import { RslintVersionMismatchError } from './status'; /** - * One **Rslint core** the extension is willing to run, plus the folder-level + * One **Rslint core** the extension is willing to run, plus the per-document * config choice the Lint worker needs on its command line. * * `shimPath` is part of the runtime's identity, not decoration: the supported * config protocols lock `configPath` for the server's lifetime (ADR 0003), so - * a folder that flips native ↔ bridged must get a *new* runtime even when both - * modes resolve the same physical core. Upstream has no bridged mode and keys - * on the core alone. + * a document that flips native ↔ bridged must get a *new* runtime even when + * both modes resolve the same physical core. Upstream has no bridged mode and + * keys on the core alone. */ export interface CoreInstallation { /** Stable physical identity. Never merge cores by version text alone. */ diff --git a/packages/vscode/src/stacks/lint/RuntimeManager.ts b/packages/vscode/src/stacks/lint/RuntimeManager.ts index 58aa441..5b55ce2 100644 --- a/packages/vscode/src/stacks/lint/RuntimeManager.ts +++ b/packages/vscode/src/stacks/lint/RuntimeManager.ts @@ -5,9 +5,9 @@ // diffable against it. // // Adaptations (see packages/vscode/AGENTS.md): -// - Detection is the gate. A document whose folder detection did not light up -// for Rslint is never linted, so the manager asks `folderMode` for the -// folder's ownership choice and detaches the document when it has none. +// - Detection is the gate. A document no detected config governs is never +// linted, so the manager asks `documentMode` for the document's ownership +// choice (ADR 0006) and detaches the document when it has none. // - Settings live under `rstack.rslint.*`. // - A failed resolution reports through the **folder status**, never a // `window.showWarningMessage`: this stack owns no UI chrome (adaptation 4). @@ -69,11 +69,11 @@ export interface DocumentResolutionFailure { export interface RuntimeManagerOptions { /** - * The folder's Rslint ownership choice, or `undefined` when detection did - * not light the folder up. Undefined means "never lint this document" — the + * The document's Rslint ownership choice, or `undefined` when no detected + * config governs it. Undefined means "never lint this document" — the * detection gate, not a failure. */ - readonly folderMode: (folder: WorkspaceFolder) => RslintMode | undefined; + readonly documentMode: (document: TextDocument) => RslintMode | undefined; readonly documentIsOpen?: (document: TextDocument) => boolean; readonly onDocumentFailure?: (failure: DocumentResolutionFailure) => void; /** The document has no outstanding failure: bound, unchanged, or detached. */ @@ -227,9 +227,7 @@ export class RuntimeManager { document: TextDocument, ): Promise { const workspaceFolder = workspace.getWorkspaceFolder(document.uri); - const mode = workspaceFolder - ? this.options.folderMode(workspaceFolder) - : undefined; + const mode = this.options.documentMode(document); if ( !isSupportedWorkspaceDocument(document) || !workspaceFolder || diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 4c7c0cb..65fb8cc 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -11,7 +11,7 @@ import { formatNotInstalledLog } from '../../shared/notInstalled'; import { CoreResolver, type ResolvedCoreRuntime } from './CoreResolver'; import { Logger } from './logger'; import { Rslint } from './Rslint'; -import type { RslintMode } from './resolution'; +import { decideDocumentMode, type RslintMode } from './resolution'; import { registerRuleDocumentationProviders } from './ruleDocumentationProviders'; import { formatCoreSelectionFailure, RuntimeManager } from './RuntimeManager'; import { @@ -86,7 +86,7 @@ class RslintController implements StackController { this.startRuntimeManager(); this.#subscriptions.push( ...registerRuleDocumentationProviders({ - servesDocument: (document) => this.servesDocument(document), + servesDocument: (document) => this.documentMode(document) !== undefined, serverAdvertisesHover: (document) => this.serverAdvertisesHover(document), refreshOn: context.onDidChangeDetection, @@ -188,7 +188,7 @@ class RslintController implements StackController { (resolved) => this.createRuntime(router, context, logger, resolved), logger, { - folderMode: (folder) => this.folderMode(folder), + documentMode: (document) => this.documentMode(document), onDocumentFailure: ({ document, workspaceFolder, @@ -294,24 +294,29 @@ class RslintController implements StackController { 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`, + `Using ${configPath} for documents in ${entry.folder.name} that no rslint.config.* inside the folder governs`, ); } } private detectedFolders() { - return (this.#snapshot?.foldersFor('rslint') ?? []).filter( - (entry) => entry.stacks.rslint.mode !== undefined, - ); - } - - private folderMode(folder: vscode.WorkspaceFolder): RslintMode | undefined { - return this.#snapshot?.forFolder(folder)?.stacks.rslint.mode; + return this.#snapshot?.foldersFor('rslint') ?? []; } - private servesDocument(document: vscode.TextDocument): boolean { + /** Per-document ownership (ADR 0006): one folder can run both modes. */ + private documentMode(document: vscode.TextDocument): RslintMode | undefined { const folder = vscode.workspace.getWorkspaceFolder(document.uri); - return folder !== undefined && this.folderMode(folder) !== undefined; + const entry = folder ? this.#snapshot?.forFolder(folder) : undefined; + if (!entry) return undefined; + const { configFiles, ancestorConfigPath } = entry.stacks.rslint; + return decideDocumentMode({ + documentPath: document.uri.fsPath, + nativeConfigPaths: [ + ...configFiles.map((uri) => uri.fsPath), + ...(ancestorConfigPath === undefined ? [] : [ancestorConfigPath]), + ], + rootRstackConfigPath: entry.rootRstackConfigPath, + }); } private serverAdvertisesHover(document: vscode.TextDocument): boolean { diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index 6bea0ec..cbfc233 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -92,19 +92,51 @@ export function findAncestorRslintConfig( return undefined; } -export interface RslintModeSignals { +export interface DocumentModeSignals { + readonly documentPath: string; + /** + * Every `rslint.config.*` inside the workspace folder, plus the nearest one + * above it (`findAncestorRslintConfig`) when detection found one. + */ readonly nativeConfigPaths: readonly string[]; readonly rootRstackConfigPath?: string; + /** Selects the path flavour; only tests pass it. */ + readonly platform?: NodeJS.Platform; } -/** Native ownership wins; only a root Rstack config can bridge a folder. */ -export function decideRslintMode({ +/** + * Lint ownership for one document (ADR 0006). The supported config protocols + * lock the config choice per process, not per folder, so each document picks + * its own and runtimes of both modes can share a folder. Each config owns its + * directory's subtree and the nearest one on the document's ancestor chain + * wins; in one directory an `rslint.config.*` beats the `rstack.config.*`. A + * document with none is not served, as the `rslint` CLI does not lint files + * outside every config. + */ +export function decideDocumentMode({ + documentPath, nativeConfigPaths, rootRstackConfigPath, -}: RslintModeSignals): RslintMode | undefined { - if (nativeConfigPaths.length > 0) return 'native'; - if (rootRstackConfigPath !== undefined) return 'bridged'; - return undefined; + platform = process.platform, +}: DocumentModeSignals): RslintMode | undefined { + const paths = platform === 'win32' ? path.win32 : path.posix; + // `CoreResolver.normalizeIdentity`'s rule: Windows paths compare case-blind. + const directoryOf = (filePath: string): string => { + const directory = paths.normalize(paths.dirname(filePath)); + return platform === 'win32' ? directory.toLowerCase() : directory; + }; + const nativeDirectories = new Set(nativeConfigPaths.map(directoryOf)); + const bridgedDirectory = + rootRstackConfigPath === undefined + ? undefined + : directoryOf(rootRstackConfigPath); + for (let directory = directoryOf(documentPath); ;) { + if (nativeDirectories.has(directory)) return 'native'; + if (directory === bridgedDirectory) return 'bridged'; + const parent = paths.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } } interface PackageLocation { @@ -179,7 +211,7 @@ export interface ResolveRslintOptions { /** * Where the native walk-up starts (per-document resolution, rslint #1617); * defaults to the folder root. Bridged mode starts at rstack's directory - * instead — one config choice per folder, ADR 0003. + * instead, which resolves from the folder root (ADR 0003). */ readonly documentDirectory?: string; } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index d1379b2..efd2b52 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -81,9 +81,10 @@ export interface StackDetection { readonly detected: boolean; /** Tool-native config files (`rslint.config.*` / `rstest.config.*`). */ 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. */ + /** + * Rslint only: the nearest config above the folder, which lints the + * documents no config inside the folder governs (`decideDocumentMode`). + */ readonly ancestorConfigPath?: string; /** * `rstack.config.*` files governing this folder. A folder can be detected diff --git a/packages/vscode/tests/lintDetection.test.ts b/packages/vscode/tests/lintDetection.test.ts index d0da7f3..f105139 100644 --- a/packages/vscode/tests/lintDetection.test.ts +++ b/packages/vscode/tests/lintDetection.test.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { describe, expect, it, rs } from '@rstest/core'; import vscode from 'vscode'; -import { decideRslintMode } from '../src/stacks/lint/resolution'; import { detectFolder, RSTACK_CONFIG_GLOB } from '../src/detection'; let configPaths: string[] = []; @@ -70,30 +69,10 @@ describe('Rslint folder ownership', () => { configPaths = ordered; const snapshot = await detectFolder(workspaceFolder); expect(snapshot.rootRstackConfigPath).toBe(root); - expect(snapshot.stacks.rslint.mode).toBe('bridged'); } configPaths = [nested]; expect( (await detectFolder(workspaceFolder)).rootRstackConfigPath, ).toBeUndefined(); }); - - it('gives native config presence precedence anywhere in the folder', () => { - expect( - decideRslintMode({ - nativeConfigPaths: ['/workspace/packages/app/rslint.config.ts'], - rootRstackConfigPath: '/workspace/rstack.config.ts', - }), - ).toBe('native'); - }); - - it('bridges only a root Rstack config when no native config exists', () => { - expect( - decideRslintMode({ - nativeConfigPaths: [], - rootRstackConfigPath: '/workspace/rstack.config.ts', - }), - ).toBe('bridged'); - expect(decideRslintMode({ nativeConfigPaths: [] })).toBeUndefined(); - }); }); diff --git a/packages/vscode/tests/stacks/lint/packageFixtures.ts b/packages/vscode/tests/stacks/lint/packageFixtures.ts index 2e9044f..59b11aa 100644 --- a/packages/vscode/tests/stacks/lint/packageFixtures.ts +++ b/packages/vscode/tests/stacks/lint/packageFixtures.ts @@ -47,7 +47,7 @@ export function installPackage( ); } -/** rstack's published `dist/rslintConfig.js`, the bridged folder's shim. */ +/** rstack's published `dist/rslintConfig.js`, the bridged runtime's shim. */ export function installShim(rstackDirectory: string): string { const shimPath = path.join(rstackDirectory, 'dist', 'rslintConfig.js'); fs.mkdirSync(path.dirname(shimPath), { recursive: true }); diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts index b0855da..a1635ad 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; import { + decideDocumentMode, findAncestorRslintConfig, resolveRslint, RslintResolutionError, @@ -17,7 +18,7 @@ import { afterEach(removeTemporaryDirectories); describe('resolveRslint', () => { - it('resolves a native folder directly from its @rslint/core installation', () => { + it('resolves a native runtime directly from its @rslint/core installation', () => { const root = temporaryDirectory(); const coreDir = installPackage(root, '@rslint/core', '0.8.0'); @@ -28,7 +29,7 @@ describe('resolveRslint', () => { }); }); - it('follows the rstack dependency chain for a bridged folder', () => { + it('follows the rstack dependency chain for a bridged runtime', () => { const root = temporaryDirectory(); const rstackDir = installPackage(root, 'rstack', '0.6.1'); const coreDir = installPackage(rstackDir, '@rslint/core', '0.8.0'); @@ -100,9 +101,9 @@ describe('resolveRslint', () => { ).toEqual({ mode: 'native', coreDir: nestedCore, coreVersion: '0.8.1' }); }); - it('ignores the document directory for a bridged folder', () => { - // A bridged folder is one config choice for the whole folder, so it is - // always exactly one core: rstack's own. + it('ignores the document directory for a bridged runtime', () => { + // Every bridged document of a folder evaluates the one root Rstack config, + // so it is always exactly one core: rstack's own. const root = temporaryDirectory(); const rstackDir = installPackage(root, 'rstack', '0.6.1'); const coreDir = installPackage(rstackDir, '@rslint/core', '0.8.0'); @@ -184,3 +185,82 @@ describe('findAncestorRslintConfig', () => { expect(findAncestorRslintConfig(vendored)).toBe(above); }); }); + +describe('decideDocumentMode', () => { + const rootRstackConfigPath = '/workspace/rstack.config.ts'; + const legacyConfig = '/workspace/packages/legacy/rslint.config.ts'; + const ancestorConfigPath = '/rslint.config.mjs'; + + it.each([ + { + name: 'a nested native config owns the documents under it', + documentPath: '/workspace/packages/legacy/src/index.ts', + nativeConfigPaths: [legacyConfig], + rootRstackConfigPath, + expected: 'native', + }, + { + name: 'the root Rstack config owns the documents outside it', + documentPath: '/workspace/src/index.ts', + nativeConfigPaths: [legacyConfig], + rootRstackConfigPath, + expected: 'bridged', + }, + { + name: 'a root native config beside a root Rstack config wins everywhere', + documentPath: '/workspace/packages/app/src/index.ts', + nativeConfigPaths: ['/workspace/rslint.config.mjs', legacyConfig], + rootRstackConfigPath, + expected: 'native', + }, + { + name: 'a sibling native config does not count', + documentPath: '/workspace/packages/app/src/index.ts', + nativeConfigPaths: [legacyConfig], + expected: undefined, + }, + { + name: 'no config leaves the document unserved', + documentPath: '/workspace/src/index.ts', + nativeConfigPaths: [], + expected: undefined, + }, + { + name: 'a config above the folder owns an otherwise ungoverned document', + documentPath: '/workspace/src/index.ts', + nativeConfigPaths: [legacyConfig, ancestorConfigPath], + expected: 'native', + }, + { + name: 'a root Rstack config precedes a config above the folder', + documentPath: '/workspace/src/index.ts', + nativeConfigPaths: [ancestorConfigPath], + rootRstackConfigPath, + expected: 'bridged', + }, + ])('$name', ({ name: _name, expected, ...signals }) => { + expect(decideDocumentMode({ platform: 'linux', ...signals })).toBe( + expected, + ); + }); + + it('compares Windows paths case-insensitively', () => { + const signals = { + nativeConfigPaths: ['c:\\work\\repo\\packages\\Legacy\\rslint.config.ts'], + rootRstackConfigPath: 'C:\\Work\\Repo\\rstack.config.ts', + platform: 'win32' as const, + }; + expect( + decideDocumentMode({ + ...signals, + documentPath: 'C:\\WORK\\REPO\\Packages\\legacy\\src\\index.ts', + }), + ).toBe('native'); + expect( + decideDocumentMode({ + ...signals, + documentPath: 'c:\\work\\repo\\src\\index.ts', + }), + ).toBe('bridged'); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/runtimeManager.test.ts b/packages/vscode/tests/stacks/lint/runtimeManager.test.ts index a7f5ddc..447d65e 100644 --- a/packages/vscode/tests/stacks/lint/runtimeManager.test.ts +++ b/packages/vscode/tests/stacks/lint/runtimeManager.test.ts @@ -151,7 +151,7 @@ function createHarness(): Harness { }, silentLogger, { - folderMode: () => 'bridged', + documentMode: () => 'bridged', documentIsOpen: () => state.documentOpen, onDocumentFailure: (failure) => failures.push(failure.error), }, diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index 5f3a583..1070194 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -137,7 +137,7 @@ it('reconciles documents on every detection pass even while a config refresh is name: 'project', uri: { fsPath: '/project', toString: () => 'file:///project' }, }; - const entry = { folder, stacks: { rslint: { mode: 'native' } } }; + const entry = { folder, stacks: { rslint: {} } }; const snapshot = { forFolder: () => entry, foldersFor: () => [entry], @@ -185,7 +185,7 @@ it('updates a surviving bridge runtime attribution before the next config failur const entry = { folder, rootRstackConfigPath: configPath, - stacks: { rslint: { mode: 'bridged' } }, + stacks: { rslint: {} }, }; return { forFolder: () => entry, diff --git a/packages/vscode/tests/stacks/lint/status.test.ts b/packages/vscode/tests/stacks/lint/status.test.ts index 6c54021..785543b 100644 --- a/packages/vscode/tests/stacks/lint/status.test.ts +++ b/packages/vscode/tests/stacks/lint/status.test.ts @@ -13,7 +13,7 @@ import { describe('Rslint status classification', () => { it('disables a folder whose package is not installed', () => { // Both not-installed shapes take the uniform disabled state (AGENTS.md): - // a bridged folder without rstack, a fresh clone without the core. The + // a bridged document without rstack, a fresh clone without the core. The // verdict rides on the error (`missingPackage`), set by the throw site. expect( statusForRslintStartFailure( @@ -166,7 +166,7 @@ describe('foldRslintFolderState', () => { }); }); - it('lets a bridged folder that lost rstack outrank its live runtime', () => { + it('lets a bridged document that lost rstack outrank its live runtime', () => { // Inside a folder `disabled` only ever means "a package is not // installed" — a failure the user must see, not the shell's kill switch — // so, unlike the cross-folder rank, it beats a healthy runtime.