Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ 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 server per folder, one config choice per server process). 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 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.

## lint

- **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.
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0003-lint-through-editor-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ Rslint's language server is two halves: the Go process (`rslint --lsp`) lints na
## Consequences

- **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 `^22.18.0 || >=23.6.0`. 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 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 <dir> [--config <shim>]` 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 "latest release only" rule: `@rslint/core >= 0.8.0` (protocol 1 support removed) and `rstack >= 0.6.1` toolchain-wide — `rstack` 0.5.2 still depends on `@rslint/core ~0.7.3`, and one answer to "which rstack does the extension support" is worth more than keeping 0.5.x users' tests running.
- **One override, and it names a core, not a binary.** `rstack.rslint.binPath` / `customBinPath` are removed (with the `rslint.customBinPath` migration mapping) in favour of `rstack.rslint.corePath` — the setting upstream introduced in rslint #1617: a path to an `@rslint/core` package directory, 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 — is a separate sync, tracked in issue #13; the worker takes explicit `--core` / `--config` paths precisely so that change does not touch it.
- **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 <dir> [--config <shim>]` 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 "latest release only" rule: `@rslint/core >= 0.8.0` (protocol 1 support removed) and `rstack >= 0.6.1` toolchain-wide — `rstack` 0.5.2 still depends on `@rslint/core ~0.7.3`, and one answer to "which rstack does the extension support" is worth more than keeping 0.5.x users' tests 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, 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 (protocol 2 locks `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.** Bridged folder: no `rstack` → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Native folder missing `@rslint/core` stays `crashed` — the user asked for Rslint by name.
Expand Down
4 changes: 3 additions & 1 deletion packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

- `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase.
- The copies diverge from upstream in exactly seven ways (the "adaptations" below). When syncing upstream, preserve them. An eighth divergence is either a bug or must be added to this list.
- **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do.

## The seven adaptations

Expand All @@ -15,7 +16,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. 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 folder 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. A bridged folder passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Why: `docs/adr/0003-lint-through-editor-worker.md`.
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 protocol 2 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`.

## Rules

Expand All @@ -36,6 +37,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

- 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 "no `rstack`", not the kill switch). Triggers: the shell's detection pass (which already covers lockfiles) plus one lint-owned watcher on `node_modules/@rslint/core/package.json` — upstream's glob minus the lockfiles detection owns. 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 protocol 2 locks that choice for the process lifetime.
- 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. Never re-implement rstack config semantics in the extension.
- 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`.
Expand Down
7 changes: 7 additions & 0 deletions packages/vscode/e2e/lint/runSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
* for the lint stack to register through the extension's public exports
* channel (`whenStackActive('rslint')`) instead. The no-config suite asserts
* the opposite state and opts out via `createRun({ expectLintStack: false })`.
* - Since rslint #1617 that signal means "the controller registered its
* detected folders and scheduled the open documents", not "a server is
* running": a **Lint runtime** exists only while a document uses it, so a
* freshly registered stack usually holds zero runtimes (the folder reports
* `running: idle`) and that counts as active. A suite that needs a live
* server opens a document and awaits its diagnostics — which is what the
* ported suites already do.
* - `fast-glob` is replaced by a small recursive walk (one dependency less).
*/
import fs from 'node:fs';
Expand Down
Loading