From 4d453996b97dac2c1f9ceb0d039b8792b07907c7 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 18 Aug 2026 14:11:21 +0800 Subject: [PATCH 1/4] feat(vscode): bridge Rslint through editor worker --- CONTEXT.md | 10 +- README.md | 6 +- docs/adr/0001-node-runtime-selection.md | 6 +- docs/adr/0003-lint-through-editor-worker.md | 31 + docs/tickets/lint-worker-bridge.md | 66 ++ packages/vscode/AGENTS.md | 21 +- packages/vscode/README.md | 19 +- .../vscode/e2e/fixtures/rslint/package.json | 2 +- .../vscode/e2e/fixtures/rstack/package.json | 4 +- .../e2e/fixtures/rstack/rstack.config.ts | 3 +- .../vscode/e2e/lint/fixtures/package.json | 2 +- packages/vscode/e2e/lint/runSuite.ts | 16 +- packages/vscode/e2e/lint/runTest.ts | 15 + .../e2e/lint/suite-bridge/bridge.test.ts | 156 ++++ .../vscode/e2e/lint/suite-bridge/index.ts | 3 + .../suite-eslint-plugins/plugin-pool.test.ts | 17 +- .../suite-jsconfig/config-transaction.test.ts | 52 +- packages/vscode/e2e/run.mjs | 7 +- packages/vscode/e2e/suite/detection.test.ts | 8 +- packages/vscode/package.json | 27 +- packages/vscode/rslib.config.mts | 23 + packages/vscode/src/detection.ts | 32 +- packages/vscode/src/extension.ts | 9 +- packages/vscode/src/migration.ts | 38 +- .../src/shared/nodeExecutableSetting.ts | 4 +- packages/vscode/src/shared/nodeResolution.ts | 2 +- .../src/shared/vendored/loadRstackConfig.ts | 244 ------ packages/vscode/src/shared/versionCheck.ts | 40 +- packages/vscode/src/stacks/lint/Rslint.ts | 765 ++++-------------- .../stacks/lint/WorkspaceRslintCoordinator.ts | 3 +- .../vscode/src/stacks/lint/configLoader.ts | 104 --- packages/vscode/src/stacks/lint/index.ts | 59 +- .../vscode/src/stacks/lint/jitiPreflight.ts | 119 --- .../vscode/src/stacks/lint/projectModules.ts | 49 -- packages/vscode/src/stacks/lint/resolution.ts | 389 +++------ packages/vscode/src/stacks/lint/status.ts | 34 + packages/vscode/src/stacks/lint/utils.ts | 57 -- .../{ => worker}/ConfigTransactionAdapter.ts | 105 +-- .../lint/{ => worker}/PluginLintPool.ts | 197 +---- packages/vscode/src/stacks/lint/worker/cli.ts | 50 ++ .../vscode/src/stacks/lint/worker/core.ts | 156 ++++ .../src/stacks/lint/worker/fingerprint.ts | 59 ++ .../vscode/src/stacks/lint/worker/index.ts | 276 +++++++ .../vscode/src/stacks/lint/worker/logger.ts | 40 + .../vscode/src/stacks/lint/worker/main.ts | 13 + packages/vscode/src/types.ts | 2 + packages/vscode/tests/extension.test.ts | 19 +- packages/vscode/tests/lintDetection.test.ts | 23 + .../vscode/tests/loadRstackConfig.test.ts | 72 -- packages/vscode/tests/migration.test.ts | 98 +-- .../tests/stacks/lint/resolution.test.ts | 112 +++ .../vscode/tests/stacks/lint/status.test.ts | 51 ++ .../vscode/tests/stacks/lint/worker.test.ts | 187 +++++ .../vscode/tests/stacks/test/bridge.test.ts | 8 +- packages/vscode/tests/versionCheck.test.ts | 16 +- pnpm-lock.yaml | 210 +++-- 56 files changed, 1962 insertions(+), 2174 deletions(-) create mode 100644 docs/adr/0003-lint-through-editor-worker.md create mode 100644 docs/tickets/lint-worker-bridge.md create mode 100644 packages/vscode/e2e/lint/suite-bridge/bridge.test.ts create mode 100644 packages/vscode/e2e/lint/suite-bridge/index.ts delete mode 100644 packages/vscode/src/shared/vendored/loadRstackConfig.ts delete mode 100644 packages/vscode/src/stacks/lint/configLoader.ts delete mode 100644 packages/vscode/src/stacks/lint/jitiPreflight.ts delete mode 100644 packages/vscode/src/stacks/lint/projectModules.ts create mode 100644 packages/vscode/src/stacks/lint/status.ts delete mode 100644 packages/vscode/src/stacks/lint/utils.ts rename packages/vscode/src/stacks/lint/{ => worker}/ConfigTransactionAdapter.ts (56%) rename packages/vscode/src/stacks/lint/{ => worker}/PluginLintPool.ts (53%) create mode 100644 packages/vscode/src/stacks/lint/worker/cli.ts create mode 100644 packages/vscode/src/stacks/lint/worker/core.ts create mode 100644 packages/vscode/src/stacks/lint/worker/fingerprint.ts create mode 100644 packages/vscode/src/stacks/lint/worker/index.ts create mode 100644 packages/vscode/src/stacks/lint/worker/logger.ts create mode 100644 packages/vscode/src/stacks/lint/worker/main.ts create mode 100644 packages/vscode/tests/lintDetection.test.ts delete mode 100644 packages/vscode/tests/loadRstackConfig.test.ts create mode 100644 packages/vscode/tests/stacks/lint/resolution.test.ts create mode 100644 packages/vscode/tests/stacks/lint/status.test.ts create mode 100644 packages/vscode/tests/stacks/lint/worker.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 951f397..c01ce17 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,7 +14,7 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **VS Code Node runtime** — the Node.js shipped inside VS Code, which the extension host itself runs on. Its version follows VS Code's release cadence, and it is Electron's Node, on a different ABI line from plain Node. _Avoid_: host runtime, extension host runtime. - **User Node runtime** — the Node.js the user's own environment provides, discovered by the extension rather than shipped with it. _Avoid_: worker runtime, project-side Node. - **Load bound** — the limit on what a piece of work can end up loading: what the extension ships, plus ABI-stable N-API bindings. Work that stays inside the bound may run on the VS Code Node runtime; work that can load project code has no load bound and belongs on a User Node runtime. _Avoid_: load surface. -- **Preflight** — the check that picks a User Node runtime, run once per extension host and shared by every process that loads project code (the test worker, the fmt server). Its failure is a status, never a crash. +- **Preflight** — the check that picks a User Node runtime, run once per extension host and shared by every process that loads project code (the lint worker, the test worker, the fmt server). Its failure is a status, never a crash. - **Runtime floor** — the version range a User Node runtime must satisfy (`NODE_RUNTIME_RANGE` in `shared/versionCheck.ts`). A declared support contract, not a probed capability. ## Tools and configs @@ -25,7 +25,13 @@ 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 assigning a directory to one tool when both a native config and a Rstack config are present there: the atomic tool's native config wins and the bridge yields. 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 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. + +## lint + +- **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 config 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. ## fmt diff --git a/README.md b/README.md index 63826de..559d3fe 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,15 @@ Rstack Editor provides unified editor support for [Rstack](https://rstack.rs), t ## Roadmap -The extension takes its configuration from five sources. The tool-native configs are fully supported today; support for driving a stack from `rstack.config.*` is landing one stack at a time. +The extension takes its configuration from five sources. Tool-native configs and each supported `define.*()` bridge are available today. | Config source | Status | | --- | --- | | `rslint.config.*` | **Supported.** Diagnostics, quick fixes and the language server, all resolved from the `@rslint/core` installed in your project. | | `rstest.config.*` | **Supported.** Test discovery, run and debug, watch mode, coverage and snapshot updates in the Test Explorer. | | `define.test()` in `rstack.config.*` | **Supported.** Tests run through the same config shim `rs test` uses, so the editor and the CLI resolve the config identically. | -| `define.fmt()` in `rstack.config.*` | **Supported.** Document formatting through the project-local `rs fmt` language server (`rs fmt --lsp`), one per workspace folder, loading the config from the folder root — the same config `rs fmt` run there would use. Needs `rstack` 0.5.2 or newer. | -| `define.lint()` in `rstack.config.*` | **Planned.** Linting a project configured only through `rstack.config.*` needs upstream changes in Rslint and rstack-cli before the editor can evaluate it correctly. `rs lint` on the command line is unaffected. | +| `define.fmt()` in `rstack.config.*` | **Supported.** Document formatting through the project-local `rs fmt` language server (`rs fmt --lsp`), one per workspace folder, loading the config from the folder root — the same config `rs fmt` run there would use. | +| `define.lint()` in `rstack.config.*` | **Supported.** In a folder without a native Rslint config, diagnostics come through rstack's published lint shim — the same config path `rs lint` uses. | ## License diff --git a/docs/adr/0001-node-runtime-selection.md b/docs/adr/0001-node-runtime-selection.md index e0a0732..8aeb9ca 100644 --- a/docs/adr/0001-node-runtime-selection.md +++ b/docs/adr/0001-node-runtime-selection.md @@ -36,12 +36,10 @@ Note that _worker_ names a process, not a runtime. The worker is our own code; t ### Where the line is drawn today -This decision was written for one path, the rstest worker, and named two others that sat on the wrong side of the line. One of them has since moved: +This decision was written for one path, the rstest worker, and named two others that sat on the wrong side of the line. Both have since moved: - **fmt** used to spawn the project's `rs` bin on `process.execPath` with `ELECTRON_RUN_AS_NODE=1` (`stacks/fmt/run.ts`) — the VS Code Node runtime — and let `rs fmt` load the project's config in that process: unbounded load, no floor, no preflight. It now runs `rs fmt --lsp` as a language server on a User Node runtime chosen by this decision's own logic, against the same floor, with the shared `rstack.nodeExecutable` as its escape hatch. Why the server, and why one per workspace folder: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`. -- **lint** imports the project's `@rslint/core/config-loader` into the extension host and loads the user's `rslint.config.ts` there (`stacks/lint/configLoader.ts`), and runs user plugin rules on the same runtime (`stacks/lint/PluginLintPool.ts`). `stacks/lint/jitiPreflight.ts` already records the resulting divergence in so many words: that loader "runs on the extension host's Node — whose version is fixed by VS Code, not by the user — so the jiti branch can trigger in the editor even when the CLI works fine". Its answer is a diagnostic, not a runtime choice. - -Lint is what moving costs when it is not cheap: fmt's move needed a whole upstream language server to exist first, and lint needs its own spawn-and-protocol work for the config loader and the plugin host, with no reported bug behind it yet. It stays known debt, deliberately — the rule is not universal until that entry is gone, and nobody should describe it as if it were. +- **lint** now runs an editor-shipped, vscode-free worker on the same User Node runtime. The worker owns the Go LSP, config evaluation and plugin rules, so no project code is imported into the extension host. The worker and the Rstack lint bridge are the decision in `docs/adr/0003-lint-through-editor-worker.md`; that ADR retires lint from this decision's debt list. ## Consequences diff --git a/docs/adr/0003-lint-through-editor-worker.md b/docs/adr/0003-lint-through-editor-worker.md new file mode 100644 index 0000000..968baa0 --- /dev/null +++ b/docs/adr/0003-lint-through-editor-worker.md @@ -0,0 +1,31 @@ +--- +status: accepted +--- + +# Linting through an editor-shipped worker on the User Node runtime + +Rslint's language server is two halves: the Go process (`rslint --lsp`) lints natively, but it hands config evaluation (`rslint/loadConfigs`, `activateConfigs`, `commit`/`abort`) and JS plugin rules (`rslint/pluginLint`) back to its client over reverse requests. Upstream's VS Code extension is that client, and so — as a near-verbatim copy — was ours: the project's `rslint.config.*` was evaluated inside the extension host, on the VS Code Node runtime, with a cwd that means nothing (ADR 0001's lint entry). Linting from a Rstack config (`define.lint()`) forces the issue: `rs lint`'s answer is a shim rstack ships (`/dist/rslintConfig.js`, treated as a stable path by agreement with rstack-cli) that calls `loadRstackConfig()` and finds `rstack.config.*` from **the evaluating process's cwd**. Evaluated in the extension host, it finds nothing. The first attempt (PR #10) worked around that by writing a **generated shim** into the project with the absolute config path baked in; it was rejected — the editor must not manufacture bridging artifacts. + +**Decision.** The extension ships a **lint worker**: a vscode-free Node script, run once per lint server on a **User Node runtime** (ADR 0001's floor and candidate order, `rstack.nodeExecutable` as the shared escape hatch) with its cwd at the workspace folder root. The worker spawns the Go `rslint --lsp` of the resolved `@rslint/core`, proxies LSP over stdio to the extension, and answers Go's reverse requests itself with that core's `ConfigModuleHost` and `createPluginLintHost`. Its entry is `--lsp [--config ]`: with `--config` it pins the server to that module through the protocol-2 `configPath` of `rslint/configRefresh` (`@rslint/core >= 0.8.0`, rslint #1630); without it the server does its ordinary automatic discovery. A **bridged folder** runs the worker with `--config /dist/rslintConfig.js`; a **native folder** runs it without. From the extension's side, lint is now the shape fmt already has: a thin language client per server, and no project code loaded in the extension host — which retires the lint entry on ADR 0001's debt list. + +## Considered options + +**The JS host in `@rslint/core`** — a self-hosted `rslint --lsp` on the JS bin, accepting `--config`; rstack's `rs lint --lsp` becomes the same five-line passthrough `rs lint` already is. The best layering (any editor gets JS configs, not only VS Code), and the shape this worker is deliberately written in so it can move there whole. Not taken now because it gates the feature on an rslint release. + +**The JS host in rstack-cli** (`rs lint --lsp` written on `@rslint/core`'s public exports) — serves bridged folders only; native folders would keep the extension-host path, leaving two lint paths in the editor. Rejected. + +**Keeping the JS host in the extension host and adapting** — either an in-memory adapter that calls `rstack/config`'s `loadRstackConfig({configFilePath, cwd})` when Go asks for the Rstack config path (the editor re-implements the shim's three lines and keeps evaluating project code on the VS Code Node runtime), or setting `globalThis.__rstackCliState` before importing rstack's shim (an internal contract, racy across folders). Both rejected: the first widens ADR 0001's debt and violates "never re-implement rstack config semantics", the second is a hack. + +**A child process that only evaluates, with the extension staying Go's client** — every `pluginLint` would cross one more IPC hop (Go → extension → child → worker) and half the host logic would stay in the extension host. Rejected in favour of the worker fronting Go directly. + +**A generated shim** (PR #10) — rejected by decision, see above. + +## 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 [--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 "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. +- **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. +- 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/tickets/lint-worker-bridge.md b/docs/tickets/lint-worker-bridge.md new file mode 100644 index 0000000..dba549b --- /dev/null +++ b/docs/tickets/lint-worker-bridge.md @@ -0,0 +1,66 @@ +# Ticket: lint through an editor-shipped worker; bridge `define.lint()` from `rstack.config.*` + +Implements `docs/adr/0003-lint-through-editor-worker.md` (accepted). Read that ADR, `CONTEXT.md` (Ownership, Lint worker, Bridged folder, Native folder) and `packages/vscode/AGENTS.md` first. Terms below are the glossary's. + +## Outcome + +- A **bridged folder** (no `rslint.config.*` anywhere in the workspace folder, a `rstack.config.*` at its root) lints from `define.lint()`, through rstack's own shipped shim, with the same diagnostics `rs lint` prints in a terminal. +- A **native folder** keeps linting exactly as today from the user's point of view (ported E2E suites stay green with their assertion semantics), but its config evaluation and plugin rules now run in the **lint worker** on a User Node runtime, not in the extension host. +- No project code is imported into the extension host by the lint stack any more. + +## Non-goals + +- No generated shim, no editor-side interpretation of the Rstack config (never read `configs.lint`, never call `loadRstackConfig` from the extension). The only Rstack artefact the editor touches is the path `/dist/rslintConfig.js`, treated as stable. +- No `rs lint --lsp`, no rslint upstream change. The worker is written vscode-free so it can move to `@rslint/core` later; do not add editor coupling to it. +- No sync of rslint #1617's per-document runtime model — issue #13. Keep `WorkspaceRslintCoordinator` per folder. +- No `configPath`-style user setting. + +## Facts the implementer needs (verified 2026-08-18) + +**rslint protocol (Go side, `@rslint/core` 0.8.0, rslint PR #1630)** — `internal/lsp/config_discovery.go`: + +- `rslint/configRefresh` request `{protocolVersion: 2, reason, configPath?}`. `reason` of the first refresh must be `'initial'`. `configPath` is an absolute **native** path (no `file:` URI), extension `.js/.mjs/.cjs/.ts/.mts/.cts`. Once the first refresh has decided (present or absent), the choice is **locked for the process** — a later refresh that changes it errors with `InvalidParams`; changing mode = new process. +- Explicit mode: Go loads exactly that module, keeps the **spawn cwd** as the matching root (`files`/`ignores`/`parserOptions.project`), skips `rslint.json` fallback, and stops watching ancestor JS configs — **the client owns change notifications for the explicit path** (`architecture.md` ~L785). Its `.gitignore` watcher stays. +- Reverse requests Go sends its client: `rslint/loadConfigs`, `rslint/activateConfigs`, `rslint/commitConfigs`, `rslint/abortConfigs`, `rslint/pluginLint`. Handlers must be registered before the first refresh. +- The Go binary is `@rslint/native-*`; the CLI/LSP takes only `--lsp`; argv is otherwise ignored. + +**`@rslint/core` 0.8.0 exports** (`./config-loader`): `ConfigModuleHost`, `CONFIG_DISCOVERY_PROTOCOL_VERSION` (=2), `resolveRslintBinary()`, protocol types; (`./eslint-plugin`): `createPluginLintHost`. Upstream's `CoreResolver.ts` derives everything from one core directory this way. + +**rstack** (`0.6.1`, depends on `@rslint/core ~0.8.0`; `0.5.2` still `~0.7.3`): `rs lint` = `runCLI({argv:[..., '--config', join(import.meta.dirname, 'rslintConfig.js')]})`. `dist/rslintConfig.js` = `loadRstackConfig()` → `configs.lint ?? []` → call if function → default export. `loadRstackConfig()` with no args reads the CLI's `globalThis.__rstackCliState.configPath` else searches `process.cwd()` for `rstack.config.{ts,js,mts,mjs}` via `@rstackjs/load-config` `loader:'native'`, `fresh:true`. `dist/*` is not in `exports`; locate the package via its `package.json`. + +**Our code today** (`packages/vscode/src`): spawn at `stacks/lint/Rslint.ts:630-642` (`LanguageServerProcessOwner(binPath, ['--lsp'], folderRoot)`), reverse handlers `Rslint.ts:732-783`, refresh `Rslint.ts:957-961`, watch glob `Rslint.ts:126`; JS host pieces `configLoader.ts`, `ConfigTransactionAdapter.ts`, `PluginLintPool.ts`, `projectModules.ts`, `jitiPreflight.ts`; resolution `stacks/lint/resolution.ts`; detection `detection.ts:176-212`; floors `shared/versionCheck.ts:27-31`, protocol set `:154`; User Node selection `shared/nodeResolution.ts` (+ `USER_NODE_STACKS` in `extension.ts:34`, `nodeExecutableSetting.ts`); fmt's spawn-on-User-Node reference `stacks/fmt/index.ts:329-371, 429`; the rstest worker bundling pattern `rslib.config.mts:44-110`; settings `package.json` "Rstack › Rslint" block; migration mapping tests `tests/migration.test.ts:186-192, 362`. + +## Work breakdown + +1. **Lint worker** (`src/stacks/lint/worker/`, own rslib entry like the rstest worker; CJS, `target: node`, no `vscode` import). + - CLI: `--lsp --core [--config ]`. Anything else is a usage error. + - Load `/config-loader` and `/eslint-plugin` (ESM `import()` from the core dir; mirror upstream `CoreResolver.ts` structural checks). Binary = that core's `resolveRslintBinary()`; spawn it with `--lsp`, cwd = `process.cwd()`, stdio pipes. + - Proxy JSON-RPC between `process.stdin/stdout` (extension) and the Go child (vscode-jsonrpc star handlers both ways, cancellation and ids preserved). Own the Go child's lifetime (SIGTERM → SIGKILL, exit when either side closes). + - Answer Go's five reverse requests locally: port `LspConfigTransactionAdapter` + `PluginLintPool` + fingerprinting logic into the worker with their behaviour intact (`loadMode:'fresh'` forcing, generation grace, protocol validation, cancellation → AbortSignal). + - Intercept the extension's `rslint/configRefresh {reason}`: stamp `protocolVersion` from the core and, when started with `--config`, the same `configPath` every time; forward to Go; return Go's response. + - Log to stderr only; stdout is the LSP channel. +2. **Extension-side lint stack** (`Rslint.ts` keeps the language-client half only). + - Resolution (`resolution.ts`, pure, unit-tested): native folder → `@rslint/core` from the folder root; bridged folder → `rstack` from the folder root, then `@rslint/core` from rstack's directory (`createRequire`-style, physical `node_modules`, no PnP); `rstack.rslint.corePath` overrides the core hop in both modes. Result: `{ mode, coreDir, coreVersion, rstackDir?, rstackVersion?, shimPath? }`. Gates: `@rslint/core >= 0.8.0`; bridged additionally `rstack >= 0.6.1` (raise `SUPPORT_MATRIX` — `rstack: '>=0.6.1'`, `'@rslint/core': '>=0.8.0'`; delete `SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS` and the protocol-mismatch classes/messages). + - Runtime: pick the User Node through `resolveUserNodeOnce` with lint's own consequence sentence; add `'rslint'` to `USER_NODE_STACKS`; honour `rstack.nodeExecutable`. Spawn ` --lsp --core [--config ]` via `LanguageServerProcessOwner`, cwd = folder root. + - Delete from the extension host: `configLoader.ts`, `ConfigTransactionAdapter.ts`, `PluginLintPool.ts`, `projectModules.ts`, `jitiPreflight.ts` (they move to the worker or die), and `shared/vendored/loadRstackConfig.ts` if nothing else imports it. Remove every `TODO(rstack-bridge)` marker. + - Refresh: keep the watcher-driven `requestConfigRefresh` (send `{reason}` only). Bridged folder: watch glob adds the root `rstack.config.{ts,js,mts,mjs}` (mind: no nested brace groups). Mode flip (a native config appearing/disappearing, root rstack config appearing/disappearing) restarts through the coordinator's replacement path. + - Status: bridged folder — no `rstack` → `disabled`; `rstack`/chained `@rslint/core` below floor or no Node clearing the floor → `version mismatch` (message names package + required version, or the shared preflight message + lint consequence); worker/Go dies → `crashed`. Native folder missing `@rslint/core` stays `crashed`. +3. **Detection** (`detection.ts`): `rslint.detected = rslintConfigFiles.length > 0 || rootRstackConfigExists`; expose which (mode) so the stack does not re-scan; presence only, never contents. Update `tests/lintDetection.test.ts`, `tests/detection.test.ts`, `e2e/suite/detection.test.ts` (the rstack row now lights all three stacks). +4. **Settings/migration**: remove `rstack.rslint.binPath`, `rstack.rslint.customBinPath` (and `rslint.customBinPath` from the migration mapping + tests); add `rstack.rslint.corePath` (string, resource-scoped, description mirroring upstream); `restartOnSettings = ['corePath', 'trace.server']`. README (user-facing) settings table updated. +5. **Docs**: `packages/vscode/AGENTS.md` — adaptation #7 (lint worker + bridge), rewrite the "bridge was built and deliberately removed" gotcha, drop "Lint still loads project code on the VS Code Node runtime" from adaptation #6, add worker gotchas (vscode-free; explicit paths only; refresh vs restart line). `docs/adr/0001-node-runtime-selection.md`: move lint from the debt list to "retired by ADR 0003". Root `README.md`/`packages/vscode/README.md` only if user-facing behaviour is described there. +6. **Tests**: + - Unit: resolution chain + mode decision + status classification as pure modules; worker CLI parsing / configRefresh stamping with a fake Go (spawn a small stdio JSON-RPC stub). + - E2E: bump fixtures to `rstack@0.6.1`, `@rslint/core@^0.8.0`; `e2e/fixtures/rstack/rstack.config.ts` already carries `define.lint([...no-debugger...])` — add a lint bridge suite (diagnostic from that rule in a folder with no `rslint.config.*`, a `rstack.config.ts` edit refreshing it, a native config appearing flipping the folder). All ported lint suites (`e2e/lint/suite*`) must pass unchanged in assertion semantics. Add the slice to `SLICES` if a new entry is needed. + +## Verification (report real output) + +- `pnpm lint && pnpm test:unit` +- `VSCODE_CLI=1 pnpm test:e2e lint vscode smoke` (lint slice = ported suites + bridge; `vscode` = detection; `smoke` uses the rslint fixture — update if it imports the removed in-host plugin host path) +- Manual: open `packages/vscode/e2e/fixtures/rstack` alone in the Extension Development Host; expect a `no-debugger` diagnostic and status `running`; delete `rstack` from `node_modules` → `disabled`. + +## Guardrails + +- Never delete `packages/vscode/.vscode-test/`. +- Fixture `node_modules` are disposable, never committed. +- Do not reintroduce a per-request child, a generated file, or any Rstack-config semantics in the extension. +- Do not add native dependencies to the VSIX. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 58377c7..b446175 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -5,20 +5,21 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## The copies are intentional - `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 six ways (the "adaptations" below). When syncing upstream, preserve them. A seventh divergence is either a bug or must be added to this list. +- 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. -## The six adaptations +## The seven adaptations 1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker. 2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` names appear only in the migration mapping. Command IDs were renamed without aliases (breaking old keybindings was an accepted cost). 3. **Resolve-from-project** — no tool binaries or tool packages in the VSIX; everything resolves from the user's project so the editor runs the CLI's exact versions. Version floors surface as a status, never a crash. All cooperating lint pieces (binary, config loader, plugin host) must come from one resolution root. 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** (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`). Both callers — 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. Lint still loads project code on the VS Code Node runtime — known debt recorded in the ADR, not an invariant the extension already holds. +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`. ## Rules -- **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. Only the **latest released** `rstack`, `@rstest/core` and `@rslint/core` need support; version floors may be raised without a transition story (the floor status names the required version). The settings migration exists for users of the two retired standalone extensions, never for earlier states of this one. +- **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. Only the **latest released** `rstack`, `@rstest/core` and `@rslint/core` need support: whenever a change touches a floor in `SUPPORT_MATRIX`, set it to the latest release at that time — do not reason about which older release would still work — and raise it without a transition story (the floor status names the required version). The settings migration exists for users of the two retired standalone extensions, never for earlier states of this one. - **The three tools are treated uniformly by default.** Detection, dependency-change retry, restart semantics, version gating and status reporting follow one shared pattern across the lint/test/fmt stacks; a stack diverges only when its tool forces it, and the divergence is recorded here as a gotcha. When adding behavior to one stack, first ask whether it belongs to all three. This is about behavior, not code — the upstream copies still must not be deduplicated. - One stack failing to register or crashing must never take another stack (or the shell) down. - The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. Enable-settings are coarse kill switches only. @@ -33,14 +34,14 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Gotchas — decisions that look wrong but aren't -- The lint × `rstack.config.*` bridge was built and deliberately removed: a partial editor-side bridge gave wrong results, and a correct one needs upstream work first. `TODO(rstack-bridge)` markers carry the plan. Do not reintroduce a partial bridge. +- 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. +- 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 for `rstack < 0.5.2`; that is a version gate (`SUPPORT_MATRIX.rstack`), 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`. -- fmt importing `stacks/lint/LanguageServerProcessOwner.ts` is not a refactor across the copies: that file has no lint imports and no lint behaviour, it only owns the native children of one language client — including the ones vscode-languageclient's automatic restart creates, which is exactly the leak an ad-hoc copy would reintroduce. Lint's `ManagedLanguageClient` is _restated_ in `stacks/fmt/index.ts` instead, because importing it from `Rslint.ts` would drag the lint runtime graph (plugin pool, config loader, rstack bridge) into the fmt stack. Keep that line where it is: shared process ownership yes, shared stack runtime no. -- `projectModules.ts` has no cache-invalidation hook and restart must not grow one. Node's ESM registry is keyed by resolved URL and process-lifetime, so clearing the local memo hands back the identical module object (verified); a `?epoch=` query does reload the entry but relative specifiers inside it do not inherit the query, yielding a fresh entry over stale dependencies. In-place reinstalls under an unchanged path need a window reload — say so, don't fake it. +- 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`. +- fmt importing `stacks/lint/LanguageServerProcessOwner.ts` is not a refactor across the copies: that file has no lint imports and no lint behaviour, it only owns the native children of one language client — including the ones vscode-languageclient's automatic restart creates, which is exactly the leak an ad-hoc copy would reintroduce. Lint's `ManagedLanguageClient` is _restated_ in `stacks/fmt/index.ts` instead, because importing it from `Rslint.ts` would couple fmt to the lint stack's runtime graph. Keep that line where it is: shared process ownership yes, shared stack runtime no. - The VSIX is platform-targeted for exactly one reason: the test stack's AST collection loads a native parser binding. Do not add another native dependency — it multiplies the release matrix. -- `shared/nodeResolution.ts` takes its shell, its cwd and its notify callback as options instead of importing `vscode` and a stack's `logger` singleton, unlike its neighbours. That is not stylistic: it keeps `resolveUserNode` a pure decision table over its inputs, which is what makes the case-by-case unit tests possible without a `vscode` stub. It sat in `stacks/test/` until fmt became the second stack running project code on a User Node runtime — the exact condition its old note named — and moved on that trigger, not before. Do not route the remaining callers through it: work that only runs _our_ code on the VS Code Node runtime (the lint plugin host) has no candidate to choose between and needs `nativeTypeStrippingAvailable()` alone. Its host-scoped preflight memo is reset by the shell's restart pass only when **no** consumer stack (`USER_NODE_STACKS`) survives the pass — a single-stack `rstack.fmt.restart` beside a live Rstest controller deliberately keeps the memo, since the survivor's existing workers were built on that decision; the full `rstack.restart` always clears it. `stacks/fmt/binEntry.ts` and `stacks/fmt/status.ts` are separate pure modules for the same testability reason — `stacks/fmt/index.ts` evaluates `vscode`, and `status.ts` pins the fold's invariant (a healthy sibling folder never masks another folder's failure) in a unit test the single-folder E2E fixtures cannot. -- The uniform Node floor deliberately exceeds `@rstest/core`'s own `engines` (`^20.19.0 || >=22.12.0`), because the strictest thing any of these processes does — the test worker through rstack's shim, the `rs fmt` server through `@rstackjs/load-config` — is load an `rstack.config.*` with `loader: 'native'`, no jiti fallback, and so needs native type stripping (22.18+; on the 23.x line only from 23.6). Do not specialise the floor per project — that was considered and rejected. Why, and what else was rejected: `docs/adr/0001-node-runtime-selection.md`. +- `shared/nodeResolution.ts` takes its shell, its cwd and its notify callback as options instead of importing `vscode` and a stack's `logger` singleton, unlike its neighbours. That is not stylistic: it keeps `resolveUserNode` a pure decision table over its inputs, which is what makes the case-by-case unit tests possible without a `vscode` stub. It sat in `stacks/test/` until fmt became the second stack running project code on a User Node runtime — the exact condition its old note named — and moved on that trigger, not before. Its host-scoped preflight memo is reset by the shell's restart pass only when **no** consumer stack (`USER_NODE_STACKS`) survives the pass — a single-stack `rstack.fmt.restart` beside a live Rstest controller deliberately keeps the memo, since the survivor's existing workers were built on that decision; the full `rstack.restart` always clears it. `stacks/fmt/binEntry.ts` and `stacks/fmt/status.ts` are separate pure modules for the same testability reason — `stacks/fmt/index.ts` evaluates `vscode`, and `status.ts` pins the fold's invariant (a healthy sibling folder never masks another folder's failure) in a unit test the single-folder E2E fixtures cannot. +- The uniform Node floor deliberately exceeds `@rstest/core`'s own `engines` (`^20.19.0 || >=22.12.0`), because the strictest thing any of these processes does — either worker through rstack's shim, or the `rs fmt` server through `@rstackjs/load-config` — is load an `rstack.config.*` with `loader: 'native'`, no jiti fallback, and so needs native type stripping (22.18+; on the 23.x line only from 23.6). Do not specialise the floor per project — that was considered and rejected. Why, and what else was rejected: `docs/adr/0001-node-runtime-selection.md`. - Bun is not a supported worker runtime (it segfaults running `@rstest/core`). If that is ever revisited, gate it on an explicit setting — never on `bun.lock`, since bun-as-package-manager still runs the `rs` bin through its `#!/usr/bin/env node` shebang. ## Testing diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 11617ee..9e519fc 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -11,7 +11,7 @@ The extension ships no tool binaries: `@rslint/core`, `@rstest/core` and `rstack ## Features -- **Linting (Rslint)** — diagnostics, quick fixes and auto-fix on save via Rslint's language server. +- **Linting (Rslint)** — diagnostics, quick fixes and auto-fix on save via Rslint's language server, from either a native config or `define.lint()`. - **Testing (Rstest)** — a Test Explorer tree built from your test files: run or debug individual tests, suites or files; the tree stays in sync as files change; failed tests show up as editor diagnostics. - **rstack-cli** — document formatting through the project-local `rs fmt` language server, one per workspace folder. - **One status bar item** — a single `Rstack` entry shows which tools are active in the current workspace and why. @@ -22,13 +22,13 @@ The extension activates on startup, then decides **per workspace folder** which | Tool | Started when the folder contains | | --- | --- | -| Rslint | `rslint.config.{js,mjs,ts,mts}` | +| Rslint | `rslint.config.{js,mjs,ts,mts}` anywhere in the folder, or `rstack.config.*` at the folder root when no native config exists | | Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` | | rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` | Config files and lockfiles are watched, so detection re-runs without a window reload. When something changes that none of those files record — a reinstall that leaves the lockfile untouched, or a `node_modules` that ends up broken — run **Rstack: Relaunch Extension** from the Command Palette (also on the status bar hover) to tear every tool down and start over. To rebuild a single tool, use **Rstack: Restart Rslint** / **Restart Rstest** / **Restart rs fmt**. -A restart re-resolves every binary and package version and respawns every tool process, but it cannot reload JavaScript the editor has already imported from your project — Node keeps those modules for the lifetime of the window. If a reinstall replaced `@rslint/core` in place and lint still behaves like the old version, reload the window. Deprecated `rslint.json` / `rslint.jsonc` configs are **not** detection signals — migrate them with `rslint --init`. +A restart re-resolves every binary and package version and respawns every tool process. Deprecated `rslint.json` / `rslint.jsonc` configs are **not** detection signals — migrate them with `rslint --init`. ## Supported package versions @@ -36,11 +36,11 @@ The project-resolved packages are checked against a support matrix at runtime; a | Package | Required | | -------------- | --------- | -| `@rslint/core` | `>=0.7.2` | +| `@rslint/core` | `>=0.8.0` | | `@rstest/core` | `>=0.6.0` | -| `rstack` | `>=0.5.2` | +| `rstack` | `>=0.6.1` | -`rstack` 0.5.2 is the first release with `rs fmt --lsp`, the language server the formatter is a client of; on older releases the formatter reports `version mismatch` instead of formatting. +`rstack` 0.6.1 and `@rslint/core` 0.8.0 provide the explicit lint config protocol used by the Rstack bridge. Older releases report `version mismatch`. ## Auto-fix on save (Rslint) @@ -77,10 +77,9 @@ All settings live under the unified `rstack.*` namespace. There are no `rslint.* | Setting | Default | Description | | --- | --- | --- | | `rstack.enable` | `true` | Master switch for the whole extension. | -| `rstack.nodeExecutable` | — | Node binary used for the processes that load your project: the test worker and the `rs fmt` language server. Empty means the extension picks one (`PATH` first, then the `node` your interactive shell resolves). | +| `rstack.nodeExecutable` | — | Node binary used for the processes that load your project: the lint worker, test worker and `rs fmt` language server. Empty means the extension picks one (`PATH` first, then the `node` your interactive shell resolves). | | `rstack.rslint.enable` | `true` | Enable/disable the Rslint integration. | -| `rstack.rslint.binPath` | `local` | `local` (project `node_modules`, incl. Yarn PnP) or `custom`. | -| `rstack.rslint.customBinPath` | — | Binary path used when `binPath` is `custom`. | +| `rstack.rslint.corePath` | — | Path to an `@rslint/core` package directory; relative paths resolve from the workspace folder. | | `rstack.rslint.trace.server` | `off` | LSP trace level (`off` / `messages` / `verbose`). | | `rstack.rstest.enable` | `true` | Enable/disable the Rstest integration. | | `rstack.rstest.configFileGlobPattern` | `["**/rstest.config.{mjs,ts,js,cjs,mts,cts}"]` | Glob patterns used to discover config files. | @@ -107,7 +106,7 @@ Formatting runs one `rs fmt` language server per workspace folder, which loads ` Run **Rstack: Migrate Rslint/Rstest Settings** from the Command Palette (it is also offered once, dismissibly, when legacy keys are found). - Settings are migrated per layer (User, Workspace, Workspace Folder), and the legacy keys are removed after they are copied. Workspace and folder layers touch files inside your repository, so nothing is written before you confirm the previewed key mapping. -- `rslint.binPath: "built-in"` becomes `rstack.rslint.binPath: "local"`: this extension ships no binary and always resolves it from your project. +- Legacy `rslint.binPath` / `rslint.customBinPath` values are left untouched: a standalone binary path cannot be translated safely into the `@rslint/core` directory the worker requires. - **Keybindings are not migrated.** Command ids were renamed to `rstack.*` with no aliases, and VS Code has no keybindings API, so any keybinding bound to an old `rslint.*` / `rstest.*` command id has to be re-bound by hand. - Projects with only `rslint.json` / `rslint.jsonc` are reported as `not detected`; run `rslint --init` to migrate to a JS/TS config. diff --git a/packages/vscode/e2e/fixtures/rslint/package.json b/packages/vscode/e2e/fixtures/rslint/package.json index 6d97a0c..2cc1fcd 100644 --- a/packages/vscode/e2e/fixtures/rslint/package.json +++ b/packages/vscode/e2e/fixtures/rslint/package.json @@ -5,6 +5,6 @@ "type": "module", "description": "E2E fixture: a project detected as Rslint only, installed from the npm registry.", "dependencies": { - "@rslint/core": "^0.7.2" + "@rslint/core": "^0.8.0" } } diff --git a/packages/vscode/e2e/fixtures/rstack/package.json b/packages/vscode/e2e/fixtures/rstack/package.json index 8bdf70c..155a9af 100644 --- a/packages/vscode/e2e/fixtures/rstack/package.json +++ b/packages/vscode/e2e/fixtures/rstack/package.json @@ -3,9 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "E2E fixture: an rstack-cli project whose only config is `rstack.config.ts`, which lights the Rstest and rs fmt stacks.", + "description": "E2E fixture: an rstack-cli project whose only config is `rstack.config.ts`, which lights all three stacks.", "dependencies": { - "rstack": "0.5.2" + "rstack": "0.6.1" }, "devDependencies": { "jiti": "^2.0.0" diff --git a/packages/vscode/e2e/fixtures/rstack/rstack.config.ts b/packages/vscode/e2e/fixtures/rstack/rstack.config.ts index 9003997..88a0e5b 100644 --- a/packages/vscode/e2e/fixtures/rstack/rstack.config.ts +++ b/packages/vscode/e2e/fixtures/rstack/rstack.config.ts @@ -2,8 +2,7 @@ // // This fixture has NO tool-native config: no `rslint.config.*`, no // `rstest.config.*`. `rstack.config.ts` is the single config source, and its -// presence alone must light the Rstest and rs fmt stacks (Rslint via -// `define.lint()` is deferred — TODO(rstack-bridge)) — `rs lint` and +// presence alone must light all three stacks. The lint bridge, `rs lint` and // `rs test` inject rstack's own shim configs, so a tool-native file never has // to exist. import { define } from 'rstack'; diff --git a/packages/vscode/e2e/lint/fixtures/package.json b/packages/vscode/e2e/lint/fixtures/package.json index 909abcc..56baa1e 100644 --- a/packages/vscode/e2e/lint/fixtures/package.json +++ b/packages/vscode/e2e/lint/fixtures/package.json @@ -4,7 +4,7 @@ "private": true, "description": "Shared install root for the Rslint E2E fixture workspaces. The extension ships no binary: every fixture resolves @rslint/core - including its native Go binary, config-loader and eslint-plugin host - from this one published-npm install. jiti backs the config-file-loader's TypeScript-config fallback.", "dependencies": { - "@rslint/core": "^0.7.2", + "@rslint/core": "^0.8.0", "jiti": "^2.7.0" } } diff --git a/packages/vscode/e2e/lint/runSuite.ts b/packages/vscode/e2e/lint/runSuite.ts index 6880e0f..253b112 100644 --- a/packages/vscode/e2e/lint/runSuite.ts +++ b/packages/vscode/e2e/lint/runSuite.ts @@ -106,7 +106,21 @@ async function activateAndRun( } const mocha = new Mocha({ ui: 'tdd' }); files.forEach((file) => mocha.addFile(file)); - mocha.run((failures) => callback(null, failures)); + const failed: string[] = []; + const runner = mocha.run((failures) => { + if (failures === 0) { + callback(null, 0); + return; + } + callback( + new Error(`${failures} E2E test(s) failed:\n${failed.join('\n')}`), + ); + }); + runner.on('fail', (test, error) => { + failed.push( + `- ${test.fullTitle()}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); } catch (error) { callback(error); } diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index 42c9fc3..3015856 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -96,6 +96,10 @@ async function runIsolatedSuite( recursive: true, force: false, errorOnExist: true, + // Dependency lookup is reconstructed as one read-only parent symlink + // below. Copying a fixture's disposable node_modules would be both slow + // and a writable second resolution root inside the sandbox. + filter: (source) => path.basename(source) !== 'node_modules', }); const expectedWorkspaceFolders = await Promise.all( (suite.workspaceFolders ?? ['.']).map((folder) => @@ -181,7 +185,13 @@ async function main(): Promise { // `__dirname` is `/tests-dist/e2e/lint` (see tsconfig.e2e.json). const extensionDevelopmentPath = path.resolve(__dirname, '../../..'); const fixturesRoot = path.join(extensionDevelopmentPath, 'e2e/lint/fixtures'); + const sharedFixturesRoot = path.join( + extensionDevelopmentPath, + 'e2e/fixtures', + ); const fixture = (name: string): string => path.join(fixturesRoot, name); + const sharedFixture = (name: string): string => + path.join(sharedFixturesRoot, name); const suiteDir = (name: string): string => path.resolve(__dirname, name); // The extension host loads `main` from `package.json`; an unbuilt repo would @@ -269,6 +279,11 @@ async function main(): Promise { workspace: fixture('eslint-plugins'), tests: suiteDir('suite-eslint-plugins'), }, + { + name: 'Rstack lint bridge tests', + workspace: sharedFixture('rstack'), + tests: suiteDir('suite-bridge'), + }, ]; // Optional development filter: `RSTACK_LINT_E2E_SUITES="No config,Monorepo"` diff --git a/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts b/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts new file mode 100644 index 0000000..b23cdc6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts @@ -0,0 +1,156 @@ +import * as assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import { + getRslintDiagnostics, + waitForRslintDiagnostics, + waitForRslintDiagnosticsCount, +} from '../utils/diagnostics'; + +const nativeConfigName = 'rslint.config.mjs'; + +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 configSource(rule: 'error' | 'off', markerPath?: string): string { + const marker = + markerPath === undefined + ? '' + : `import { writeFileSync } from 'node:fs';\n\n`; + const callbackStart = + markerPath === undefined + ? 'define.lint([' + : `define.lint(() => {\n writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ pid: process.pid, execPath: process.execPath }));\n return [`; + const callbackEnd = markerPath === undefined ? ']);' : ' ];\n});'; + return `${marker}import { define } from 'rstack'; + +${callbackStart} + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': '${rule}', + }, + }, +${callbackEnd} +`; +} + +async function openLintTarget(): Promise { + const document = await vscode.workspace.openTextDocument( + path.join(workspaceRoot(), 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + return document; +} + +function hasNoDebugger(diagnostics: readonly vscode.Diagnostic[]): boolean { + return diagnostics.some((diagnostic) => + diagnostic.message.includes('no-debugger'), + ); +} + +suite('Rstack lint bridge', function () { + this.timeout(120_000); + + const root = workspaceRoot(); + const rstackConfigPath = path.join(root, 'rstack.config.ts'); + const nativeConfigPath = path.join(root, nativeConfigName); + const markerPath = path.join(root, '.lint-worker-config.json'); + const originalConfig = fs.readFileSync(rstackConfigPath, 'utf8'); + + teardown(async () => { + fs.writeFileSync(rstackConfigPath, originalConfig, 'utf8'); + fs.rmSync(nativeConfigPath, { force: true }); + fs.rmSync(markerPath, { force: true }); + const document = vscode.workspace.textDocuments.find( + (candidate) => + candidate.uri.fsPath === path.join(root, 'src', 'index.ts'), + ); + if (document) { + await waitForRslintDiagnostics(document, hasNoDebugger); + } + }); + + test('matches rs lint and evaluates define.lint outside the extension host', async () => { + const document = await openLintTarget(); + const diagnostics = await waitForRslintDiagnostics(document, hasNoDebugger); + assert.ok( + hasNoDebugger(diagnostics), + `Expected the bridged no-debugger diagnostic, got: ${diagnostics + .map((diagnostic) => diagnostic.message) + .join(' | ')}`, + ); + + const rsBin = path.join( + path.dirname(root), + 'node_modules', + '.bin', + process.platform === 'win32' ? 'rs.cmd' : 'rs', + ); + const cli = spawnSync(rsBin, ['lint'], { + cwd: root, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + assert.equal(cli.error, undefined, `rs lint failed to start: ${cli.error}`); + assert.match( + `${cli.stdout}${cli.stderr}`, + /no-debugger/, + 'rs lint should report the same configured rule as the editor', + ); + + fs.writeFileSync( + rstackConfigPath, + configSource('error', markerPath), + 'utf8', + ); + await waitForRslintDiagnostics(document, () => fs.existsSync(markerPath)); + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')) as { + pid: number; + execPath: string; + }; + assert.notEqual( + marker.pid, + process.pid, + `define.lint ran in the extension host (${marker.execPath})`, + ); + assert.notEqual( + marker.execPath, + process.execPath, + `define.lint ran on the VS Code Node runtime (${marker.execPath})`, + ); + }); + + test('refreshes diagnostics when rstack.config.ts changes', async () => { + const document = await openLintTarget(); + await waitForRslintDiagnostics(document, hasNoDebugger); + + fs.writeFileSync(rstackConfigPath, configSource('off'), 'utf8'); + const diagnostics = await waitForRslintDiagnosticsCount(document, 0); + assert.deepStrictEqual(diagnostics, []); + + fs.writeFileSync(rstackConfigPath, originalConfig, 'utf8'); + await waitForRslintDiagnostics(document, hasNoDebugger); + }); + + test('replaces bridged ownership when a native config appears', async () => { + const document = await openLintTarget(); + await waitForRslintDiagnostics(document, hasNoDebugger); + + fs.writeFileSync( + nativeConfigPath, + `export default [{ rules: { 'no-debugger': 'off' } }];\n`, + 'utf8', + ); + await waitForRslintDiagnosticsCount(document, 0); + assert.deepStrictEqual(getRslintDiagnostics(document), []); + + fs.rmSync(nativeConfigPath); + await waitForRslintDiagnostics(document, hasNoDebugger); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-bridge/index.ts b/packages/vscode/e2e/lint/suite-bridge/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-bridge/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-eslint-plugins/plugin-pool.test.ts b/packages/vscode/e2e/lint/suite-eslint-plugins/plugin-pool.test.ts index dfd78da..f8d5ddc 100644 --- a/packages/vscode/e2e/lint/suite-eslint-plugins/plugin-pool.test.ts +++ b/packages/vscode/e2e/lint/suite-eslint-plugins/plugin-pool.test.ts @@ -1,10 +1,7 @@ // Ported from web-infra-dev/rslint // `packages/vscode-extension/__tests__/suite-eslint-plugins/plugin-pool.test.ts` -// (origin/main). Only the import paths changed: the copied extension sources -// live under `src/stacks/lint/` in this repo. The type-only -// `@rslint/core/eslint-plugin` import stays a devDependency; the runtime pool -// under test loads nothing from it (the runtime module is always resolved from -// the user's project, never bundled). +// (origin/main). The pool now lives in the dedicated lint worker, so this test +// covers that worker-owned lifecycle implementation directly. import * as assert from 'node:assert'; import type { @@ -13,9 +10,9 @@ import type { EslintPluginLintResult, PluginLintHost, } from '@rslint/core/eslint-plugin'; -import { CancellationTokenSource } from 'vscode'; -import { PluginLintPool } from '../../../src/stacks/lint/PluginLintPool'; -import type { Logger } from '../../../src/stacks/lint/logger'; +import { CancellationTokenSource } from 'vscode-jsonrpc/node'; +import { PluginLintPool } from '../../../src/stacks/lint/worker/PluginLintPool'; +import type { WorkerLogger } from '../../../src/stacks/lint/worker/logger'; function deferred(): { promise: Promise; @@ -61,11 +58,11 @@ function request(generation: string): EslintPluginLintRequest { }; } -function testLogger(): Logger { +function testLogger(): WorkerLogger { return { error() {}, debug() {}, - } as unknown as Logger; + } as unknown as WorkerLogger; } suite('PluginLintPool generations', () => { diff --git a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts index 112db8f..1d68af9 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts @@ -1,12 +1,12 @@ // Ported from web-infra-dev/rslint // `packages/vscode-extension/__tests__/suite-jsconfig/config-transaction.test.ts` // (origin/main). Adaptations: -// - Import paths: the copied extension sources live under `src/stacks/lint/`. +// - The transaction adapter now lives under the dedicated lint worker. // - `LspConfigTransactionAdapter` no longer bakes in a compile-time protocol // constant: `@rslint/core/config-loader` is resolved from the project at // runtime, so the adapter takes the loader's // `CONFIG_DISCOVERY_PROTOCOL_VERSION` as a constructor argument. The tests -// inject the devDependency's constant, which tracks the same `^0.7.2` floor +// inject the devDependency's constant, which tracks the same `^0.8.0` floor // as the fixtures. // - The watch-glob test asserts upstream's glob is kept verbatim, lockfiles // included. @@ -28,7 +28,7 @@ import { recoverConfigDiscoveryOnServerState, retryConfigRefreshOnSourceChange, } from '../../../src/stacks/lint/Rslint'; -import { LspConfigTransactionAdapter } from '../../../src/stacks/lint/ConfigTransactionAdapter'; +import { LspConfigTransactionAdapter } from '../../../src/stacks/lint/worker/ConfigTransactionAdapter'; import { State } from 'vscode-languageclient/node'; import { RelativePattern, @@ -335,60 +335,18 @@ suite('LSP config discovery transactions', () => { assert.deepStrictEqual(host.deletedTransactions, ['tx-abort']); }); - test('native-server restart aborts orphaned state and accepts a new transaction', async () => { - const host = new TestConfigHost(); - const pool = new TestPluginPool(); - const adapter = new LspConfigTransactionAdapter( - host, - pool, - () => 'fingerprint-restart', - CONFIG_DISCOVERY_PROTOCOL_VERSION, - ); - - await adapter.loadConfigs(loadRequest('old-process-tx')); - await adapter.activateConfigs({ - protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, - transactionId: 'old-process-tx', - effectiveConfigIds: ['root'], - }); - await adapter.resetForServerRestart(); - assert.deepStrictEqual(pool.abortCalls, ['old-process-tx']); - assert.deepStrictEqual(host.deletedTransactions, ['old-process-tx']); - - await adapter.loadConfigs(loadRequest('new-process-tx')); - const activation = await adapter.activateConfigs({ - protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, - transactionId: 'new-process-tx', - effectiveConfigIds: ['root'], - }); - assert.strictEqual(activation.transactionId, 'new-process-tx'); - }); - - test('a later Running state resets orphaned state before requesting an initial catalog', async () => { - const host = new TestConfigHost(); - const pool = new TestPluginPool(); - const adapter = new LspConfigTransactionAdapter( - host, - pool, - () => 'fingerprint-restart', - CONFIG_DISCOVERY_PROTOCOL_VERSION, - ); - await adapter.loadConfigs(loadRequest('orphaned-tx')); - + test('a later Running state requests a fresh initial catalog', async () => { const events: string[] = []; const recovery = recoverConfigDiscoveryOnServerState( State.Running, - async (reason, beforeRequest) => { + async (reason) => { events.push(`request:${reason}`); - await beforeRequest?.(adapter); events.push('send'); }, ); await recovery; assert.deepStrictEqual(events, ['request:initial', 'send']); - assert.deepStrictEqual(pool.abortCalls, ['orphaned-tx']); - assert.deepStrictEqual(host.deletedTransactions, ['orphaned-tx']); let stoppedRefresh = false; const ignored = recoverConfigDiscoveryOnServerState( diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index f86d0d5..7b4d36c 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -39,10 +39,11 @@ const SLICES = [ compile: true, }, { - // The ported Rslint suites; `RSTACK_LINT_E2E_SUITES=` filters - // which of them run. + // The ported Rslint suites plus the Rstack lint bridge; the latter uses the + // shared `rstack` fixture. `RSTACK_LINT_E2E_SUITES=` filters + // which suites run. name: 'lint', - fixtures: ['lint'], + fixtures: ['lint', 'rstack'], entry: 'tests-dist/e2e/lint/runTest.js', compile: true, }, diff --git a/packages/vscode/e2e/suite/detection.test.ts b/packages/vscode/e2e/suite/detection.test.ts index ca20cd8..64cc068 100644 --- a/packages/vscode/e2e/suite/detection.test.ts +++ b/packages/vscode/e2e/suite/detection.test.ts @@ -14,12 +14,10 @@ import { eventually } from './helpers'; * | ------- | --------------------- | ------ | ------ | --- | * | rslint | `rslint.config.mjs` | yes | no | no | * | rstest | `rstest.config.ts` | no | yes | no | - * | rstack | `rstack.config.ts` | no | yes | yes | + * | rstack | `rstack.config.ts` | yes | yes | yes | * * The rstack row is the interesting one: a single `rstack.config.*` lights - * Rstest and rs fmt even though no tool-native config exists (Rslint is - * deliberately NOT lit — TODO(rstack-bridge), the earlier lint bridge was - * removed pending upstream support), and `rs fmt` + * all three stacks even though no tool-native config exists, and `rs fmt` * is additionally confirmed by the bin probe (`rstack`'s two bins are `rs` and * `rstack`). * @@ -49,7 +47,7 @@ const EXPECTED: Readonly> = { rstackConfigFiles: [], }, rstack: { - detected: { rslint: false, rstest: true, fmt: true }, + detected: { rslint: true, rstest: true, fmt: true }, configFiles: { rslint: [], rstest: [], fmt: [] }, rstackConfigFiles: ['rstack.config.ts'], fmtBin: 'rs', diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 3c67597..ebc1885 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -139,7 +139,7 @@ "order": 1, "type": "string", "scope": "resource", - "markdownDescription": "Overrides the `node` binary used to spawn processes that load your project — the Rstest test worker and the `rs fmt` language server. Provide an absolute path to a Node.js executable (for example, a version-manager or custom build). An explicit choice is always honoured — the escape hatch when no suitable Node.js can be found automatically — but the version check still runs as an advisory: pointing at a Node.js below the supported floor shows a status-bar warning while runs proceed with it. When empty, the extension picks one: the `node` on `PATH` if it is new enough, otherwise the one your interactive shell resolves. Supports the `${workspaceFolder}` placeholder." + "markdownDescription": "Overrides the `node` binary used to spawn processes that load your project — the Rslint worker, the Rstest test worker and the `rs fmt` language server. Provide an absolute path to a Node.js executable (for example, a version-manager or custom build). An explicit choice is always honoured — the escape hatch when no suitable Node.js can be found automatically — but the version check still runs as an advisory: pointing at a Node.js below the supported floor shows a status-bar warning while runs proceed with it. When empty, the extension picks one: the `node` on `PATH` if it is new enough, otherwise the one your interactive shell resolves. Supports the `${workspaceFolder}` placeholder." } } }, @@ -154,29 +154,15 @@ "scope": "window", "markdownDescription": "Enable the Rslint language server for detected workspace folders. Requires `#rstack.enable#`. This is a window-level kill switch; which folders run Rslint is decided by detection." }, - "rstack.rslint.binPath": { + "rstack.rslint.corePath": { "order": 1, "type": "string", - "enum": [ - "local", - "custom" - ], - "default": "local", + "default": "", "scope": "resource", - "markdownEnumDescriptions": [ - "Resolve the Rslint binary from the workspace `node_modules` (Yarn PnP is supported). This extension ships no binary.", - "Use the binary at `#rstack.rslint.customBinPath#`, e.g. a global installation or a specific version of Rslint." - ], - "description": "How to locate the Rslint executable binary" - }, - "rstack.rslint.customBinPath": { - "order": 2, - "type": "string", - "scope": "resource", - "markdownDescription": "Custom path to the Rslint executable. Only used when `#rstack.rslint.binPath#` is set to `custom`. Requires reloading VS Code to take effect." + "markdownDescription": "Path to an `@rslint/core` package directory. Relative paths are resolved from the workspace folder. When empty, the extension resolves the core from the folder's selected lint config source." }, "rstack.rslint.trace.server": { - "order": 3, + "order": 2, "type": "string", "enum": [ "off", @@ -444,7 +430,7 @@ "devDependencies": { "@rsbuild/core": "~2.1.9", "@rslib/core": "^1.0.0-beta.1", - "@rslint/core": "^0.7.2", + "@rslint/core": "^0.8.0", "@rstackjs/load-config": "^0.1.2", "@rstest/core": "^0.11.5", "@types/istanbul-lib-report": "^3.0.3", @@ -466,6 +452,7 @@ "tinyglobby": "^0.2.17", "typescript": "^5.9.3", "valibot": "^1.4.2", + "vscode-jsonrpc": "^8.2.0", "vscode-languageclient": "^9.0.1", "yuku-parser": "^0.8.3" }, diff --git a/packages/vscode/rslib.config.mts b/packages/vscode/rslib.config.mts index 015f410..44cc050 100644 --- a/packages/vscode/rslib.config.mts +++ b/packages/vscode/rslib.config.mts @@ -45,6 +45,7 @@ const sourceMap = process.env.SOURCEMAP === 'true'; // while the stacks are still being copied in. const workerEntry = './src/stacks/test/worker/index.ts'; const hasWorkerEntry = existsSync(path.join(rootDir, workerEntry)); +const lintWorkerEntry = './src/stacks/lint/worker/main.ts'; const libs: LibConfig[] = [ { @@ -113,4 +114,26 @@ if (hasWorkerEntry) { }); } +libs.push({ + syntax: 'es2023', + format: 'cjs', + source: { + entry: { + 'lint-worker': lintWorkerEntry, + }, + }, + output: { + target: 'node', + externals, + sourceMap, + }, + tools: { + rspack: { + output: { + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }, + }, + }, +}); + export default defineConfig({ lib: libs }); diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index d765af5..dd906cc 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -6,18 +6,12 @@ import { type StackId, STACK_IDS, } from './types'; +import { decideRslintMode } from './stacks/lint/resolution'; /** - * `rstack.config.*` is a config source for Rstest and rs fmt: - * an rstack-cli user may only have `rstack.config.ts` with `define.test()` / - * `define.fmt()`. - * - * TODO(rstack-bridge): Rslint is deliberately NOT lit by `rstack.config.*` for - * now — the earlier lint bridge had no complete final data path and was - * removed. Rebuilding it needs upstream work: rstack publishing an - * explicit-path config loader plus adapter exports, rslint accepting per-root - * fallback config candidates on `rslint/configRefresh`, and a generic - * evaluator-module seam shared by the config host and plugin workers. + * `rstack.config.*` is a config source for all three tool stacks. Lint only + * bridges a config at the workspace-folder root; test and fmt retain their own + * project/config-root rules. */ export const RSTACK_CONFIG_NAMES = [ 'rstack.config.ts', @@ -187,13 +181,23 @@ export const detectFolder = async ( ), ] as const); + const rootRstackConfigPath = rstackConfigFiles.find((uri) => + RSTACK_CONFIG_NAMES.some( + (name) => + vscode.Uri.joinPath(folder.uri, name).toString() === uri.toString(), + ), + )?.fsPath; + const rslintMode = decideRslintMode({ + nativeConfigPaths: rslintConfigFiles.map((uri) => uri.fsPath), + rootRstackConfigPath, + }); + const stacks: Record = { rslint: { - // TODO(rstack-bridge): `rstack.config.*` deliberately does not light - // Rslint (see the RSTACK_CONFIG_NAMES doc comment). - detected: rslintConfigFiles.length > 0, + detected: rslintMode !== undefined, configFiles: rslintConfigFiles, rstackConfigFiles, + mode: rslintMode, }, rstest: { detected: rstestConfigFiles.length > 0 || rstackConfigFiles.length > 0, @@ -220,7 +224,7 @@ const signatureOf = (snapshot: DetectionSnapshot): string => .map((uri) => uri.toString()) .sort() .join(','); - return `${stack}:${detection.detected ? 1 : 0}:${detection.binPath ?? ''}:${files}`; + return `${stack}:${detection.detected ? 1 : 0}:${detection.mode ?? ''}:${detection.binPath ?? ''}:${files}`; }).join('|'); return `${entry.folder.uri.toString()}=>${stacks}`; }) diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index f7c0f67..3142684 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -25,13 +25,8 @@ const STACK_FACTORIES: Readonly> = { fmt: createFmtController, }; -/** - * The stacks that run project code on a User Node runtime and therefore read - * the host-scoped preflight memo (`shared/nodeResolution.ts`) — the set - * `runRestart` checks before resetting it. Lint is deliberately absent: it - * still runs on the VS Code Node runtime (ADR 0001's recorded debt). - */ -const USER_NODE_STACKS: readonly StackId[] = ['rstest', 'fmt']; +/** Stacks that run project-loading children on the shared User Node runtime. */ +const USER_NODE_STACKS: readonly StackId[] = ['rslint', 'rstest', 'fmt']; const errorMessage = (error: unknown): string => error instanceof Error ? (error.stack ?? error.message) : String(error); diff --git a/packages/vscode/src/migration.ts b/packages/vscode/src/migration.ts index a7eba33..f12a59c 100644 --- a/packages/vscode/src/migration.ts +++ b/packages/vscode/src/migration.ts @@ -52,9 +52,9 @@ type ValueMapping = | { readonly kind: 'skip'; readonly reason: SkipReason }; export interface LegacyMapping { - /** Fully qualified legacy key, e.g. `rslint.binPath`. */ + /** Fully qualified legacy key, e.g. `rslint.enable`. */ readonly from: string; - /** Fully qualified new key, e.g. `rstack.rslint.binPath`. */ + /** Fully qualified new key, e.g. `rstack.rslint.enable`. */ readonly to: string; /** * Scope of the *new* key in this extension's manifest. A window-scoped @@ -70,23 +70,6 @@ export interface LegacyMapping { readonly mapValue?: (value: unknown) => ValueMapping; } -/** - * `rslint.binPath` is the one non-mechanical mapping: the old - * default `built-in` no longer exists because the extension ships no Rslint - * binary. The new default is `local`; an explicitly set `built-in` becomes - * `local`, `custom` carries over together with `customBinPath`, and anything - * else is not in the new enum and would poison the setting. - */ -const mapBinPath = (value: unknown): ValueMapping => { - if (value === 'built-in') { - return { kind: 'value', value: 'local' }; - } - if (value === 'local' || value === 'custom') { - return { kind: 'value', value }; - } - return { kind: 'skip', reason: 'unsupported-value' }; -}; - /** * Legacy Rstest keys, in manifest order. Every one of them is a mechanical * `rstest.` -> `rstack.rstest.` rename; only the scope of the *new* @@ -115,8 +98,10 @@ const RSTEST_KEYS: readonly (readonly [string, 'resource' | 'window'])[] = [ ]; /** - * The complete legacy inventory: 4 Rslint keys + 14 Rstest keys. Kept in one - * table so the preview, the writer and the tests cannot disagree. + * The migratable legacy inventory: 2 Rslint keys + 14 Rstest keys. The old + * Rslint binary settings are intentionally absent: a binary path cannot be + * translated into the `@rslint/core` package directory the worker requires. + * Kept in one table so the preview, writer and tests cannot disagree. */ export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ { @@ -127,17 +112,6 @@ export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ to: 'rstack.rslint.enable', targetScope: 'window', }, - { - from: 'rslint.binPath', - to: 'rstack.rslint.binPath', - targetScope: 'resource', - mapValue: mapBinPath, - }, - { - from: 'rslint.customBinPath', - to: 'rstack.rslint.customBinPath', - targetScope: 'resource', - }, { from: 'rslint.trace.server', to: 'rstack.rslint.trace.server', diff --git a/packages/vscode/src/shared/nodeExecutableSetting.ts b/packages/vscode/src/shared/nodeExecutableSetting.ts index bfb52d4..67140ac 100644 --- a/packages/vscode/src/shared/nodeExecutableSetting.ts +++ b/packages/vscode/src/shared/nodeExecutableSetting.ts @@ -14,8 +14,8 @@ export const expandWorkspaceFolder = ( /** * The shared User Node runtime pin (`rstack.nodeExecutable`), read by every - * stack that spawns a project-loading child process (the rstest worker, the - * `rs fmt --lsp` server). One setting rather than one per stack on purpose: + * stack that spawns a project-loading child process (the lint worker, the + * rstest worker and the `rs fmt --lsp` server). One setting rather than one per stack on purpose: * the runtime selection logic is uniform across stacks, so the escape hatch * must be too — a user pinning Node for one tool means it for the toolchain. * diff --git a/packages/vscode/src/shared/nodeResolution.ts b/packages/vscode/src/shared/nodeResolution.ts index a4fa746..f3430ce 100644 --- a/packages/vscode/src/shared/nodeResolution.ts +++ b/packages/vscode/src/shared/nodeResolution.ts @@ -8,7 +8,7 @@ import { /** * Choosing the User Node runtime a stack's project-loading child process runs - * on (the rstest worker, the `rs fmt --lsp` server). + * on (the lint worker, the rstest worker and the `rs fmt --lsp` server). * * The bare `node` a GUI extension host inherits is a *login-shell* snapshot * taken at startup — typically a version manager's global default rather than diff --git a/packages/vscode/src/shared/vendored/loadRstackConfig.ts b/packages/vscode/src/shared/vendored/loadRstackConfig.ts deleted file mode 100644 index d17d95f..0000000 --- a/packages/vscode/src/shared/vendored/loadRstackConfig.ts +++ /dev/null @@ -1,244 +0,0 @@ -// TODO: revisit asking rstack-cli to export loadRstackConfig from the root entry. -// An official export would delete this vendored copy, whose globalThis session key is internal API. -// -// NOTE(rstack-bridge): the rslint bridge — this loader's main consumer — was -// deliberately removed; rebuilding it needs upstream work (rstack publishing an -// explicit-path config loader plus adapter exports, rslint accepting per-root -// fallback config candidates on `rslint/configRefresh`, and a generic -// evaluator-module seam shared by the config host and plugin workers). The -// formatter deliberately leaves `define.fmt()` evaluation to the CLI. This -// copy remains for the Rslint jiti preflight's `nativeTypeStrippingAvailable` -// probe and for direct loader unit coverage. -// -// Vendored from rstackjs/rstack-cli `packages/rstack/src/config.ts` -// (origin/main @ 6494ba2, rstack@0.3.2). Only three things differ from upstream: -// -// 1. the type imports of configs this extension does not care about are widened -// to `unknown` so the extension does not depend on @rsbuild/@rslib/@rspress; -// 2. `loadRstackConfig` accepts a `cwd` and a `loader` in addition to -// `configFilePath` — see the comment on `RstackConfigLoader`; -// 3. `define` is exported for completeness but is never used by the extension: -// the user's own `rstack.config.*` imports `define` from *their* `rstack` -// install. Interop is safe by construction because the session storage lives -// on `globalThis` under the exact upstream key, so both module instances -// share one active session. -// -// The `globalThis.__rstackConfigSessionStorage` key is internal API of -// rstack-cli. It must stay byte-identical to upstream — it is the entire -// interop mechanism. -import { AsyncLocalStorage } from 'node:async_hooks'; -import { loadConfig } from '@rstackjs/load-config'; -import type { RslintConfig } from '@rslint/core'; -import type { RstestConfigExport } from '@rstest/core'; - -export type RslintConfigDefinition = - RslintConfig | (() => Promise); - -export type Configs = { - /** `define.app` — an Rsbuild config; not consumed by the extension. */ - app?: unknown; - /** `define.lib` — an Rslib config; not consumed by the extension. */ - lib?: unknown; - /** `define.doc` — an Rspress config; not consumed by the extension. */ - doc?: unknown; - test?: RstestConfigExport; - lint?: RslintConfigDefinition; - /** `define.fmt` — a Prettier config; consumed by `rs fmt` itself. */ - fmt?: unknown; - /** `define.staged` — a lint-staged config; not consumed by the extension. */ - staged?: unknown; -}; - -export type LoadedRstackConfig = { - configs: Configs; - filePath: string | null; - /** - * Absolute paths of the statically imported relative dependencies of the - * config file — usable as extra watch targets. - */ - dependencies: string[]; -}; - -/** - * Divergence from upstream, deliberate and isolated: rstack-cli hardcodes - * `loader: 'native'`, whose failure path rethrows instead of falling back to - * jiti. The CLI runs on the user's Node (`engines.node >= 22.12`), but this - * loader runs on the extension host's Node, whose version is fixed by VS Code. - * On a host without native TypeScript type stripping, `'native'` hard-fails on - * every `rstack.config.ts`, so the default here is `'auto'`. - * - * `'auto'` falls back to jiti, which has to be resolvable — see - * `nativeTypeStrippingAvailable`, the preflight the caller is expected to use - * for an actionable status bar hint. - */ -export type RstackConfigLoader = 'native' | 'auto'; - -export type LoadRstackConfigOptions = { - /** - * The path to the Rstack config file, can be a relative or absolute path. - * If `configFilePath` is not provided, the config path set by the CLI is used. - * If neither path is provided, the function will search for the config file in the current working directory. - */ - configFilePath?: string; - /** - * Directory the default `rstack.config.*` probe runs in. Upstream never sets - * it (the CLI relies on `process.cwd()`); the extension host's cwd is - * meaningless, so a caller without an explicit `configFilePath` must pass it. - */ - cwd?: string; - loader?: RstackConfigLoader; -}; - -type ConfigSession = { - configs: Configs; - active: boolean; -}; - -type ConfigState = { - configPath?: string; -}; - -declare global { - // rslint-disable-next-line no-var - var __rstackConfigSessionStorage: - AsyncLocalStorage | undefined; - // rslint-disable-next-line no-var - var __rstackCliState: ConfigState | undefined; -} - -const getConfigSessionStorage = (): AsyncLocalStorage => { - // Rsbuild's fresh import loader can load this module more than once when it - // imports the internal Rstack config. Keep the storage on globalThis so - // every module instance reads and writes the same active session. - if (!globalThis.__rstackConfigSessionStorage) { - globalThis.__rstackConfigSessionStorage = - new AsyncLocalStorage(); - } - - return globalThis.__rstackConfigSessionStorage; -}; - -export const getConfigState = (): ConfigState => { - // The CLI and its internal tool config can also be loaded as separate module - // instances. Keep only the CLI config path in its own global state. - if (!globalThis.__rstackCliState) { - globalThis.__rstackCliState = {}; - } - - return globalThis.__rstackCliState; -}; - -type Define = { - app: (config: unknown) => void; - lib: (config: unknown) => void; - doc: (config: unknown) => void; - test: (config: RstestConfigExport) => void; - lint: (config: RslintConfigDefinition) => void; - fmt: (config: unknown) => void; - staged: (config: unknown) => void; -}; - -const setConfig = ( - type: T, - config: Configs[T], -): void => { - const session = getConfigSessionStorage().getStore(); - - if (!session?.active) { - throw new Error( - `The "${type}" config must be defined while loading an Rstack config.`, - ); - } - - if (type in session.configs) { - throw new Error(`The "${type}" config has already been defined.`); - } - session.configs[type] = config; -}; - -export const define: Define = { - app: (config) => setConfig('app', config), - lib: (config) => setConfig('lib', config), - doc: (config) => setConfig('doc', config), - test: (config) => setConfig('test', config), - lint: (config) => setConfig('lint', config), - fmt: (config) => setConfig('fmt', config), - staged: (config) => setConfig('staged', config), -}; - -export const RSTACK_CONFIG_FILE_NAMES = [ - 'rstack.config.ts', - 'rstack.config.js', - 'rstack.config.mts', - 'rstack.config.mjs', -]; - -export const loadRstackConfig = async ({ - configFilePath, - cwd, - loader = 'auto', -}: LoadRstackConfigOptions = {}): Promise => { - const state = getConfigState(); - const configPath = configFilePath ?? state.configPath; - const session: ConfigSession = { - configs: {}, - active: true, - }; - - return getConfigSessionStorage().run(session, async () => { - try { - const { filePath, dependencies } = await loadConfig({ - loader, - exportName: false, - fresh: true, - ...(cwd !== undefined ? { cwd } : {}), - ...(configPath !== undefined - ? { path: configPath } - : { - configFileNames: RSTACK_CONFIG_FILE_NAMES, - }), - }); - - return { - configs: session.configs, - filePath, - dependencies, - }; - } finally { - session.active = false; - session.configs = {}; - } - }); -}; - -/** - * Preflight for the loader: when this is false, a `.ts`/`.mts` Rstack config - * can only be loaded through jiti, and a missing jiti has to be surfaced as an - * actionable hint instead of a generic config-load failure. - * - * Verified on a VS Code-class host (Node 20, no type stripping): loading a - * `.ts` config through this loader ends in `@rstackjs/load-config`'s - * `The "jiti" package is required to load this config.` — and because this - * loader is *bundled into the extension*, its `import('jiti')` resolves from - * the extension, not from the user's project. Installing jiti in the project - * therefore does not fix it on its own. Callers must preflight with - * `nativeTypeStrippingAvailable()` and show the hint below. - */ -export const nativeTypeStrippingAvailable = (): boolean => - Boolean((process.features as { typescript?: unknown }).typescript); - -export const JITI_REQUIRED_HINT = - 'This VS Code build cannot strip TypeScript types, so an rstack.config.ts can only be loaded through jiti. Use rstack.config.mjs/js, or run VS Code on a Node build with type stripping.'; - -/** - * Applies the logic of rstack-cli's `dist/rslintConfig.js` shim to a loaded - * Rstack config. The Rslint path deliberately does not import that shim: it - * would call `loadRstackConfig()` with no arguments inside the extension host, - * whose cwd is meaningless. - */ -export const resolveRslintConfig = async ( - configs: Configs, -): Promise => { - const lintExports = configs.lint ?? []; - return typeof lintExports === 'function' ? await lintExports() : lintExports; -}; diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index 95229e4..274f308 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -8,26 +8,25 @@ import { readPackageJson } from './packageResolve'; * status bar state with actual vs required versions. * * Launch floors (verified against npm): - * - `@rslint/core >= 0.7.2` — first version whose package exports - * `./config-loader` and `./eslint-plugin`. + * - `@rslint/core >= 0.8.0` — explicit protocol-2 config selection. * - `@rstest/core >= 0.6.0` — the existing `MIN_CORE_VERSION` upstream. - * - `rstack >= 0.5.2` — first release with `rs fmt --lsp`, the language server - * the fmt stack is a client of. There is no stdin fallback for older + * - `rstack >= 0.6.1` — the release whose lint shim uses @rslint/core 0.8. + * There is no stdin fallback for older * releases: the editor would then format through a different code path than * the one it is tested against, so the floor is a version gate instead. * The floor is **uniform across consumers by decision**: the Rstest bridge - * checks the same entry, so a project on rstack 0.3.5–0.5.1 reports - * `version mismatch` for tests too, even though only fmt strictly needs - * 0.5.2. One matrix entry means "which rstack does the extension support?" + * checks the same entry, so an older rstack reports `version mismatch` for + * tests too, even when that stack's own API would still work. One matrix + * entry means "which rstack does the extension support?" * has one answer; per-stack rstack floors were considered and rejected — * they make the answer depend on which status the user happens to look at, * for the price of keeping tests alive on releases the toolchain has moved * past. The status message names the required version either way. */ export const SUPPORT_MATRIX = { - '@rslint/core': '>=0.7.2', + '@rslint/core': '>=0.8.0', '@rstest/core': '>=0.6.0', - rstack: '>=0.5.2', + rstack: '>=0.6.1', } as const; export type SupportedPackage = keyof typeof SUPPORT_MATRIX; @@ -56,8 +55,8 @@ export const readPackageVersion = ( }; /** - * The floor for the Node.js a project-loading child process runs on — the test - * worker and the `rs fmt --lsp` server alike. Not part of + * The floor for the Node.js a project-loading child process runs on — the lint + * worker, test worker and `rs fmt --lsp` server alike. Not part of * `SUPPORT_MATRIX`, which is keyed by npm package name, but the same kind of * fact and deliberately kept in the same file so "what does this extension * require?" has one answer. @@ -143,22 +142,3 @@ export const reportVersionCheck = ( } return true; }; - -/** - * The reverse config-discovery protocol between the Rslint Go server and the - * project-resolved `@rslint/core/config-loader` is versioned independently of - * the package version, so `semver.satisfies` is necessary but - * not sufficient: the client additionally validates the `protocolVersion` - * carried by `rslint/configRefresh`. - */ -export const SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS: ReadonlySet = - new Set([1]); - -export const isSupportedConfigDiscoveryProtocolVersion = ( - version: number, -): boolean => SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS.has(version); - -export const formatProtocolVersionMismatch = (version: number): string => - `Rslint config-discovery protocol version ${version} is not supported (supported: ${[ - ...SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS, - ].join(', ')})`; diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 5acb2bc..af0bcfd 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -1,44 +1,18 @@ // Copied from web-infra-dev/rslint `packages/vscode-extension/src/Rslint.ts` -// (origin/main). This is the most heavily adapted file of the port; -// every divergence from upstream is one of: -// -// 1. the shell-activation adaptation — nothing here creates a status bar item, -// registers a command or activates the extension; the shell owns the -// lifecycle and this class is a per-workspace-folder runtime driven by -// `WorkspaceRslintCoordinator`. -// 2. the namespace adaptation — every setting is read from `rstack.rslint.*`. -// 3. the resolve-from-project adaptation — the `built-in` binary mode is gone, -// the binary/config-loader/eslint-plugin all come from one project -// resolution root (`resolution.ts`), and `@rslint/core/config-loader` -// contributes types only at compile time; `CONFIG_DISCOVERY_PROTOCOL_VERSION` -// and `ConfigModuleHost` are injected from the project-resolved module. -// 4. the status-aggregation adaptation — `reportStatus` instead of an own -// status bar. -// 5. watch glob — `CONFIG_REFRESH_WATCH_GLOB` kept verbatim. -// -// Plus the two compatibility diagnostics: the config-discovery protocol -// handshake and the jiti preflight. -// -// TODO(rstack-bridge): Rslint deliberately does NOT consume `rstack.config.*` -// (`define.lint()`) for now. The earlier bridge was removed because it had no -// complete final data path — the LSP has no explicit-config channel, the shim -// resolves its config from a meaningless extension-host cwd, and plugin -// workers would re-import the wrong source shape. Rebuilding it needs upstream -// work: rstack publishing an explicit-path config loader plus adapter exports, -// rslint accepting per-root fallback config candidates on -// `rslint/configRefresh`, and a generic evaluator-module seam shared by the -// config host and plugin workers. +// and adapted so the extension host is only the language-client half. Project +// config evaluation and plugin rules run in the editor-shipped lint worker. +import path from 'node:path'; import { - workspace, - Uri, Disposable, - FileSystemWatcher, + type FileSystemWatcher, + type OutputChannel, RelativePattern, - WorkspaceFolder, - OutputChannel, - TextDocument, - type CancellationToken, + type TextDocument, + Uri, + workspace, + type WorkspaceFolder, + env, } from 'vscode'; import { CloseAction, @@ -46,74 +20,43 @@ import { DidOpenTextDocumentNotification, ErrorAction, LanguageClient, - LanguageClientOptions, + type LanguageClientOptions, type ErrorHandler, type Middleware, - ServerOptions, + type ServerOptions, State, Trace, } from 'vscode-languageclient/node'; -import type { Logger } from './logger'; -import path from 'node:path'; -import fs from 'node:fs'; -import type { - ActivateConfigsRequest, - ConfigModuleActivationPlan, - LoadConfigsRequest, -} from '@rslint/core/config-loader'; -import { PluginLintPool } from './PluginLintPool'; -import type { - ConfigDescriptor, - EslintPluginLintRequest, - PluginLintHost, -} from '@rslint/core/eslint-plugin'; import { - ConfigTransactionProtocolMismatchError, - LspConfigTransactionAdapter, - type ConfigTransactionControlRequest, -} from './ConfigTransactionAdapter'; -import { - createWorkspaceDocumentSelector, - type WorkspaceDocumentRouter, -} from './WorkspaceDocumentRouter'; -import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; -import type { StackState } from '../../types'; + configuredNodeBelowFloor, + NodePreflightError, + resolveUserNodeOnce, +} from '../../shared/nodeResolution'; +import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; import { checkPackageVersion, formatVersionMismatch, } from '../../shared/versionCheck'; +import type { StackState } from '../../types'; +import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; +import type { Logger } from './logger'; +import { resolveRslint, type RslintMode } from './resolution'; import { - ConfigDiscoveryProtocolMismatchError, - loadConfigLoaderModule, - loadEslintPluginModule, - type ConfigLoaderModule, -} from './configLoader'; -import { - RslintResolutionError, - resolveRslint, - type RslintResolution, -} from './resolution'; + RslintVersionMismatchError, + runningRslintStatus, + statusForRslintStartFailure, +} from './status'; import { - describeJitiPreflight, - isJitiMissingError, - JITI_INSTALL_HINT, -} from './jitiPreflight'; -/** - * Workspace-relative lockfiles whose individual metadata feeds the - * plugin-host fingerprint. A dependency install can swap a plugin's - * implementation without touching the config file, so the host must rebuild. - */ + createWorkspaceDocumentSelector, + type WorkspaceDocumentRouter, +} from './WorkspaceDocumentRouter'; + const LOCKFILE_NAMES = [ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', ] as const; -/** - * Kept verbatim from upstream, JSON names included: they are not detection - * signals but watching them is harmless, and the Go server does - * load `rslint.json` from its cwd as a no-JS-config fallback. - */ const RSLINT_CONFIG_WATCH_NAMES = [ 'rslint.config.js', 'rslint.config.mjs', @@ -123,58 +66,27 @@ const RSLINT_CONFIG_WATCH_NAMES = [ 'rslint.jsonc', ] as const; -// Upstream's glob kept verbatim. `rstack.config.*` is -// deliberately absent: see the TODO(rstack-bridge) note in the file header. export const CONFIG_REFRESH_WATCH_GLOB = `**/{${[ ...RSLINT_CONFIG_WATCH_NAMES, ...LOCKFILE_NAMES, ].join(',')}}`; -/** - * The project's `@rslint/core` is outside the support matrix. - * Distinct from a crash so the status bar can show `version mismatch` with the - * actual and the required version. - */ -export class RslintVersionMismatchError extends Error { - constructor(message: string) { - super(message); - this.name = 'RslintVersionMismatchError'; - } -} +/** Kept separate to avoid the nested-brace glob shape VS Code cannot parse. */ +export const RSTACK_CONFIG_REFRESH_WATCH_GLOB = 'rstack.config.{ts,js,mts,mjs}'; export type ConfigRefreshReason = 'initial' | 'config-change' | 'dependency-change'; -interface ConfigRefreshRequest { - /** - * The protocol version is no longer a compile-time constant of - * a bundled loader — it is read from the project-resolved - * `@rslint/core/config-loader`, so the value the Go server sees is always the - * one the JS side actually implements. - */ - protocolVersion: number; - reason: ConfigRefreshReason; -} - export type ConfigRefreshRequester = ( reason: ConfigRefreshReason, - beforeRequest?: (adapter: LspConfigTransactionAdapter) => Promise, ) => Promise; -/** - * Recover the extension-side transaction host when LanguageClient restarts its - * native server. The listener using this helper is installed only after the - * initial Running transition, so a later Running state unambiguously means the - * replacement process needs a new initial catalog. - */ export function recoverConfigDiscoveryOnServerState( newState: State, requestConfigRefresh: ConfigRefreshRequester, ): Promise | undefined { if (newState !== State.Running) return undefined; - return requestConfigRefresh('initial', async (adapter) => - adapter.resetForServerRestart(), - ); + return requestConfigRefresh('initial'); } export function shouldResetDocumentSessionOnServerState( @@ -184,7 +96,6 @@ export function shouldResetDocumentSessionOnServerState( return oldState === State.Running && newState !== State.Running; } -/** Bind each language client to the workspace whose Go process owns discovery. */ export function createLanguageClientOptions( workspaceFolder: WorkspaceFolder, outputChannel: OutputChannel | undefined, @@ -193,10 +104,6 @@ export function createLanguageClientOptions( const documentSelector = createWorkspaceDocumentSelector(workspaceFolder); return { workspaceFolder, - // languageclient v9 types this client-only selector as the LSP shape, - // whose pattern is string-only. Its converter forwards the pattern to - // VS Code's DocumentFilter, which supports RelativePattern and preserves - // an unambiguous workspace base even when the path contains glob syntax. documentSelector: // rslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion documentSelector as unknown as LanguageClientOptions['documentSelector'], @@ -208,11 +115,13 @@ export function createLanguageClientOptions( export function configRefreshReasonForPath( filePath: string, ): Exclude { - const basename = path.basename(filePath); - if ((LOCKFILE_NAMES as readonly string[]).includes(basename)) { - return 'dependency-change'; - } - return 'config-change'; + return (LOCKFILE_NAMES as readonly string[]).includes(path.basename(filePath)) + ? 'dependency-change' + : 'config-change'; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { @@ -224,10 +133,6 @@ export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { ); } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - export async function retryConfigRefreshOnSourceChange( initial: () => Promise, retry: () => Promise, @@ -242,22 +147,6 @@ export async function retryConfigRefreshOnSourceChange( } } -async function withCancellationSignal( - token: CancellationToken, - operation: (signal: AbortSignal) => Promise, -): Promise { - const controller = new AbortController(); - if (token.isCancellationRequested) controller.abort(); - const subscription = token.onCancellationRequested(() => { - controller.abort(); - }); - try { - return await operation(controller.signal); - } finally { - subscription.dispose(); - } -} - function abortError(signal: AbortSignal): Error { if (signal.reason instanceof Error) return signal.reason; const error = new Error('Rslint workspace start was cancelled'); @@ -275,9 +164,7 @@ async function raceWithAbort( ): Promise { throwIfAborted(signal); return new Promise((resolve, reject) => { - const onAbort = () => { - reject(abortError(signal)); - }; + const onAbort = () => reject(abortError(signal)); signal.addEventListener('abort', onAbort, { once: true }); operation.then( (value) => { @@ -298,12 +185,6 @@ export interface LanguageClientCloseTarget { dispose(): Promise; } -/** - * vscode-languageclient calls stop() without observing its promise when an - * initialize request fails. Its base stop rejects for non-Running states; the - * process owner handles those states, so normalize only that inactive case and - * preserve actionable failures from a Running shutdown. - */ export class ManagedLanguageClient extends LanguageClient { public override async stop(timeout?: number): Promise { const stateBeforeStop = this.state; @@ -315,12 +196,6 @@ export class ManagedLanguageClient extends LanguageClient { } } -/** - * Disposes a LanguageClient without waiting for a possibly hung initialize - * request. Its outer LanguageServerProcessOwner blocks restarts and terminates - * the callback-owned child after an inactive-state rejection; failures from a - * Running client remain independently actionable. - */ export async function disposeLanguageClient( client: LanguageClientCloseTarget, ): Promise { @@ -356,9 +231,7 @@ export async function waitForPromiseSettlement( () => true, ), new Promise((resolve) => { - timer = setTimeout(() => { - resolve(false); - }, timeoutMs); + timer = setTimeout(() => resolve(false), timeoutMs); }), ]); if (!settled) { @@ -395,36 +268,17 @@ function observeClientStopped( }; } -/** Config files detection found for this folder, read at every start. */ -export interface RslintFolderConfigPaths { - /** Native `rslint.config.{js,mjs,ts,mts}` files. */ - readonly rslintConfigPaths: readonly string[]; -} - -/** - * The status-aggregation adaptation: this runtime owns no status bar item. It pushes - * its folder-level state to the stack controller, which aggregates every folder - * into the one shared status bar entry. - */ export type RslintStatusSink = (state: StackState) => void; export interface RslintOptions { readonly rootKey: string; readonly workspaceFolder: WorkspaceFolder; - /** The shell-owned `Rstack: Rslint` channel. */ readonly outputChannel: OutputChannel; - /** - * Trace sink for `rstack.rslint.trace.server`. The extension deliberately - * caps itself at four channels, so this is the same channel as `outputChannel` - * unless a caller wants them split. - */ readonly lspOutputChannel: OutputChannel; readonly router: WorkspaceDocumentRouter; - /** Folder-scoped view of the shell's shared output channel. */ readonly logger: Logger; readonly reportStatus: RslintStatusSink; - /** Read lazily so a re-start after a detection change sees current paths. */ - readonly getConfigPaths: () => RslintFolderConfigPaths; + readonly getMode: () => RslintMode; } export class Rslint implements Disposable { @@ -434,94 +288,40 @@ export class Rslint implements Disposable { public readonly workspaceFolder: WorkspaceFolder; private readonly router: WorkspaceDocumentRouter; private readonly reportStatus: RslintStatusSink; - private readonly getConfigPaths: () => RslintFolderConfigPaths; - /** Set by `startImpl` before anything can use a project-resolved module. */ - private resolution: RslintResolution | undefined; - private configLoader: ConfigLoaderModule | undefined; - /** Non-fatal notes appended to the folder's status detail. */ - private statusNotes: string[] = []; - private readonly lspOutputChannel: OutputChannel | undefined; - private readonly outputChannel: OutputChannel | undefined; - private configWatcher: FileSystemWatcher | undefined; + private readonly getMode: () => RslintMode; + private readonly lspOutputChannel: OutputChannel; + private readonly outputChannel: OutputChannel; + private readonly configWatchers: FileSystemWatcher[] = []; private configReloadTimer: ReturnType | undefined; private configReloadChain: Promise = Promise.resolve(); private serverRestartWatcher: Disposable | undefined; private serverProcessOwner: LanguageServerProcessOwner | undefined; private stateWatcher: Disposable | undefined; - private readonly requestHandlers: Disposable[] = []; private lifecycleEpoch = 0; - private pluginDependencyRevision = 0; - private pluginLintPoolDisposed = false; - private configTransactionAdapter: LspConfigTransactionAdapter | undefined; + private advisory: string | undefined; private startPromise: Promise | undefined; private startOperation: Promise | undefined; private clientStartPromise: Promise | undefined; private closePromise: Promise | undefined; private closing = false; - /** - * Hosts the in-process WorkerPool that answers Go's reverse - * `rslint/pluginLint` requests for rules mounted via a config's - * object-form `plugins`. It stays uninitialized until a config actually - * mounts plugins. - */ - private readonly pluginLintPool: PluginLintPool; constructor(options: RslintOptions) { this.rootKey = options.rootKey; this.workspaceFolder = options.workspaceFolder; this.router = options.router; this.reportStatus = options.reportStatus; - this.getConfigPaths = options.getConfigPaths; - const logger = options.logger; - this.logger = logger; + this.getMode = options.getMode; + this.logger = options.logger; this.lspOutputChannel = options.lspOutputChannel; this.outputChannel = options.outputChannel; - try { - // The resolve-from-project adaptation: the ESLint-plugin host is loaded from the project, - // out of the same `@rslint/core` install as the Go binary. The factory is - // only invoked once a config actually mounts plugins, which is always - // after `startImpl` published `this.resolution`. - this.pluginLintPool = new PluginLintPool(logger, async (configs, onLog) => - this.createPluginHost(configs, onLog), - ); - } catch (error) { - logger.dispose(); - throw error; - } } - private async createPluginHost( - configs: ConfigDescriptor[], - onLog: (rec: { level: string; source: string; text: string }) => void, - ): Promise { - const resolution = this.resolution; - if (!resolution) { - throw new Error( - 'the ESLint-plugin host was requested before @rslint/core was resolved from the project', - ); - } - const module = await loadEslintPluginModule(resolution); - return module.createPluginLintHost(configs, onLog); - } - - /** Folder-level status, aggregated by the stack controller (the status-aggregation adaptation). */ private report(state: StackState): void { this.reportStatus(state); } - private runningDetail(): string | undefined { - return this.statusNotes.length > 0 - ? this.statusNotes.join('; ') - : undefined; - } - - private addStatusNote(note: string): void { - if (!this.statusNotes.includes(note)) { - this.statusNotes.push(note); - } - if (this.isRunning()) { - this.report({ kind: 'running', detail: this.runningDetail() }); - } + private reportRunning(): void { + this.report(runningRslintStatus(this.advisory)); } public async start(signal: AbortSignal): Promise { @@ -529,26 +329,16 @@ export class Rslint implements Disposable { await this.startPromise; return; } - if (this.closing || signal.aborted) { - throw abortError(signal); - } + if (this.closing || signal.aborted) throw abortError(signal); this.startOperation = this.startImpl(signal).catch((error: unknown) => { this.reportStartFailure(error); throw error; }); - // The abort facade releases the per-URI coordinator even when JavaScript - // module evaluation itself cannot be interrupted. startImpl retains its - // own rejection handler and epoch checks so a late completion is harmless. this.startPromise = raceWithAbort(this.startOperation, signal); void this.startOperation.catch(() => undefined); await this.startPromise; } - /** - * Resolution failure surfaces in the status bar; no silent fallback. - * The two version seams (the support matrix and the config-discovery - * protocol) are additionally separated from an ordinary crash. - */ private reportStartFailure(error: unknown): void { if ( this.closing || @@ -556,97 +346,71 @@ export class Rslint implements Disposable { ) { return; } - if ( - error instanceof RslintVersionMismatchError || - error instanceof ConfigDiscoveryProtocolMismatchError - ) { - this.report({ kind: 'version-mismatch', detail: error.message }); - return; - } - const detail = - error instanceof RslintResolutionError || error instanceof Error - ? error.message - : String(error); - this.report({ kind: 'crashed', detail }); + this.report(statusForRslintStartFailure(error)); } private async startImpl(signal: AbortSignal): Promise { this.configReloadChain = Promise.resolve(); this.lifecycleEpoch++; const epoch = this.lifecycleEpoch; - const pluginLintPool = this.pluginLintPool; - this.pluginDependencyRevision = 0; - this.statusNotes = []; + this.advisory = undefined; this.report({ kind: 'starting' }); - // One resolution root for the binary, the config-loader and - // the ESLint-plugin host — asserted inside `resolveRslint`. A failure here - // is terminal and user-visible; there is no built-in binary to fall back to. - const resolution = await resolveRslint(this.workspaceFolder, this.logger); + const folderRoot = this.workspaceFolder.uri.fsPath; + const mode = this.getMode(); + const corePath = workspace + .getConfiguration('rstack.rslint', this.workspaceFolder.uri) + .get('corePath'); + const resolution = resolveRslint({ folderRoot, mode, corePath }); this.assertStartCurrent(epoch, signal); - this.resolution = resolution; - this.logger.info( - `Rslint resolved from the project (${resolution.kind}): ${resolution.coreDir}` + - ` (version ${resolution.coreVersion ?? 'unknown'})`, - ); - // Compatibility seam (1): the support matrix. `@rslint/core` older than the - // launch floor has no `./config-loader` / `./eslint-plugin` export at all, - // so this check must precede the module load to produce the better message. - const versionCheck = checkPackageVersion( + if (resolution.rstackDir !== undefined) { + const rstackCheck = checkPackageVersion( + 'rstack', + resolution.rstackVersion, + ); + if (rstackCheck.kind === 'mismatch') { + throw new RslintVersionMismatchError( + formatVersionMismatch('rstack', rstackCheck), + ); + } + } + const coreCheck = checkPackageVersion( '@rslint/core', resolution.coreVersion, ); - if (versionCheck.kind === 'mismatch') { + if (coreCheck.kind === 'mismatch') { throw new RslintVersionMismatchError( - formatVersionMismatch('@rslint/core', versionCheck), + formatVersionMismatch('@rslint/core', coreCheck), ); } - // Compatibility seam (2): the config-discovery protocol handshake. The value - // this yields is what every `rslint/configRefresh` request carries and what - // every server-initiated transaction is validated against, so the Go - // binary and the JS loader can never silently disagree. - const configLoader = await loadConfigLoaderModule(resolution); - this.assertStartCurrent(epoch, signal); - this.configLoader = configLoader; - this.logger.debug( - `Config-discovery protocol version ${configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION} (project loader: ${resolution.configLoaderPath})`, + this.logger.info( + `Rslint ${mode} mode: @rslint/core ${resolution.coreVersion ?? 'unknown'} at ${resolution.coreDir}`, ); - - // Compatibility seam (3): the jiti preflight. `@rslint/core`'s config-file-loader - // falls back to jiti when the host's Node cannot strip types, and the host - // Node version is fixed by VS Code rather than by the user. - const jitiNote = describeJitiPreflight({ - folder: this.workspaceFolder, - coreDir: resolution.coreDir, - rslintConfigPaths: this.getConfigPaths().rslintConfigPaths, - }); - if (jitiNote) { - this.logger.warn(jitiNote); - this.statusNotes.push(jitiNote); + if (resolution.shimPath !== undefined) { + this.logger.info(`Rstack lint shim: ${resolution.shimPath}`); } - const binPath = resolution.binPath; - this.logger.info('Rslint binary path:', binPath); - + const nodeExecutable = await this.resolveNodeExecutable(); + this.assertStartCurrent(epoch, signal); + const workerPath = path.resolve(__dirname, 'lint-worker.js'); + const workerArgs = [workerPath, '--lsp', '--core', resolution.coreDir]; + if (resolution.shimPath !== undefined) { + workerArgs.push('--config', resolution.shimPath); + } const serverProcessOwner = new LanguageServerProcessOwner( - binPath, - ['--lsp'], - this.workspaceFolder.uri.fsPath, + nodeExecutable, + workerArgs, + folderRoot, ); this.serverProcessOwner = serverProcessOwner; - const serverOptions: ServerOptions = async () => { - const process = await serverProcessOwner.start(); - return process; - }; + const serverOptions: ServerOptions = async () => serverProcessOwner.start(); - // Check if LSP tracing is enabled const traceServer = workspace .getConfiguration('rstack.rslint', this.workspaceFolder.uri) .get('trace.server', 'off'); const traceEnabled = traceServer !== 'off'; - const clientOptions = createLanguageClientOptions( this.workspaceFolder, this.outputChannel, @@ -654,34 +418,25 @@ export class Rslint implements Disposable { ); const errorHandlerHolder: { current?: ErrorHandler } = {}; clientOptions.errorHandler = { - error: async (error, message, count) => { - const result = await Promise.resolve( + error: async (error, message, count) => + Promise.resolve( errorHandlerHolder.current?.error(error, message, count) ?? { action: ErrorAction.Shutdown, }, - ); - return result; - }, + ), closed: async () => { if (this.closing) { return { action: CloseAction.DoNotRestart, handled: true }; } - const result = await Promise.resolve( + return Promise.resolve( errorHandlerHolder.current?.closed() ?? { action: CloseAction.DoNotRestart, }, ); - return result; }, }; - if (traceEnabled) { clientOptions.traceOutputChannel = this.lspOutputChannel; - this.logger.info( - 'LSP tracing enabled, the trace is written to the "Rstack: Rslint" output channel', - ); - } else { - this.logger.debug('LSP tracing disabled by configuration'); } const client = new ManagedLanguageClient( @@ -696,19 +451,14 @@ export class Rslint implements Disposable { this.logger.debug( `Language client state ${event.oldState} -> ${event.newState}`, ); - if (this.closing || client !== this.client) { - return; - } + if (this.closing || client !== this.client) return; if (event.newState === State.Stopped) { - // The process owner and languageclient's error handler decide whether - // a restart happens; either way the user must see that this folder is - // currently not linting. this.report({ kind: 'crashed', detail: 'the Rslint language server stopped', }); } else if (event.newState === State.Running) { - this.report({ kind: 'running', detail: this.runningDetail() }); + this.reportRunning(); } }); @@ -718,74 +468,6 @@ export class Rslint implements Disposable { await clientStartPromise; this.assertStartCurrent(epoch, signal, client); - const adapter = new LspConfigTransactionAdapter( - new configLoader.ConfigModuleHost(), - pluginLintPool, - (activation) => this.computeActivationFingerprint(activation), - configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION, - (error) => { - this.report({ kind: 'version-mismatch', detail: error.message }); - }, - ); - this.configTransactionAdapter = adapter; - - this.requestHandlers.push( - client.onRequest( - 'rslint/loadConfigs', - async (params: LoadConfigsRequest, token: CancellationToken) => - this.observeConfigTransaction(async () => - withCancellationSignal(token, async (requestSignal) => - adapter.loadConfigs(params, requestSignal), - ), - ), - ), - ); - this.requestHandlers.push( - client.onRequest( - 'rslint/activateConfigs', - async (params: ActivateConfigsRequest, token: CancellationToken) => - this.observeConfigTransaction(async () => - withCancellationSignal(token, async (requestSignal) => - adapter.activateConfigs(params, requestSignal), - ), - ), - ), - ); - this.requestHandlers.push( - client.onRequest( - 'rslint/commitConfigs', - async (params: ConfigTransactionControlRequest) => - adapter.commitConfigs(params), - ), - ); - this.requestHandlers.push( - client.onRequest( - 'rslint/abortConfigs', - async (params: ConfigTransactionControlRequest) => - adapter.abortConfigs(params), - ), - ); - - // Answer Go's reverse `rslint/pluginLint` requests: Go lints - // natively but dispatches rules mounted via a config's object-form - // `plugins` back to us, where the JS WorkerPool runs them. The generic - // string-method overload of `onRequest` handles server-initiated custom - // requests. The handler's CancellationToken — fired when Go sends - // $/cancelRequest for a superseded keystroke / closed document — is - // threaded through to the pool, which bridges it to an AbortSignal and - // cancels the in-flight worker tasks. - this.requestHandlers.push( - client.onRequest( - 'rslint/pluginLint', - async (params: EslintPluginLintRequest, token: CancellationToken) => - pluginLintPool.lint(params, token), - ), - ); - - // client.start() has already emitted the initial Running transition. Any - // later Running event belongs to an automatic native-server restart. - // Reset router-side server-open state before LanguageClient replays open - // documents, then rebuild the replacement Go process's config catalog. this.serverRestartWatcher = client.onDidChangeState((event) => { if ( shouldResetDocumentSessionOnServerState( @@ -793,7 +475,7 @@ export class Rslint implements Disposable { event.newState, ) ) { - this.router.resetServerSession(this).catch((error: unknown) => { + void this.router.resetServerSession(this).catch((error: unknown) => { this.logger.error( 'Failed to reset documents after server exit', error, @@ -802,9 +484,9 @@ export class Rslint implements Disposable { } const recovery = recoverConfigDiscoveryOnServerState( event.newState, - async (reason, beforeRequest) => { + async (reason) => { await this.router.resetServerSession(this); - await this.requestConfigRefresh(reason, beforeRequest); + await this.requestConfigRefresh(reason); }, ); recovery?.then( @@ -820,27 +502,16 @@ export class Rslint implements Disposable { }); if (traceEnabled) { - const traceLevel = - traceServer === 'verbose' ? Trace.Verbose : Trace.Messages; - await client.setTrace(traceLevel); + await client.setTrace( + traceServer === 'verbose' ? Trace.Verbose : Trace.Messages, + ); this.assertStartCurrent(epoch, signal, client); - this.logger.info(`LSP trace level set to: ${traceServer}`); } - this.installConfigRefreshWatcher(); - // The watcher is live before initial discovery, so a mutation during a - // slow module evaluation schedules a second serialized transaction. - // A plugin worker is prepared between two config fingerprints. If the - // initial source changes in that window, Go correctly aborts the - // generation. Retry once from the now-current bytes instead of tearing - // down the language client before the already-live watcher can recover. + this.installConfigRefreshWatchers(mode); const retried = await retryConfigRefreshOnSourceChange( - async () => { - await this.requestConfigRefresh('initial'); - }, - async () => { - await this.requestConfigRefresh('config-change'); - }, + async () => this.requestConfigRefresh('initial'), + async () => this.requestConfigRefresh('config-change'), ); this.assertStartCurrent(epoch, signal, client); if (retried) { @@ -848,36 +519,36 @@ export class Rslint implements Disposable { 'Config changed during initial activation; discovery recovered on retry', ); } - this.logger.info('Rslint language client started successfully'); - // Any bridge note was already collected before the client started, so it - // is part of the very first `running` detail. - this.report({ kind: 'running', detail: this.runningDetail() }); - } catch (err: unknown) { - this.logger.error('Failed to start Rslint language client', err); - throw err; + this.reportRunning(); + } catch (error: unknown) { + this.logger.error('Failed to start Rslint language client', error); + throw error; } } - /** - * Surfaces the two failure classes that must stay actionable - * instead of generic: a config-discovery protocol disagreement and the - * config-file-loader's "Install jiti as a dependency" error. - */ - private async observeConfigTransaction( - operation: () => Promise, - ): Promise { + private async resolveNodeExecutable(): Promise { + const configured = getConfiguredNodeExecutable(this.workspaceFolder); + if (configured !== undefined) { + void configuredNodeBelowFloor(configured).then((message) => { + if (message !== undefined && !this.closing) { + this.advisory = message; + if (this.isRunning()) this.reportRunning(); + } + }); + return configured; + } try { - return await operation(); + const resolution = await resolveUserNodeOnce({ + shell: env.shell || undefined, + cwd: this.workspaceFolder.uri.fsPath, + notify: (message) => this.logger.info(message), + }); + return resolution.executable; } catch (error) { - if (error instanceof ConfigTransactionProtocolMismatchError) { - // Already reported through the adapter's `onProtocolMismatch`. - throw error; - } - if (isJitiMissingError(error)) { - this.addStatusNote( - 'a TypeScript Rslint config could not be loaded: ' + - JITI_INSTALL_HINT, + if (error instanceof NodePreflightError) { + throw new RslintVersionMismatchError( + error.messageWith('Rslint will not lint'), ); } throw error; @@ -899,136 +570,51 @@ export class Rslint implements Disposable { } } - private installConfigRefreshWatcher(): void { - this.configWatcher = workspace.createFileSystemWatcher( - // Go owns the config-scoped .gitignore watcher and refresh transaction. - // Keeping it out of this direct watcher prevents one mutation from - // starting both a didChangeWatchedFiles and a configRefresh transaction. - new RelativePattern(this.workspaceFolder, CONFIG_REFRESH_WATCH_GLOB), - ); + private installConfigRefreshWatchers(mode: RslintMode): void { + const patterns = [ + CONFIG_REFRESH_WATCH_GLOB, + ...(mode === 'bridged' ? [RSTACK_CONFIG_REFRESH_WATCH_GLOB] : []), + ]; const refreshConfig = (uri: Uri) => { const reason = configRefreshReasonForPath(uri.fsPath); - if (reason === 'dependency-change') { - // The actual package contents can change while config source bytes stay - // identical. Feed a monotonic dependency revision into the staged host - // fingerprint so any lockfile mutation forces a worker rebuild. - this.pluginDependencyRevision++; - } this.logger.debug(`${reason}: ${uri.fsPath}`); clearTimeout(this.configReloadTimer); this.configReloadTimer = setTimeout(() => { this.configReloadTimer = undefined; - this.requestConfigRefresh(reason).catch((err: unknown) => { - this.logger.error('Failed to refresh config discovery', err); + void this.requestConfigRefresh(reason).catch((error: unknown) => { + this.logger.error('Failed to refresh config discovery', error); }); }, 300); }; - this.configWatcher.onDidChange(refreshConfig); - this.configWatcher.onDidCreate(refreshConfig); - this.configWatcher.onDidDelete(refreshConfig); + for (const pattern of patterns) { + const watcher = workspace.createFileSystemWatcher( + new RelativePattern(this.workspaceFolder, pattern), + ); + this.configWatchers.push(watcher); + watcher.onDidChange(refreshConfig); + watcher.onDidCreate(refreshConfig); + watcher.onDidDelete(refreshConfig); + } } private async requestConfigRefresh( reason: ConfigRefreshReason, - beforeRequest?: (adapter: LspConfigTransactionAdapter) => Promise, ): Promise { const epoch = this.lifecycleEpoch; const client = this.client; - const pluginLintPool = this.pluginLintPool; - const adapter = this.configTransactionAdapter; - const configLoader = this.configLoader; - if (!client || !adapter || !configLoader || this.pluginLintPoolDisposed) { - return; - } + if (!client) return; const refresh = this.configReloadChain.then(async () => { - if ( - !this.isLifecycleCurrent(epoch, client, pluginLintPool) || - adapter !== this.configTransactionAdapter - ) { - return; - } - await beforeRequest?.(adapter); - if ( - !this.isLifecycleCurrent(epoch, client, pluginLintPool) || - adapter !== this.configTransactionAdapter - ) { - return; - } - const request: ConfigRefreshRequest = { - protocolVersion: configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION, - reason, - }; - await client.sendRequest('rslint/configRefresh', request); + if (!this.isLifecycleCurrent(epoch, client)) return; + await client.sendRequest('rslint/configRefresh', { reason }); }); this.configReloadChain = refresh.catch(() => undefined); await refresh; } - private isLifecycleCurrent( - epoch: number, - client: LanguageClient, - pluginLintPool: PluginLintPool, - ): boolean { + private isLifecycleCurrent(epoch: number, client: LanguageClient): boolean { return ( - epoch === this.lifecycleEpoch && - client === this.client && - pluginLintPool === this.pluginLintPool && - !this.pluginLintPoolDisposed && - !this.closing - ); - } - - /** - * Fingerprint the inputs that decide whether the plugin host must rebuild: - * Go's selected config snapshots plus each workspace-root lockfile's - * existence, mtime, and size. A dependency install can replace plugin code - * without changing config, so the lockfile also feeds the key. - */ - private computeMetadataFingerprint(filePath: string): string { - try { - const stat = fs.statSync(filePath); - return `${stat.mtimeMs}:${stat.size}`; - } catch { - return 'absent'; - } - } - - private computeActivationFingerprint( - activation: ConfigModuleActivationPlan, - ): string { - const sourceFingerprint = this.computeFingerprint( - activation.pluginConfigs.map((config) => config.configPath), - activation.configs, - ); - return `${sourceFingerprint}|dependency-revision:${this.pluginDependencyRevision}`; - } - - private computeFingerprint( - configPaths: string[], - configs: ReadonlyArray<{ - configPath: string; - sourceFingerprint: string; - }>, - ): string { - const parts: string[] = []; - const sourceFingerprintByPath = new Map( - configs.map((config) => [ - path.normalize(config.configPath), - config.sourceFingerprint, - ]), + epoch === this.lifecycleEpoch && client === this.client && !this.closing ); - for (const p of [...configPaths].sort()) { - const sourceFingerprint = sourceFingerprintByPath.get(path.normalize(p)); - if (sourceFingerprint === undefined) { - throw new Error(`missing source fingerprint for plugin config ${p}`); - } - parts.push(`${p}:${sourceFingerprint}`); - } - for (const name of LOCKFILE_NAMES) { - const lockPath = path.join(this.workspaceFolder.uri.fsPath, name); - parts.push(`lock:${name}:${this.computeMetadataFingerprint(lockPath)}`); - } - return parts.join('|'); } public async close(): Promise { @@ -1052,20 +638,12 @@ export class Rslint implements Disposable { this.configReloadTimer = undefined; disposeSafely(this.serverRestartWatcher); this.serverRestartWatcher = undefined; - disposeSafely(this.configWatcher); - this.configWatcher = undefined; - disposeSafely(this.configTransactionAdapter); - this.configTransactionAdapter = undefined; - for (const handler of this.requestHandlers.splice(0)) { - disposeSafely(handler); + for (const watcher of this.configWatchers.splice(0)) { + disposeSafely(watcher); } disposeSafely(this.stateWatcher); this.stateWatcher = undefined; - // Do not await startOperation/configReloadChain: user module evaluation can - // contain a non-settling top-level await. Epoch/closing checks fence every - // late continuation from publishing resources or state. this.configReloadChain = Promise.resolve(); - this.pluginLintPoolDisposed = true; const client = this.client; this.client = undefined; @@ -1077,15 +655,9 @@ export class Rslint implements Disposable { : undefined; const serverProcessOwner = this.serverProcessOwner; this.serverProcessOwner = undefined; - // Block vscode-languageclient's automatic restart callback before its - // graceful client shutdown begins. The owner force-terminates and awaits - // any surviving child after the bounded protocol shutdown finishes. serverProcessOwner?.beginClose(); - const asynchronousCleanups: Promise[] = [ - (async () => { - await this.pluginLintPool.dispose(); - })(), - ]; + + const asynchronousCleanups: Promise[] = []; if (client) { asynchronousCleanups.push( (async () => { @@ -1133,36 +705,19 @@ export class Rslint implements Disposable { })(), ); } else if (serverProcessOwner) { - asynchronousCleanups.push( - (async () => { - await serverProcessOwner.close(); - })(), - ); + asynchronousCleanups.push(serverProcessOwner.close()); } const results = await Promise.allSettled(asynchronousCleanups); - this.pluginDependencyRevision = 0; - for (const result of results) { - if (result.status === 'rejected') { - const reason: unknown = result.reason; - errors.push(reason); - } + if (result.status === 'rejected') errors.push(result.reason); } - try { - for (const error of errors) { - this.logger.error('Failed to close Rslint workspace resource', error); - } - if (errors.length === 0) { - this.logger.info('Rslint language client closed'); - } - } catch (error) { - errors.push(error); + for (const error of errors) { + this.logger.error('Failed to close Rslint workspace resource', error); } - try { - this.logger.dispose(); - } catch (error) { - errors.push(error); + if (errors.length === 0) { + this.logger.info('Rslint language client closed'); } + this.logger.dispose(); if (errors.length > 0) { throw new AggregateError(errors, 'failed to close Rslint workspace'); } diff --git a/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts b/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts index 9db870b..bf90ab6 100644 --- a/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts +++ b/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts @@ -150,13 +150,14 @@ export class WorkspaceRslintCoordinator { public handleWorkspaceFoldersChanged( event: WorkspaceFoldersChangeEvent, folders: readonly WorkspaceFolder[], + forceReplaceRoots: ReadonlySet = new Set(), ): void { if (this.closing) return; const removedKeys = new Set(); for (const folder of event.removed) { removedKeys.add(workspaceRootKey(folder)); } - const forceReplace = new Set(); + const forceReplace = new Set(forceReplaceRoots); for (const folder of event.added) { const key = workspaceRootKey(folder); // A rename/remove+add replacement can preserve the URI. A plain added diff --git a/packages/vscode/src/stacks/lint/configLoader.ts b/packages/vscode/src/stacks/lint/configLoader.ts deleted file mode 100644 index de3fb96..0000000 --- a/packages/vscode/src/stacks/lint/configLoader.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { - ConfigModuleHost, - ConfigModuleHostOptions, -} from '@rslint/core/config-loader'; -import type { createPluginLintHost } from '@rslint/core/eslint-plugin'; -import { - formatProtocolVersionMismatch, - isSupportedConfigDiscoveryProtocolVersion, -} from '../../shared/versionCheck'; -import { importProjectModule } from './projectModules'; -import type { RslintResolution } from './resolution'; - -/** - * `@rslint/core/config-loader` is **not bundled**: the types are - * `import type` only and the runtime value comes from the project-resolved - * module, loaded from the same root as the Go binary. - */ -export interface ConfigLoaderModule { - readonly CONFIG_DISCOVERY_PROTOCOL_VERSION: number; - readonly ConfigModuleHost: new ( - options?: ConfigModuleHostOptions, - ) => ConfigModuleHost; -} - -/** The `@rslint/core/eslint-plugin` surface `PluginLintPool` depends on. */ -export interface EslintPluginModule { - readonly createPluginLintHost: typeof createPluginLintHost; -} - -/** - * The config-discovery protocol between the Go server and the JS loader is - * versioned independently of the package version, so `semver.satisfies` is - * necessary but not sufficient. A mismatch is surfaced as the - * `version mismatch` status-bar state. - */ -export class ConfigDiscoveryProtocolMismatchError extends Error { - constructor(readonly protocolVersion: number) { - super(formatProtocolVersionMismatch(protocolVersion)); - this.name = 'ConfigDiscoveryProtocolMismatchError'; - } -} - -const isConfigLoaderModule = (value: unknown): value is ConfigLoaderModule => - value !== null && - typeof value === 'object' && - typeof (value as ConfigLoaderModule).CONFIG_DISCOVERY_PROTOCOL_VERSION === - 'number' && - typeof (value as ConfigLoaderModule).ConfigModuleHost === 'function'; - -/** - * Loads the project's config-loader and performs the protocol handshake. - * - * The `protocolVersion` this returns is the exact value the client then puts - * into every `rslint/configRefresh` request and validates on every - * server-initiated config transaction, so the Go server and the JS loader can - * never silently disagree. - */ -export const loadConfigLoaderModule = async ( - resolution: RslintResolution, -): Promise => { - const module = await importProjectModule( - resolution.configLoaderPath, - ); - if (!isConfigLoaderModule(module)) { - throw new Error( - `${resolution.configLoaderPath} does not export the expected config-loader surface (CONFIG_DISCOVERY_PROTOCOL_VERSION, ConfigModuleHost)`, - ); - } - if ( - !isSupportedConfigDiscoveryProtocolVersion( - module.CONFIG_DISCOVERY_PROTOCOL_VERSION, - ) - ) { - throw new ConfigDiscoveryProtocolMismatchError( - module.CONFIG_DISCOVERY_PROTOCOL_VERSION, - ); - } - return module; -}; - -const isEslintPluginModule = (value: unknown): value is EslintPluginModule => - value !== null && - typeof value === 'object' && - typeof (value as EslintPluginModule).createPluginLintHost === 'function'; - -/** - * Loads the project's ESLint-plugin host. Verified to work unpatched from a - * plain project install (a verified non-requirement): the host spawns its - * sibling `lint-worker.js` and the worker finds `@rslint/native-*` by - * node_modules walk-up, all inside the project. - */ -export const loadEslintPluginModule = async ( - resolution: RslintResolution, -): Promise => { - const module = await importProjectModule( - resolution.eslintPluginPath, - ); - if (!isEslintPluginModule(module)) { - throw new Error( - `${resolution.eslintPluginPath} does not export createPluginLintHost`, - ); - } - return module; -}; diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index e29c921..b1cfffb 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -5,8 +5,10 @@ import type { StackController, StackState, } from '../../types'; +import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; import { Logger } from './logger'; -import { Rslint, type RslintFolderConfigPaths } from './Rslint'; +import { Rslint } from './Rslint'; +import type { RslintMode } from './resolution'; import { WorkspaceDocumentRouter } from './WorkspaceDocumentRouter'; import { WorkspaceRslintCoordinator, @@ -109,12 +111,11 @@ export const aggregateFolderStates = ( class RslintController implements StackController { readonly id = 'rslint' as const; - // A `binPath`/`customBinPath` change must re-resolve the binary, which only - // happens on a fresh start (upstream documents `customBinPath` as requiring a - // reload; a restart is strictly better). A shallower local restart would - // replace the coordinator but keep this controller's already-resolved binary - // and version check. - readonly restartOnSettings = ['binPath', 'customBinPath', 'trace.server']; + readonly restartOnSettings = [ + NODE_EXECUTABLE_SETTING, + 'corePath', + 'trace.server', + ]; #context: StackContext | undefined; #logger: Logger | undefined; @@ -122,6 +123,7 @@ class RslintController implements StackController { #snapshot: DetectionSnapshot | undefined; readonly #subscriptions: vscode.Disposable[] = []; readonly #folderStates = new Map(); + readonly #folderModes = new Map(); #disposed = false; async register(context: StackContext): Promise> { @@ -131,8 +133,16 @@ class RslintController implements StackController { this.#subscriptions.push( context.onDidChangeDetection((snapshot) => { + const changedModes = new Set(); + for (const entry of snapshot.foldersFor('rslint')) { + const key = workspaceRootKey(entry.folder); + const mode = entry.stacks.rslint.mode; + if (mode !== undefined && this.#folderModes.get(key) !== mode) { + changedModes.add(key); + } + } this.#snapshot = snapshot; - this.reconcileFolders({ added: [], removed: [] }); + this.reconcileFolders({ added: [], removed: [] }, changedModes); // A detection pass fires on config topology and lockfile changes — // exactly the moments a previously failed root (missing binary, // uninstalled dependencies) may have become startable. @@ -173,15 +183,14 @@ class RslintController implements StackController { ); } - private configPathsFor( - folder: vscode.WorkspaceFolder, - ): RslintFolderConfigPaths { - const detection = this.#snapshot?.forFolder(folder)?.stacks.rslint; - return { - rslintConfigPaths: (detection?.configFiles ?? []).map( - (uri) => uri.fsPath, - ), - }; + private modeFor(folder: vscode.WorkspaceFolder): RslintMode { + const mode = this.#snapshot?.forFolder(folder)?.stacks.rslint.mode; + if (mode === undefined) { + throw new Error( + `Rslint mode is unavailable for ${folder.uri.toString()}`, + ); + } + return mode; } private startCoordinator(): void { @@ -206,7 +215,7 @@ class RslintController implements StackController { reportStatus: (state) => { this.setFolderState(rootKey, workspaceFolder.name, state); }, - getConfigPaths: () => this.configPathsFor(workspaceFolder), + getMode: () => this.modeFor(workspaceFolder), }), logger, ); @@ -214,7 +223,9 @@ class RslintController implements StackController { const folders = this.detectedFolders(); for (const folder of folders) { - this.setFolderState(workspaceRootKey(folder), folder.name, { + const key = workspaceRootKey(folder); + this.#folderModes.set(key, this.modeFor(folder)); + this.setFolderState(key, folder.name, { kind: 'starting', }); } @@ -230,7 +241,10 @@ class RslintController implements StackController { }); } - private reconcileFolders(event: vscode.WorkspaceFoldersChangeEvent): void { + private reconcileFolders( + event: vscode.WorkspaceFoldersChangeEvent, + forceReplace: ReadonlySet = new Set(), + ): void { const coordinator = this.#coordinator; if (!coordinator || this.#disposed) { return; @@ -240,16 +254,18 @@ class RslintController implements StackController { for (const key of [...this.#folderStates.keys()]) { if (!keys.has(key)) { this.#folderStates.delete(key); + this.#folderModes.delete(key); } } for (const folder of folders) { const key = workspaceRootKey(folder); + this.#folderModes.set(key, this.modeFor(folder)); if (!this.#folderStates.has(key)) { this.setFolderState(key, folder.name, { kind: 'starting' }); } } this.publishStatus(); - coordinator.handleWorkspaceFoldersChanged(event, folders); + coordinator.handleWorkspaceFoldersChanged(event, folders, forceReplace); } private setFolderState( @@ -294,6 +310,7 @@ class RslintController implements StackController { } await this.closeCoordinator(); this.#folderStates.clear(); + this.#folderModes.clear(); this.#logger = undefined; this.#context = undefined; this.#snapshot = undefined; diff --git a/packages/vscode/src/stacks/lint/jitiPreflight.ts b/packages/vscode/src/stacks/lint/jitiPreflight.ts deleted file mode 100644 index 6050a14..0000000 --- a/packages/vscode/src/stacks/lint/jitiPreflight.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { createRequire } from 'node:module'; -import type { WorkspaceFolder } from 'vscode'; -import { nativeTypeStrippingAvailable } from '../../shared/vendored/loadRstackConfig'; - -/** - * The jiti seam of the version-compatibility contract. - * - * `jiti` is an optional peer of `@rslint/core`, used by its - * `config-file-loader` when native type stripping cannot load a `.ts`/`.mts` - * config. Under resolve-from-project that loader runs on the **extension - * host's** Node — whose version is fixed by VS Code, not by the user — so the - * jiti branch can trigger in the editor even when the CLI works fine. - * - * Loader behaviour is deliberately unchanged. Two diagnostics only: - * - * 1. `describeJitiPreflight` — preflight that jiti resolves from the project - * when a TypeScript config is in play; - * 2. `isJitiMissingError` — recognise the loader's "Install jiti as a - * dependency" failure so it can be surfaced as an actionable hint instead of - * a generic config-load failure. - * - * Unlike the vendored Rstack loader (which is bundled into the extension and - * therefore resolves jiti from the *extension*), `@rslint/core`'s - * config-file-loader is itself loaded from the project, so its `import('jiti')` - * resolves from the project — which is exactly what this preflight checks. - */ - -// `require.resolve` is rewritten by the bundler; `createRequire` is not. -const nodeRequire = createRequire(__filename); - -const TYPESCRIPT_CONFIG_RE = /\.[cm]?ts$/; - -export const isTypeScriptConfigPath = (configPath: string): boolean => - TYPESCRIPT_CONFIG_RE.test(configPath); - -/** - * True when `jiti` resolves from any of the given roots. - * - * Two roots matter and they are not the same: the loader's own - * `await import('jiti')` resolves relative to `@rslint/core`'s install - * directory (which, under pnpm's strict layout, only sees jiti when the - * optional peer was actually installed and linked), while the workspace folder - * is what a user thinks of as "the project". Either hit means the loader has a - * fair chance of finding jiti, so the preflight only warns when *both* miss — - * a false negative is much cheaper than a false alarm. - */ -export const jitiResolvesFrom = (roots: readonly string[]): boolean => { - for (const root of roots) { - try { - nodeRequire.resolve('jiti/package.json', { paths: [root] }); - return true; - } catch { - // Try the next root. - } - } - return false; -}; - -export const JITI_INSTALL_HINT = - 'install jiti in the project (`npm install -D jiti`) or move the config to `.js`/`.mjs`'; - -export interface JitiPreflightInput { - readonly folder: WorkspaceFolder; - /** The project's `@rslint/core` directory — the loader's own resolution root. */ - readonly coreDir?: string; - readonly rslintConfigPaths: readonly string[]; -} - -/** - * Returns a status-bar-ready note when a TypeScript Rslint config will need - * jiti on this host and jiti is not installed in the project. Returns - * `undefined` when there is nothing to warn about. - */ -export const describeJitiPreflight = ({ - folder, - coreDir, - rslintConfigPaths, -}: JitiPreflightInput): string | undefined => { - const typescriptConfigs = rslintConfigPaths.filter(isTypeScriptConfigPath); - if (typescriptConfigs.length === 0) { - return undefined; - } - if (nativeTypeStrippingAvailable()) { - return undefined; - } - const roots = coreDir ? [coreDir, folder.uri.fsPath] : [folder.uri.fsPath]; - if (jitiResolvesFrom(roots)) { - return undefined; - } - const subject = - typescriptConfigs.length === 1 - ? typescriptConfigs[0] - : `${String(typescriptConfigs.length)} TypeScript Rslint configs`; - return `this VS Code build cannot strip TypeScript types, so loading ${String(subject)} requires jiti: ${JITI_INSTALL_HINT}`; -}; - -/** - * Recognises the multi-line error `@rslint/core`'s `config-file-loader` throws - * when a TypeScript config cannot be loaded and jiti is absent: - * `Failed to load TypeScript config file: … 2. Install jiti as a dependency: npm install -D jiti`. - */ -export const isJitiMissingError = (error: unknown): boolean => { - const message = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : typeof (error as { message?: unknown } | null)?.message === 'string' - ? String((error as { message: string }).message) - : ''; - if (!message) { - return false; - } - return ( - /install jiti/i.test(message) || - /"?jiti"? package is required/i.test(message) || - /cannot find (module|package) ['"]jiti['"]/i.test(message) - ); -}; diff --git a/packages/vscode/src/stacks/lint/projectModules.ts b/packages/vscode/src/stacks/lint/projectModules.ts deleted file mode 100644 index 4ac83dd..0000000 --- a/packages/vscode/src/stacks/lint/projectModules.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { pathToFileURL } from 'node:url'; - -/** - * Loads an ESM module from an absolute path resolved out of the *user's* - * project (the resolve-from-project adaptation). - * - * Two constraints shape this helper: - * - * - The extension bundle is CJS while `@rslint/core` is ESM (`"type": - * "module"`), so the module has to be reached through a dynamic `import()`, - * never `require`. - * - The specifier is a fully dynamic expression, which Rspack emits verbatim - * instead of turning into a bundled chunk (verified against the repo's own - * rslib output), so the load really happens at runtime against the project's - * file. A bare specifier would resolve from the extension instead, which is - * exactly the bundling seam the resolve-from-project adaptation deletes. - * - Windows: only `file:`/`data:`/`node:` URLs are accepted by Node's default - * ESM loader, so absolute paths are converted with `pathToFileURL`. - * - * There is deliberately no invalidation hook, and a restart does not get one. - * The memo below is the *second* cache in front of these modules: Node's own - * ESM registry is keyed by resolved URL and lives as long as the extension - * host, so once a path has loaded, re-importing it returns the identical - * module object no matter what this map says. Adding a cache-busting query to - * the specifier does reload the entry module, but a relative specifier inside - * it does not inherit the query — the result is a fresh entry wired to its own - * stale dependencies, which is worse than being consistently stale. A - * `node_modules` replaced in place under an unchanged path therefore needs a - * window reload; see the README's note on what restart does and does not - * recover. - */ -const cache = new Map>(); - -export const importProjectModule = async ( - absolutePath: string, -): Promise => { - let pending = cache.get(absolutePath) as Promise | undefined; - if (!pending) { - const specifier = pathToFileURL(absolutePath).href; - pending = (import(specifier) as Promise).catch((error: unknown) => { - // Never cache a rejected load: a transient failure (a half-installed - // node_modules, a mid-reinstall window) must stay retryable. - cache.delete(absolutePath); - throw error; - }); - cache.set(absolutePath, pending as Promise); - } - return pending; -}; diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index 53c15f2..ab06a0c 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -1,315 +1,152 @@ import fs from 'node:fs'; -import { createRequire } from 'node:module'; import path from 'node:path'; -import { Uri, workspace, type WorkspaceFolder } from 'vscode'; -import { findPackageJsonUncached } from '../../shared/packageResolve'; -import type { Logger } from './logger'; import { - fileExists, - getPlatformBinRequests, - type RslintBinPath, -} from './utils'; + findPackageJsonUncached, + readPackageJson, +} from '../../shared/packageResolve'; -/** - * Everything Rslint-related is resolved from the *user's project*: this - * extension ships no Go binary and no `@rslint/core`. The - * `built-in` mode is gone, so a failed resolution is a hard, user-visible - * failure — never a silent fallback. - * - * There must additionally be **one resolution root**: the Go binary, - * `@rslint/core/config-loader` and `@rslint/core/eslint-plugin` must all come - * from the same `@rslint/core` install, "never binary from A, loader from B". - * `assertSingleResolutionRoot` enforces that as an assertion, not a - * convention. - */ -export type RslintResolutionKind = 'node-modules' | 'pnp'; +export type RslintMode = 'native' | 'bridged'; export interface RslintResolution { - /** How `@rslint/core` itself was found. */ - readonly kind: RslintResolutionKind; - /** The single resolution root: the directory of the project's `@rslint/core`. */ + readonly mode: RslintMode; readonly coreDir: string; readonly coreVersion: string | undefined; - /** The Go language server executable. */ - readonly binPath: string; - /** True when `binPath` came from `rstack.rslint.customBinPath`. */ - readonly binFromUserSetting: boolean; - /** Absolute path of the project's `@rslint/core/config-loader` entry. */ - readonly configLoaderPath: string; - /** Absolute path of the project's `@rslint/core/eslint-plugin` entry. */ - readonly eslintPluginPath: string; + readonly rstackDir?: string; + readonly rstackVersion?: string; + readonly shimPath?: string; } -/** A resolution failure that must surface in the status bar. */ +export type RslintResolutionErrorCode = + 'missing-rstack' | 'missing-core' | 'invalid-package' | 'missing-shim'; + export class RslintResolutionError extends Error { - constructor(message: string, options?: { cause?: unknown }) { + constructor( + readonly code: RslintResolutionErrorCode, + message: string, + options?: { cause?: unknown }, + ) { super(message, options); this.name = 'RslintResolutionError'; } } -interface PnpApi { - resolveRequest(request: string, issuer: string): string | null; +export interface RslintModeSignals { + readonly nativeConfigPaths: readonly string[]; + readonly rootRstackConfigPath?: string; } -// `require.resolve` is rewritten by the bundler; `createRequire` is not. Every -// lookup passes an explicit `paths`/issuer, so the anchor itself is irrelevant. -const nodeRequire = createRequire(__filename); - -const readPackageVersion = (packageJsonPath: string): string | undefined => { - try { - const raw: unknown = JSON.parse( - fs.readFileSync(packageJsonPath, 'utf8'), - ) as unknown; - if (raw && typeof raw === 'object' && 'version' in raw) { - const version = (raw as { version?: unknown }).version; - return typeof version === 'string' ? version : undefined; - } - } catch { - // A malformed package.json is reported by the version check as `unknown`. - } +/** Native ownership wins; only a root Rstack config can bridge a folder. */ +export function decideRslintMode({ + nativeConfigPaths, + rootRstackConfigPath, +}: RslintModeSignals): RslintMode | undefined { + if (nativeConfigPaths.length > 0) return 'native'; + if (rootRstackConfigPath !== undefined) return 'bridged'; return undefined; -}; - -const isInside = (child: string, parent: string): boolean => { - const relative = path.relative(parent, child); - return ( - relative.length > 0 && - !relative.startsWith('..') && - !path.isAbsolute(relative) - ); -}; - -/** - * The one-resolution-root rule is enforced as an assertion, not a - * convention: both JS entry points must live inside the very `@rslint/core` - * whose native binary we are about to spawn. - */ -export const assertSingleResolutionRoot = ( - coreDir: string, - entries: ReadonlyArray<{ readonly label: string; readonly path: string }>, -): void => { - for (const entry of entries) { - if (!isInside(entry.path, coreDir)) { - throw new RslintResolutionError( - `Rslint resolution root mismatch: ${entry.label} resolved to ${entry.path}, which is outside the resolved @rslint/core at ${coreDir}`, - ); - } - } -}; - -const loadPnpApi = async (folder: WorkspaceFolder): Promise => { - for (const extension of ['cjs', 'js']) { - const pnpFile = Uri.joinPath(folder.uri, `.pnp.${extension}`); - if (!(await fileExists(pnpFile))) { - continue; - } - try { - // `yarn install` rewrites `.pnp.cjs` in place, and the module cache - // would pin the dependency map seen by the first load — the PnP flavor - // of the resolution staleness `findPackageJsonUncached` avoids — so a - // dependency-driven retry must load the current file, not the cached - // module. - delete nodeRequire.cache[nodeRequire.resolve(pnpFile.fsPath)]; - return nodeRequire(pnpFile.fsPath) as PnpApi; - } catch { - // Try the next candidate; a broken PnP file is not fatal on its own. - } - } - return null; -}; - -interface CoreLocation { - readonly kind: RslintResolutionKind; - readonly packageJsonPath: string; - readonly coreDir: string; - readonly pnpApi?: PnpApi; } -const locateCore = async ( - folder: WorkspaceFolder, - logger: Logger, -): Promise => { - const searchRoot = folder.uri.fsPath; - // Uncached on purpose: a failed root is retried after dependency changes - // (the lockfile-driven detection pass), and `require.resolve` would replay - // its process-lifetime cache instead of seeing a retargeted install. Every - // cooperating piece resolves from the returned path afterwards, so the - // whole root follows the fresh location (the one-resolution-root rule). - const packageJsonPath = findPackageJsonUncached('@rslint/core', searchRoot); - if (packageJsonPath !== undefined) { - logger.debug(`Found @rslint/core in node_modules: ${packageJsonPath}`); - return { - kind: 'node-modules', - packageJsonPath, - coreDir: path.dirname(packageJsonPath), - }; - } - logger.debug('No @rslint/core in node_modules, trying Yarn PnP'); - - const pnpApi = await loadPnpApi(folder); - if (pnpApi) { - try { - const packageJsonPath = pnpApi.resolveRequest( - '@rslint/core/package.json', - searchRoot, - ); - if (packageJsonPath) { - logger.debug(`Found @rslint/core in PnP: ${packageJsonPath}`); - return { - kind: 'pnp', - packageJsonPath, - coreDir: path.dirname(packageJsonPath), - pnpApi, - }; - } - } catch { - // Fall through to the shared failure below. - } - } - - throw new RslintResolutionError( - `Could not resolve @rslint/core from ${searchRoot}. This extension ships no Rslint binary — install @rslint/core in the project (this extension requires >= 0.7.2).`, - ); -}; +interface PackageLocation { + readonly directory: string; + readonly version: string | undefined; +} -const resolveCoreSubpath = ( - location: CoreLocation, - subpath: string, -): string => { - const specifier = `@rslint/core/${subpath}`; - if (location.pnpApi) { - const resolved = location.pnpApi.resolveRequest( - specifier, - location.packageJsonPath, +function readPackageLocation( + packageName: 'rstack' | '@rslint/core', + packageJsonPath: string, +): PackageLocation { + const pkg = readPackageJson(packageJsonPath); + if (pkg?.name !== packageName) { + throw new RslintResolutionError( + 'invalid-package', + `${packageJsonPath} is not a valid ${packageName} package`, ); - if (!resolved) { - throw new RslintResolutionError( - `Could not resolve ${specifier} through Yarn PnP from ${location.coreDir}`, - ); - } - return resolved; } + return { + directory: path.dirname(packageJsonPath), + version: typeof pkg.version === 'string' ? pkg.version : undefined, + }; +} + +function resolveConfiguredCore( + folderRoot: string, + configuredPath: string, +): PackageLocation { + const directory = path.resolve(folderRoot, configuredPath); + const packageJsonPath = path.join(directory, 'package.json'); try { - // Node's self-reference resolution: a request issued from inside the - // package resolves against that package's own `exports` map, which pins - // the answer to this exact install rather than to whatever copy a - // node_modules walk-up would find first. - return createRequire(location.packageJsonPath).resolve(specifier); + if (!fs.statSync(packageJsonPath).isFile()) throw new Error('not a file'); } catch (error) { - try { - return nodeRequire.resolve(specifier, { paths: [location.coreDir] }); - } catch { - throw new RslintResolutionError( - `Could not resolve ${specifier} from ${location.coreDir}. Rslint >= 0.7.2 is required (its package exports ./config-loader and ./eslint-plugin).`, - { cause: error }, - ); - } - } -}; - -const resolveNativeBinary = ( - location: CoreLocation, - logger: Logger, -): string => { - // Try each platform-package candidate in order, using the first that - // resolves (linux ships gnu/musl variants — only one is installed). - for (const request of getPlatformBinRequests()) { - try { - const binPath = location.pnpApi - ? // PnP's resolveRequest throws (rather than returning null) for a - // candidate absent from the dependency map, so each lookup needs its - // own try/catch to fall through to the next tuple. - location.pnpApi.resolveRequest(request, location.packageJsonPath) - : nodeRequire.resolve(request, { paths: [location.coreDir] }); - if (binPath) { - logger.debug(`Using Rslint binary from the project: ${binPath}`); - return binPath; - } - } catch { - // Candidate not installed; try the next one. - } - } - throw new RslintResolutionError( - `Could not resolve the Rslint native binary (${getPlatformBinRequests().join( - ' or ', - )}) from ${location.coreDir}. The @rslint/native-* package for this platform is not installed.`, - ); -}; - -const resolveUserBinary = async ( - folder: WorkspaceFolder, - logger: Logger, -): Promise => { - const customBinPath = workspace - .getConfiguration('rstack.rslint', folder.uri) - .get('customBinPath') - ?.trim(); - - if (!customBinPath) { throw new RslintResolutionError( - '`rstack.rslint.binPath` is set to "custom" but `rstack.rslint.customBinPath` is not configured', + 'missing-core', + `Could not access @rslint/core at ${directory}`, + { cause: error }, ); } - logger.debug( - `Try using Rslint binary path from user settings: ${customBinPath}`, - ); - if (!(await fileExists(Uri.file(customBinPath)))) { - throw new RslintResolutionError( - `Rslint binary path from user settings does not exist: ${customBinPath}`, - ); - } - logger.debug(`Using Rslint binary from user settings: ${customBinPath}`); - return customBinPath; -}; - -/** - * Binary resolution order: explicit setting → workspace - * `node_modules` → Yarn PnP. `@rslint/core`'s JS entry points always come from - * the project, because the LSP is useless without a config-loader host. - */ -export const resolveRslint = async ( - folder: WorkspaceFolder, - logger: Logger, -): Promise => { - const binPathConfig = workspace - .getConfiguration('rstack.rslint', folder.uri) - .get('binPath', 'local'); + return readPackageLocation('@rslint/core', fs.realpathSync(packageJsonPath)); +} - if (binPathConfig !== 'local' && binPathConfig !== 'custom') { +function resolveInstalledPackage( + packageName: 'rstack' | '@rslint/core', + searchRoot: string, + code: Extract, +): PackageLocation { + const packageJsonPath = findPackageJsonUncached(packageName, searchRoot); + if (packageJsonPath === undefined) { throw new RslintResolutionError( - `Unsupported rstack.rslint.binPath setting: ${String(binPathConfig)}`, + code, + `Could not resolve ${packageName} from ${searchRoot}`, ); } + return readPackageLocation(packageName, packageJsonPath); +} - const location = await locateCore(folder, logger); - const configLoaderPath = resolveCoreSubpath(location, 'config-loader'); - const eslintPluginPath = resolveCoreSubpath(location, 'eslint-plugin'); - assertSingleResolutionRoot(location.coreDir, [ - { label: '@rslint/core/config-loader', path: configLoaderPath }, - { label: '@rslint/core/eslint-plugin', path: eslintPluginPath }, - ]); - - const binFromUserSetting = binPathConfig === 'custom'; - const binPath = binFromUserSetting - ? await resolveUserBinary(folder, logger) - : resolveNativeBinary(location, logger); +export interface ResolveRslintOptions { + readonly folderRoot: string; + readonly mode: RslintMode; + readonly corePath?: string; +} - if (binFromUserSetting) { - // The user explicitly waived the one-root invariant for the binary only. - // Say so loudly: a binary/loader protocol drift shows up here first. - logger.warn( - `Rslint binary comes from rstack.rslint.customBinPath (${binPath}) while the config-loader comes from ${location.coreDir}. The single-resolution-root invariant is waived by this explicit setting.`, - ); +/** Resolves the same package chain `rs lint` uses without loading project code. */ +export function resolveRslint({ + folderRoot, + mode, + corePath, +}: ResolveRslintOptions): RslintResolution { + let rstack: PackageLocation | undefined; + let shimPath: string | undefined; + if (mode === 'bridged') { + rstack = resolveInstalledPackage('rstack', folderRoot, 'missing-rstack'); + shimPath = path.join(rstack.directory, 'dist', 'rslintConfig.js'); + try { + if (!fs.statSync(shimPath).isFile()) throw new Error('not a file'); + } catch (error) { + throw new RslintResolutionError( + 'missing-shim', + `rstack does not provide the lint config shim at ${shimPath}`, + { cause: error }, + ); + } } + const configuredCorePath = corePath?.trim(); + const core = configuredCorePath + ? resolveConfiguredCore(folderRoot, configuredCorePath) + : resolveInstalledPackage( + '@rslint/core', + rstack?.directory ?? folderRoot, + 'missing-core', + ); + return { - kind: location.kind, - coreDir: location.coreDir, - coreVersion: readPackageVersion(location.packageJsonPath), - binPath, - binFromUserSetting, - configLoaderPath, - eslintPluginPath, + mode, + coreDir: core.directory, + coreVersion: core.version, + ...(rstack === undefined + ? {} + : { + rstackDir: rstack.directory, + rstackVersion: rstack.version, + shimPath, + }), }; -}; +} diff --git a/packages/vscode/src/stacks/lint/status.ts b/packages/vscode/src/stacks/lint/status.ts new file mode 100644 index 0000000..fffcf37 --- /dev/null +++ b/packages/vscode/src/stacks/lint/status.ts @@ -0,0 +1,34 @@ +import type { StackState } from '../../types'; +import { RslintResolutionError } from './resolution'; + +export class RslintVersionMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'RslintVersionMismatchError'; + } +} + +export const statusForRslintStartFailure = (error: unknown): StackState => { + if (error instanceof RslintVersionMismatchError) { + return { kind: 'version-mismatch', detail: error.message }; + } + if ( + error instanceof RslintResolutionError && + error.code === 'missing-rstack' + ) { + return { + kind: 'disabled', + reason: + 'rstack is not installed (node_modules missing) — install it, then restart Rslint if this status stays', + }; + } + return { + kind: 'crashed', + detail: error instanceof Error ? error.message : String(error), + }; +}; + +export const runningRslintStatus = (advisory?: string): StackState => + advisory === undefined + ? { kind: 'running' } + : { kind: 'version-mismatch', detail: advisory }; diff --git a/packages/vscode/src/stacks/lint/utils.ts b/packages/vscode/src/stacks/lint/utils.ts deleted file mode 100644 index b94ca01..0000000 --- a/packages/vscode/src/stacks/lint/utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copied from web-infra-dev/rslint `packages/vscode-extension/src/utils.ts` -// (origin/main). Adapted: the `built-in` binary mode is -// dropped entirely (this extension ships no Go binary), so `RslintBinPath` -// loses that member. -// -// Reference: https://github.com/biomejs/biome-vscode/blob/8fa2ca19e612575479c840bd58f6d31e4e503b13/src/utils.ts - -import { arch } from 'node:os'; -import { FileType, Uri, workspace } from 'vscode'; - -/** - * Checks whether a file exists - * - * This function checks whether a file exists at the given URI using VS Code's - * FileSystem API. - * - * @param uri URI of the file to check - * @returns Whether the file exists - */ -export const fileExists = async (uri: Uri): Promise => { - try { - const stat = await workspace.fs.stat(uri); - return (stat.type & FileType.File) > 0; - } catch { - return false; - } -}; - -/** - * Returns the ordered list of platform-package requests to try-resolve when - * locating the Go binary, mirroring `packages/rslint/bin/rslint.js`. - * - * The Go binary lives in the `@rslint/native-{tuple}` subpackage, reached via - * its `./bin` export. npm installs only the subpackage matching the host - * os/cpu/libc, so on linux we try gnu then musl and use whichever got - * installed — no libc sniffing (Go binaries are static, the gnu/musl - * distinction doesn't matter to them). Callers should resolve each candidate - * in order and use the first that succeeds. - */ -export const getPlatformBinRequests = (): string[] => { - const cpu = arch(); - const tuples = - process.platform === 'linux' - ? [`linux-${cpu}-gnu`, `linux-${cpu}-musl`] - : process.platform === 'win32' - ? [`win32-${cpu}-msvc`] - : [`${process.platform}-${cpu}`]; - return tuples.map((tuple) => `@rslint/native-${tuple}/bin`); -}; - -/** - * `built-in` is deliberately absent: this extension ships no Go - * binary, so the only resolution order is explicit setting → workspace - * `node_modules` → Yarn PnP, and a failed resolution surfaces in the status bar - * instead of silently falling back. - */ -export type RslintBinPath = 'local' | 'custom'; diff --git a/packages/vscode/src/stacks/lint/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts similarity index 56% rename from packages/vscode/src/stacks/lint/ConfigTransactionAdapter.ts rename to packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index acbfff0..4e456c3 100644 --- a/packages/vscode/src/stacks/lint/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -1,11 +1,3 @@ -// Copied from web-infra-dev/rslint -// `packages/vscode-extension/src/ConfigTransactionAdapter.ts` (origin/main). -// -// Adapted for the resolve-from-project adaptation: `@rslint/core/config-loader` is not bundled, so -// `CONFIG_DISCOVERY_PROTOCOL_VERSION` is no longer a compile-time constant. It -// is read from the project-resolved loader module and injected here, and a -// mismatch reported by the Go server is routed to `onProtocolMismatch` so the -// status bar can show the `version mismatch` state. import type { ActivateConfigsRequest, ActivateConfigsResponse, @@ -18,9 +10,7 @@ import type { interface ConfigActivationWireResponse { transactionId: string; - /** Empty when no matching worker generation could be staged. */ eslintPluginEntries: ConfigModuleEslintPluginEntry[]; - /** False lets Go preserve its last-good catalog instead of committing. */ pluginHostReady: boolean; } @@ -39,7 +29,6 @@ interface ConfigAbortWireResponse { aborted: true; } -/** Structural seams keep the JSON-RPC transaction adapter independently testable. */ interface ConfigModuleHostAdapter { loadConfigs( request: LoadConfigsRequest, @@ -69,24 +58,6 @@ function throwIfAborted(signal?: AbortSignal): void { throw new Error('config transaction was cancelled'); } -/** - * Raised when the Go server speaks a config-discovery protocol version other - * than the one the project-resolved loader implements. This must be - * surfaced as the `version mismatch` status-bar state rather than as a generic - * transaction failure. - */ -export class ConfigTransactionProtocolMismatchError extends Error { - constructor( - readonly serverProtocolVersion: unknown, - readonly loaderProtocolVersion: number, - ) { - super( - `unsupported config transaction protocol ${String(serverProtocolVersion)} (the project's @rslint/core config-loader implements ${loaderProtocolVersion})`, - ); - this.name = 'ConfigTransactionProtocolMismatchError'; - } -} - function assertTransactionControlRequest( request: ConfigTransactionControlRequest, protocolVersion: number, @@ -95,9 +66,8 @@ function assertTransactionControlRequest( throw new Error('config transaction request must be an object'); } if (request.protocolVersion !== protocolVersion) { - throw new ConfigTransactionProtocolMismatchError( - request.protocolVersion, - protocolVersion, + throw new Error( + `unsupported config transaction protocol ${String(request.protocolVersion)}`, ); } if ( @@ -108,13 +78,7 @@ function assertTransactionControlRequest( } } -/** - * LSP transport adapter for the shared config module host. - * - * Go owns discovery, ignore semantics, last-good selection and catalog commit. - * This adapter only evaluates Go's candidates, stages the matching plugin host, - * and mirrors Go's final commit/abort for the same transaction ID. - */ +/** Hosts one Go config-discovery transaction inside the lint worker. */ export class LspConfigTransactionAdapter { private readonly transactions = new Set(); private disposed = false; @@ -123,39 +87,19 @@ export class LspConfigTransactionAdapter { private readonly host: ConfigModuleHostAdapter, private readonly pluginLintPool: PluginLintPoolAdapter, private readonly fingerprint: (plan: ConfigModuleActivationPlan) => string, - /** `CONFIG_DISCOVERY_PROTOCOL_VERSION` of the project-resolved loader. */ private readonly protocolVersion: number, - private readonly onProtocolMismatch?: ( - error: ConfigTransactionProtocolMismatchError, - ) => void, ) {} - private assertRequest(request: ConfigTransactionControlRequest): void { - try { - assertTransactionControlRequest(request, this.protocolVersion); - } catch (error) { - if (error instanceof ConfigTransactionProtocolMismatchError) { - this.onProtocolMismatch?.(error); - } - throw error; - } - } - async loadConfigs( request: LoadConfigsRequest, signal?: AbortSignal, ): Promise { this.assertActive(); - this.assertRequest(request); + assertTransactionControlRequest(request, this.protocolVersion); throwIfAborted(signal); const transactionId = request.transactionId; this.transactions.add(transactionId); try { - // Editor reloads must not reuse the config entry module. Go still sends - // the shared envelope, but the LSP transport makes that entry-freshness - // invariant explicit for every frontier. Static transitive imports retain - // Node's normal module-cache semantics; full graph isolation requires a - // separate evaluator realm rather than query-busting only the entry URL. const response = await this.host.loadConfigs( { ...request, loadMode: 'fresh' }, signal, @@ -174,7 +118,7 @@ export class LspConfigTransactionAdapter { signal?: AbortSignal, ): Promise { this.assertActive(); - this.assertRequest(request); + assertTransactionControlRequest(request, this.protocolVersion); throwIfAborted(signal); const transactionId = request.transactionId; try { @@ -198,10 +142,6 @@ export class LspConfigTransactionAdapter { throwIfAborted(signal); return { transactionId: activation.transactionId, - // Never ask Go to register/dispatch placeholder rules without the - // matching worker generation. On first startup Go may still commit the - // ordinary native config as a degraded no-host generation; with a - // last-good generation it instead aborts this transaction. eslintPluginEntries: pluginHostReady ? activation.eslintPluginEntries : [], @@ -218,7 +158,7 @@ export class LspConfigTransactionAdapter { request: ConfigTransactionControlRequest, ): Promise { this.assertActive(); - this.assertRequest(request); + assertTransactionControlRequest(request, this.protocolVersion); const transactionId = request.transactionId; if (!(await this.pluginLintPool.commit(transactionId))) { throw new Error( @@ -226,47 +166,20 @@ export class LspConfigTransactionAdapter { ); } this.cleanup(transactionId); - return { - transactionId, - committed: true, - }; + return { transactionId, committed: true }; } async abortConfigs( request: ConfigTransactionControlRequest, ): Promise { - this.assertRequest(request); + assertTransactionControlRequest(request, this.protocolVersion); const transactionId = request.transactionId; try { await this.pluginLintPool.abort(transactionId); } finally { this.cleanup(transactionId); } - return { - transactionId, - aborted: true, - }; - } - - /** - * Drop transactions orphaned by a native-server restart while keeping the - * adapter reusable for the replacement process. A transaction that reached - * PluginLintPool.commit but whose response was lost is compensated by abort; - * an older fully committed host is not in this set and remains available - * until the replacement server commits its first catalog. - */ - async resetForServerRestart(): Promise { - this.assertActive(); - const orphaned = [...this.transactions]; - this.transactions.clear(); - for (const transactionId of orphaned) { - this.host.deleteSession(transactionId); - } - await Promise.allSettled( - orphaned.map(async (transactionId) => - this.pluginLintPool.abort(transactionId), - ), - ); + return { transactionId, aborted: true }; } dispose(): void { diff --git a/packages/vscode/src/stacks/lint/PluginLintPool.ts b/packages/vscode/src/stacks/lint/worker/PluginLintPool.ts similarity index 53% rename from packages/vscode/src/stacks/lint/PluginLintPool.ts rename to packages/vscode/src/stacks/lint/worker/PluginLintPool.ts index 36904e5..4290173 100644 --- a/packages/vscode/src/stacks/lint/PluginLintPool.ts +++ b/packages/vscode/src/stacks/lint/worker/PluginLintPool.ts @@ -1,65 +1,20 @@ -/** - * VS Code-side host for ESLint-plugin lint requests. - * - * The Go LSP server lints natively, but rules mounted via a config's - * object-form `plugins` run in JS. So when Go encounters such a rule it sends a - * server→client `rslint/pluginLint` request back to this extension; - * we answer it from an in-process WorkerPool owned by `@rslint/core`'s - * `createPluginLintHost`. This file is a thin lifecycle wrapper over that - * host — the request→tasks→result boundary itself lives in `@rslint/core` - * (`buildPluginLintTasks` / `buildPluginLintResult`), shared with the CLI - * engine so the two paths never drift. - * - * Copied from web-infra-dev/rslint `packages/vscode-extension/src/PluginLintPool.ts` - * (origin/main), with the module-load path adapted for the resolve-from-project - * adaptation. - * - * Upstream loads the host through the RELATIVE specifier - * `./eslint-plugin/index.js`, which resolves to the extension's own staged - * `dist/eslint-plugin/`. This extension stages nothing: the host is resolved - * from the *project*, out of the same `@rslint/core` install as the Go binary - * (`resolution.ts`), and handed in through the mandatory `createHost` factory. - * This is a verified non-requirement — a plain project - * install runs the whole pipeline unpatched, worker and napi parser included — - * so the relative-import fix is the only change needed, and it lives here. - * - * The host is ESM (it spawns its sibling `lint-worker.js` via - * `import.meta.url`) while this extension is bundled to CJS, so the factory - * must reach it through a dynamic `import()`, never `require`. - */ - -import { window } from 'vscode'; -import type { CancellationToken } from 'vscode'; -import type { Logger } from './logger'; import type { ConfigDescriptor, - PluginLintHost, EslintPluginLintRequest, EslintPluginLintResult, + PluginLintHost, } from '@rslint/core/eslint-plugin'; +import type { CancellationToken } from 'vscode-jsonrpc/node'; +import type { WorkerLogger } from './logger'; export type PluginHostFactory = ( configs: ConfigDescriptor[], - onLog: (rec: { level: string; source: string; text: string }) => void, + onLog: (record: { level: string; source: string; text: string }) => void, ) => Promise; -// One predecessor is retained without a timer so an active commit can be -// rolled back if its JSON-RPC response is lost and Go subsequently aborts. -// Keep one additional grace generation for already-dispatched requests: the -// bound remains two old pools plus the active pool. Hosts with an acquired -// lint lease may temporarily exceed this bound until requests drain. const MAX_GRACE_GENERATIONS = 1; -/** - * Latches the one-shot "host failed to load" warning at MODULE scope (not - * per-instance) so a persistent failure (e.g. a broken vsix that didn't ship - * the worker payload) surfaces once per session — not once per workspace folder - * in a multi-root window, where each folder owns its own PluginLintPool. - */ -let warnedOnce = false; - export class PluginLintPool { - private readonly logger: Logger; private readonly generations = new Map(); private readonly generationRetirementTimers = new Map< string, @@ -67,66 +22,24 @@ export class PluginLintPool { >(); private activeGeneration: string | undefined; private activeState: HostGeneration | undefined; - /** - * The active generation's compensating rollback record. JSON-RPC has no - * response acknowledgement, so commit cannot discard this predecessor: Go - * may keep last-good and send abort when the commit response is lost or - * invalid. A later successful commit proves Go accepted this generation and - * moves its predecessor into the ordinary grace-retirement queue. - */ private activeCommitRollback: ActiveCommitRollback | undefined; private readonly liveStates = new Set(); private readonly shutdowns = new Set>(); - /** - * Serializes every lifecycle op (prepare/commit/abort/dispose). Each op - * chains onto the previous one's settlement, so concurrent config reloads - * cannot race host installation or map mutation. Lint requests for an - * installed generation take a lease immediately; only a generation that is - * not installed yet waits for this chain and checks again. - */ private opChain: Promise = Promise.resolve(); private disposed = false; - private readonly createHost: PluginHostFactory; - private readonly retirementDelayMs: number; constructor( - logger: Logger, - // No default: there is no extension-local host to fall back to. The caller - // supplies a factory that loads the project-resolved module. - createHost: PluginHostFactory, - retirementDelayMs = 30_000, - ) { - this.logger = logger; - this.createHost = createHost; - this.retirementDelayMs = retirementDelayMs; - } + private readonly logger: WorkerLogger, + private readonly createHost: PluginHostFactory, + private readonly retirementDelayMs = 30_000, + ) {} - /** Append `op` to the serialized lifecycle chain and await its turn. */ - private async enqueue(op: () => Promise): Promise { - const run = this.opChain.then(op, op); - // Keep the chain alive even if `op` throws — swallow on the chain copy so a - // single failed op doesn't poison every subsequent one. Callers awaiting - // the returned promise still observe the rejection. + private async enqueue(operation: () => Promise): Promise { + const run = this.opChain.then(operation, operation); this.opChain = run.catch(() => undefined); return run; } - /** - * Prepare a generation without making it the active fallback for requests - * without a key. The transport commits it at the matching config - * transaction's commit point; - * an abort after commit can still compensate for a lost response and return - * to the prior Go last-good generation. - * - * Returns whether the requested host state is active. Rebuilds are - * transactional: a failed replacement leaves the previous host available so - * the caller can preserve the matching last-good config payload. - * - * Empty `descriptors` needs no host, avoiding a module load and worker-pool - * allocation when no object-form community plugins are configured. The - * matching activation publishes no plugin metadata, so Go must never issue - * a plugin-lint request for that generation without a host. - */ async prepare( descriptors: ConfigDescriptor[], fingerprint: string, @@ -142,8 +55,6 @@ export class PluginLintPool { return; } - // Config-only changes can reuse the same plugin host. The new generation - // is still staged separately and is not routable as active until commit. if ( this.activeState?.ready && this.activeState.fingerprint === fingerprint @@ -167,13 +78,12 @@ export class PluginLintPool { } try { - const replacement = await this.createHost(descriptors, (rec) => { - const text = `[rslint:plugin] ${rec.text}`; - if (rec.level === 'error') this.logger.error(text); + const replacement = await this.createHost(descriptors, (record) => { + const text = `[rslint:plugin] ${record.text}`; + if (record.level === 'error') this.logger.error(text); else this.logger.debug(text); }); if (this.disposed) { - // Disposed while initializing — shut the fresh pool back down. await replacement.shutdown().catch(() => undefined); return; } @@ -187,13 +97,7 @@ export class PluginLintPool { this.liveStates.add(state); this.generations.set(generation, state); ready = true; - } catch (err: unknown) { - // Init failed: either the host module couldn't be loaded (a broken or - // partial `@rslint/core` install in the project — see `resolution.ts`), - // or a referenced plugin failed to import. Keep the previous - // active host intact. Record an unavailable staged generation so the - // first valid config can still be committed and serve native rules; - // later prepares retry instead of caching this failure as ready. + } catch (error: unknown) { const state: HostGeneration = { fingerprint, ready: false, @@ -202,23 +106,12 @@ export class PluginLintPool { }; this.liveStates.add(state); this.generations.set(generation, state); - this.logger.error('Failed to initialize ESLint-plugin host', err); - // Make the failure visible — but ONLY when a config actually mounted - // plugins (an empty-descriptor host builds no worker and failing is - // not a user-facing problem), and only once per session so a - // persistent failure doesn't re-warn on every reload. - if (descriptors.length > 0 && !warnedOnce) { - warnedOnce = true; - void window.showWarningMessage( - 'Rstack: failed to load the Rslint ESLint-plugin host; rules mounted via a config’s `plugins` will report no diagnostics. See the "Rstack: Rslint" output channel for details.', - ); - } + this.logger.error('Failed to initialize ESLint-plugin host', error); } }); return ready; } - /** Commit a previously prepared generation after Go accepts its config. */ async commit(generation: string): Promise { let committed = false; await this.enqueue(async () => { @@ -248,7 +141,6 @@ export class PluginLintPool { return committed; } - /** Discard a staged generation when source validation or Go commit fails. */ async abort(generation: string): Promise { await this.enqueue(async () => { if (generation === this.activeGeneration) { @@ -280,64 +172,50 @@ export class PluginLintPool { }); } - /** Answer one reverse `rslint/pluginLint` request. */ async lint( - req: EslintPluginLintRequest, + request: EslintPluginLintRequest, token?: CancellationToken, ): Promise { if (this.disposed) return { results: [] }; - let state = req.generation - ? this.generations.get(req.generation) + let state = request.generation + ? this.generations.get(request.generation) : this.activeState; - - // A reverse request may arrive after Go accepts a config but just before - // Node installs that generation. Wait only in that case. Existing - // generations must remain routable while an unrelated prepare is slow. - if (req.generation && !state) { + if (request.generation && !state) { if (!(await this.waitForLifecycle(token))) return { results: [] }; if (this.disposed) return { results: [] }; - state = this.generations.get(req.generation); + state = this.generations.get(request.generation); } - if (req.generation && !state) { + if (request.generation && !state) { throw new Error( - `unknown ESLint-plugin config generation: ${req.generation}`, + `unknown ESLint-plugin config generation: ${request.generation}`, ); } if (!state) return { results: [] }; const host = state.host; if (!host) { - // Generations without a host are valid committed states for native-only or - // degraded catalogs, but their activation exposes no plugin metadata. - // Reaching this branch therefore means Go and the extension disagree on - // the committed lifecycle. Do not turn that protocol failure into a - // false-green empty diagnostic set. Cancellation remains benign. if (token?.isCancellationRequested) return { results: [] }; - const generation = req.generation ?? this.activeGeneration; + const generation = request.generation ?? this.activeGeneration; throw new Error( `LSP pluginLint requested for config generation ${JSON.stringify(generation)} without an activated plugin host`, ); } - // Take the lease before yielding. Retirement removes future routing - // references, but cannot shut this state down until the lease is released. state.activeLints++; - // Bridge the LSP CancellationToken → AbortSignal for the core host, so a - // superseding keystroke / close (Go sends $/cancelRequest) stops the worker - // instead of letting it run to completion. let signal: AbortSignal | undefined; let cancellationSubscription: { dispose(): unknown } | undefined; try { if (token) { - const ac = new AbortController(); - if (token.isCancellationRequested) ac.abort(); - else + const controller = new AbortController(); + if (token.isCancellationRequested) controller.abort(); + else { cancellationSubscription = token.onCancellationRequested(() => { - ac.abort(); + controller.abort(); }); - signal = ac.signal; + } + signal = controller.signal; } - return await host.lint(req, signal); + return await host.lint(request, signal); } finally { cancellationSubscription?.dispose(); state.activeLints--; @@ -347,7 +225,6 @@ export class PluginLintPool { } } - /** Wait for the lifecycle snapshot that could be installing a generation. */ private async waitForLifecycle(token?: CancellationToken): Promise { const pending = this.opChain; if (!token) { @@ -411,9 +288,6 @@ export class PluginLintPool { }, this.retirementDelayMs); this.generationRetirementTimers.set(generation, timer); - // A burst of config updates must not retain one complete WorkerPool per - // generation for the full production grace period. Expire the oldest - // routing generation immediately once the bounded history is full. while (this.generationRetirementTimers.size > MAX_GRACE_GENERATIONS) { const oldest = this.generationRetirementTimers.keys().next().value; if (oldest === undefined) break; @@ -453,8 +327,8 @@ export class PluginLintPool { } } const shutdown = state.host - ? state.host.shutdown().catch((err: unknown) => { - this.logger.error('Error shutting down previous plugin host', err); + ? state.host.shutdown().catch((error: unknown) => { + this.logger.error('Error shutting down previous plugin host', error); }) : Promise.resolve(); state.shutdown = shutdown; @@ -465,7 +339,6 @@ export class PluginLintPool { }); } - /** Shut down the worker pool. Idempotent. */ async dispose(): Promise { this.disposed = true; await this.enqueue(async () => { @@ -478,11 +351,7 @@ export class PluginLintPool { this.activeGeneration = undefined; this.activeState = undefined; this.activeCommitRollback = undefined; - for (const state of states) { - // Terminal disposal intentionally forces shutdown even if a request is - // still active; WorkerPool turns those tasks into benign cancellation. - this.startShutdown(state); - } + for (const state of states) this.startShutdown(state); }); await Promise.all([...this.shutdowns]); } diff --git a/packages/vscode/src/stacks/lint/worker/cli.ts b/packages/vscode/src/stacks/lint/worker/cli.ts new file mode 100644 index 0000000..3d5a515 --- /dev/null +++ b/packages/vscode/src/stacks/lint/worker/cli.ts @@ -0,0 +1,50 @@ +import path from 'node:path'; + +export interface LintWorkerOptions { + readonly coreDir: string; + readonly configPath?: string; +} + +export interface ConfigRefreshParams { + readonly reason: unknown; +} + +export const LINT_WORKER_USAGE = + 'Usage: lint-worker --lsp --core [--config ]'; + +export function parseWorkerArgs(args: readonly string[]): LintWorkerOptions { + if (args.length !== 3 && args.length !== 5) { + throw new Error(LINT_WORKER_USAGE); + } + if (args[0] !== '--lsp' || args[1] !== '--core') { + throw new Error(LINT_WORKER_USAGE); + } + const coreDir = args[2]; + if (!coreDir || !path.isAbsolute(coreDir)) { + throw new Error(`--core must be an absolute path\n${LINT_WORKER_USAGE}`); + } + if (args.length === 3) return { coreDir: path.normalize(coreDir) }; + if (args[3] !== '--config') { + throw new Error(LINT_WORKER_USAGE); + } + const configPath = args[4]; + if (!configPath || !path.isAbsolute(configPath)) { + throw new Error(`--config must be an absolute path\n${LINT_WORKER_USAGE}`); + } + return { + coreDir: path.normalize(coreDir), + configPath: path.normalize(configPath), + }; +} + +export function stampConfigRefresh( + params: ConfigRefreshParams, + protocolVersion: number, + configPath?: string, +): Record { + return { + protocolVersion, + reason: params?.reason, + ...(configPath === undefined ? {} : { configPath }), + }; +} diff --git a/packages/vscode/src/stacks/lint/worker/core.ts b/packages/vscode/src/stacks/lint/worker/core.ts new file mode 100644 index 0000000..e31baea --- /dev/null +++ b/packages/vscode/src/stacks/lint/worker/core.ts @@ -0,0 +1,156 @@ +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { + ConfigModuleHost, + ConfigModuleHostOptions, +} from '@rslint/core/config-loader'; +import type { createPluginLintHost } from '@rslint/core/eslint-plugin'; + +const CORE_PACKAGE_NAME = '@rslint/core'; + +type ConfigModuleHostConstructor = new ( + options?: ConfigModuleHostOptions, +) => ConfigModuleHost; + +interface ConfigLoaderModule { + readonly ConfigModuleHost: ConfigModuleHostConstructor; + readonly CONFIG_DISCOVERY_PROTOCOL_VERSION: number; + readonly resolveRslintBinary: () => unknown; +} + +interface PluginHostModule { + readonly createPluginLintHost: typeof createPluginLintHost; +} + +interface CorePackageJson { + readonly name: string; + readonly version: string; +} + +export interface CoreInstallation { + readonly packageDirectory: string; + readonly version: string; + readonly binaryPath: string; + readonly protocolVersion: number; + createConfigModuleHost(): ConfigModuleHost; + createPluginLintHost: typeof createPluginLintHost; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isConfigLoaderModule(value: unknown): value is ConfigLoaderModule { + return ( + isRecord(value) && + typeof value.ConfigModuleHost === 'function' && + Number.isInteger(value.CONFIG_DISCOVERY_PROTOCOL_VERSION) && + typeof value.resolveRslintBinary === 'function' + ); +} + +function isPluginHostModule(value: unknown): value is PluginHostModule { + return isRecord(value) && typeof value.createPluginLintHost === 'function'; +} + +async function readPackageJson( + packageDirectory: string, +): Promise { + const packageJsonPath = path.join(packageDirectory, 'package.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as unknown; + } catch (error) { + throw new Error(`Could not read ${packageJsonPath}`, { cause: error }); + } + if ( + !isRecord(parsed) || + parsed.name !== CORE_PACKAGE_NAME || + typeof parsed.version !== 'string' || + parsed.version.length === 0 + ) { + throw new Error( + `${packageJsonPath} is not a valid ${CORE_PACKAGE_NAME} package`, + ); + } + return { name: parsed.name, version: parsed.version }; +} + +function resolveExport(packageDirectory: string, subpath: string): string { + const packageJsonPath = path.join(packageDirectory, 'package.json'); + try { + return createRequire(packageJsonPath).resolve( + `${CORE_PACKAGE_NAME}/${subpath}`, + ); + } catch (error) { + throw new Error( + `${CORE_PACKAGE_NAME} does not provide the required ./${subpath} export`, + { cause: error }, + ); + } +} + +async function loadModule(modulePath: string): Promise { + return import(pathToFileURL(modulePath).href) as Promise; +} + +export async function loadCoreInstallation( + packageDirectory: string, +): Promise { + const packageJson = await readPackageJson(packageDirectory); + const configLoaderPath = resolveExport(packageDirectory, 'config-loader'); + const pluginHostPath = resolveExport(packageDirectory, 'eslint-plugin'); + const configLoaderModule = await loadModule(configLoaderPath); + if (!isConfigLoaderModule(configLoaderModule)) { + throw new Error( + `${CORE_PACKAGE_NAME}/config-loader has an incompatible module shape`, + ); + } + + const binaryPath = configLoaderModule.resolveRslintBinary(); + if (typeof binaryPath !== 'string' || binaryPath.length === 0) { + throw new Error( + `${CORE_PACKAGE_NAME}/config-loader returned an invalid binary path`, + ); + } + const binaryStat = await fs.stat(binaryPath).catch((error: unknown) => { + throw new Error(`Rslint binary does not exist at ${binaryPath}`, { + cause: error, + }); + }); + if (!binaryStat.isFile()) { + throw new Error(`Rslint binary is not a file: ${binaryPath}`); + } + + let pluginFactoryPromise: Promise | undefined; + const getPluginFactory = async (): Promise => { + pluginFactoryPromise ??= loadModule(pluginHostPath).then((module) => { + if (!isPluginHostModule(module)) { + throw new Error( + `${CORE_PACKAGE_NAME}/eslint-plugin has an incompatible module shape`, + ); + } + return module.createPluginLintHost; + }); + try { + return await pluginFactoryPromise; + } catch (error) { + pluginFactoryPromise = undefined; + throw error; + } + }; + + return { + packageDirectory, + version: packageJson.version, + binaryPath, + protocolVersion: configLoaderModule.CONFIG_DISCOVERY_PROTOCOL_VERSION, + createConfigModuleHost: () => new configLoaderModule.ConfigModuleHost(), + createPluginLintHost: async (...args) => { + const factory = await getPluginFactory(); + return factory(...args); + }, + }; +} diff --git a/packages/vscode/src/stacks/lint/worker/fingerprint.ts b/packages/vscode/src/stacks/lint/worker/fingerprint.ts new file mode 100644 index 0000000..92f0aac --- /dev/null +++ b/packages/vscode/src/stacks/lint/worker/fingerprint.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { ConfigModuleActivationPlan } from '@rslint/core/config-loader'; + +const LOCKFILE_NAMES = [ + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +] as const; + +export class ActivationFingerprinter { + private dependencyRevision = 0; + + constructor(private readonly cwd: string) {} + + observeRefresh(reason: unknown): void { + if (reason === 'dependency-change') this.dependencyRevision++; + } + + compute(activation: ConfigModuleActivationPlan): string { + const sourceFingerprintByPath = new Map( + activation.configs.map((config) => [ + path.normalize(config.configPath), + config.sourceFingerprint, + ]), + ); + const parts: string[] = []; + const configPaths = activation.pluginConfigs + .map((config) => config.configPath) + .sort(); + for (const configPath of configPaths) { + const sourceFingerprint = sourceFingerprintByPath.get( + path.normalize(configPath), + ); + if (sourceFingerprint === undefined) { + throw new Error( + `missing source fingerprint for plugin config ${configPath}`, + ); + } + parts.push(`${configPath}:${sourceFingerprint}`); + } + for (const name of LOCKFILE_NAMES) { + parts.push( + `lock:${name}:${this.computeMetadataFingerprint(path.join(this.cwd, name))}`, + ); + } + parts.push(`dependency-revision:${this.dependencyRevision}`); + return parts.join('|'); + } + + private computeMetadataFingerprint(filePath: string): string { + try { + const stat = fs.statSync(filePath); + return `${stat.mtimeMs}:${stat.size}`; + } catch { + return 'absent'; + } + } +} diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts new file mode 100644 index 0000000..1bdc3b8 --- /dev/null +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -0,0 +1,276 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { + ActivateConfigsRequest, + LoadConfigsRequest, +} from '@rslint/core/config-loader'; +import type { EslintPluginLintRequest } from '@rslint/core/eslint-plugin'; +import { + createMessageConnection, + type CancellationToken, + type MessageConnection, +} from 'vscode-jsonrpc/node'; +import { + LspConfigTransactionAdapter, + type ConfigTransactionControlRequest, +} from './ConfigTransactionAdapter'; +import { PluginLintPool } from './PluginLintPool'; +import { + stampConfigRefresh, + type ConfigRefreshParams, + type LintWorkerOptions, +} from './cli'; +import { loadCoreInstallation } from './core'; +import { ActivationFingerprinter } from './fingerprint'; +import { logger } from './logger'; + +const GRACEFUL_EXIT_TIMEOUT_MS = 500; +const FORCED_EXIT_TIMEOUT_MS = 1_500; + +interface StopRequest { + readonly exitCode: number; + readonly reason: string; +} + +function deferred(): { + readonly promise: Promise; + resolve(value: T): void; +} { + let resolvePromise!: (value: T) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +} + +function hasExited(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +async function waitForClose( + closed: Promise, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (closedInTime: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(closedInTime); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + void closed.then(() => finish(true)); + }); +} + +async function terminateChild( + child: ChildProcessWithoutNullStreams, + closed: Promise, +): Promise { + if (!hasExited(child)) child.kill('SIGTERM'); + if (await waitForClose(closed, GRACEFUL_EXIT_TIMEOUT_MS)) return; + if (!hasExited(child)) child.kill('SIGKILL'); + if (await waitForClose(closed, FORCED_EXIT_TIMEOUT_MS)) return; + throw new Error( + `Rslint process ${String(child.pid)} did not exit after SIGKILL`, + ); +} + +async function spawnRslint( + binaryPath: string, +): Promise { + const child = spawn(binaryPath, ['--lsp'], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + }); + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + child.stderr.pipe(process.stderr, { end: false }); + return child; +} + +async function withCancellationSignal( + token: CancellationToken, + operation: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + if (token.isCancellationRequested) controller.abort(); + const subscription = token.onCancellationRequested(() => { + controller.abort(); + }); + try { + return await operation(controller.signal); + } finally { + subscription.dispose(); + } +} + +function forwardNotification( + target: MessageConnection, + method: string, + params: unknown, + requestStop: (request: StopRequest) => void, +): void { + const forwarded = + params === undefined + ? target.sendNotification(method) + : target.sendNotification(method, params); + void forwarded.catch((error: unknown) => { + logger.error(`Failed to forward notification ${method}`, error); + requestStop({ exitCode: 1, reason: `notification ${method} failed` }); + }); +} + +function forwardRequest( + target: MessageConnection, + method: string, + params: unknown, + token: CancellationToken, +): Promise { + return params === undefined + ? target.sendRequest(method, token) + : target.sendRequest(method, params, token); +} + +interface EditorProxyOptions { + readonly protocolVersion: number; + readonly configPath?: string; + observeRefresh(reason: unknown): void; + requestStop(request: StopRequest): void; +} + +export function registerEditorProxy( + editorConnection: MessageConnection, + goConnection: MessageConnection, + options: EditorProxyOptions, +): void { + editorConnection.onRequest(async (method, params, token) => { + if (method === 'rslint/configRefresh') { + const refresh = params as ConfigRefreshParams; + options.observeRefresh(refresh?.reason); + return goConnection.sendRequest( + method, + stampConfigRefresh( + refresh, + options.protocolVersion, + options.configPath, + ), + token, + ); + } + return forwardRequest(goConnection, method, params, token); + }); + editorConnection.onNotification((method, params) => { + forwardNotification(goConnection, method, params, options.requestStop); + }); +} + +export async function runLintWorker( + options: LintWorkerOptions, +): Promise { + const installation = await loadCoreInstallation(options.coreDir); + logger.info( + `Loaded @rslint/core ${installation.version} from ${installation.packageDirectory}`, + ); + const child = await spawnRslint(installation.binaryPath); + const childClosed = new Promise((resolve) => { + child.once('close', () => resolve()); + }); + + const editorConnection = createMessageConnection( + process.stdin, + process.stdout, + logger, + ); + const goConnection = createMessageConnection( + child.stdout, + child.stdin, + logger, + ); + const fingerprinter = new ActivationFingerprinter(process.cwd()); + const pluginLintPool = new PluginLintPool( + logger, + installation.createPluginLintHost, + ); + const adapter = new LspConfigTransactionAdapter( + installation.createConfigModuleHost(), + pluginLintPool, + (activation) => fingerprinter.compute(activation), + installation.protocolVersion, + ); + + const stop = deferred(); + let stopping = false; + const requestStop = (request: StopRequest): void => { + if (stopping) return; + stopping = true; + logger.debug(`Stopping lint worker: ${request.reason}`); + stop.resolve(request); + }; + + registerEditorProxy(editorConnection, goConnection, { + protocolVersion: installation.protocolVersion, + configPath: options.configPath, + observeRefresh: (reason) => fingerprinter.observeRefresh(reason), + requestStop, + }); + + goConnection.onRequest(async (method, params, token) => { + switch (method) { + case 'rslint/loadConfigs': + return withCancellationSignal(token, async (signal) => + adapter.loadConfigs(params as LoadConfigsRequest, signal), + ); + case 'rslint/activateConfigs': + return withCancellationSignal(token, async (signal) => + adapter.activateConfigs(params as ActivateConfigsRequest, signal), + ); + case 'rslint/commitConfigs': + return adapter.commitConfigs(params as ConfigTransactionControlRequest); + case 'rslint/abortConfigs': + return adapter.abortConfigs(params as ConfigTransactionControlRequest); + case 'rslint/pluginLint': + return pluginLintPool.lint(params as EslintPluginLintRequest, token); + default: + return forwardRequest(editorConnection, method, params, token); + } + }); + goConnection.onNotification((method, params) => { + forwardNotification(editorConnection, method, params, requestStop); + }); + + editorConnection.onClose(() => { + requestStop({ exitCode: 0, reason: 'editor transport closed' }); + }); + goConnection.onClose(() => { + requestStop({ + exitCode: child.exitCode ?? 1, + reason: 'Rslint transport closed', + }); + }); + child.once('close', (code) => { + requestStop({ + exitCode: code ?? 1, + reason: `Rslint exited with code ${String(code)}`, + }); + }); + process.once('SIGINT', () => { + requestStop({ exitCode: 0, reason: 'received SIGINT' }); + }); + process.once('SIGTERM', () => { + requestStop({ exitCode: 0, reason: 'received SIGTERM' }); + }); + + goConnection.listen(); + editorConnection.listen(); + const result = await stop.promise; + + adapter.dispose(); + await pluginLintPool.dispose(); + editorConnection.dispose(); + goConnection.dispose(); + await terminateChild(child, childClosed); + return result.exitCode; +} diff --git a/packages/vscode/src/stacks/lint/worker/logger.ts b/packages/vscode/src/stacks/lint/worker/logger.ts new file mode 100644 index 0000000..7a4b614 --- /dev/null +++ b/packages/vscode/src/stacks/lint/worker/logger.ts @@ -0,0 +1,40 @@ +import type { Logger as JsonRpcLogger } from 'vscode-jsonrpc/node'; + +const format = (value: unknown): string => { + if (value instanceof Error) return value.stack ?? value.message; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +}; + +export class WorkerLogger implements JsonRpcLogger { + private write(level: string, message: string, error?: unknown): void { + const detail = error === undefined ? '' : `\n${format(error)}`; + process.stderr.write(`[rslint-worker:${level}] ${message}${detail}\n`); + } + + debug(message: string, error?: unknown): void { + this.write('debug', message, error); + } + + error(message: string, error?: unknown): void { + this.write('error', message, error); + } + + info(message: string, error?: unknown): void { + this.write('info', message, error); + } + + log(message: string): void { + this.write('log', message); + } + + warn(message: string, error?: unknown): void { + this.write('warn', message, error); + } +} + +export const logger = new WorkerLogger(); diff --git a/packages/vscode/src/stacks/lint/worker/main.ts b/packages/vscode/src/stacks/lint/worker/main.ts new file mode 100644 index 0000000..c8d615e --- /dev/null +++ b/packages/vscode/src/stacks/lint/worker/main.ts @@ -0,0 +1,13 @@ +import { parseWorkerArgs } from './cli'; +import { runLintWorker } from './index'; +import { logger } from './logger'; + +async function main(): Promise { + const options = parseWorkerArgs(process.argv.slice(2)); + process.exitCode = await runLintWorker(options); +} + +void main().catch((error: unknown) => { + logger.error(error instanceof Error ? error.message : String(error), error); + process.exitCode = 1; +}); diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index 1701355..1a03418 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -60,6 +60,8 @@ 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'; /** * `rstack.config.*` files governing this folder. A folder can be detected * through these alone, in which case the stack has to go through the rstack diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index 00b6efa..6a9f585 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -317,7 +317,7 @@ describe('restart-triggering settings', () => { harness.detected = new Set(['rslint', 'rstest', 'fmt']); harness.restartOnSettings = new Map([ ['rstest', ['nodeExecutable']], - ['rslint', ['binPath', 'customBinPath']], + ['rslint', ['rstack.nodeExecutable', 'corePath', 'trace.server']], ]); await activate(context); harness.events.length = 0; @@ -372,7 +372,7 @@ describe('restart-triggering settings', () => { }); it('honours every setting a stack declares, not just the first', async () => { - changeSetting('rstack.rslint.customBinPath'); + changeSetting('rstack.rslint.corePath'); await settle(); expect(stacksOf('register').sort()).toEqual(['fmt', 'rslint', 'rstest']); }); @@ -404,9 +404,9 @@ describe('restart-triggering settings', () => { }); it('ignores a declared name under another stack namespace', async () => { - // The section is built as `rstack..`, so rslint's binPath + // The section is built as `rstack..`, so rslint's corePath // must not move rstest even though both are declared somewhere. - changeSetting('rstack.rstest.binPath'); + changeSetting('rstack.rstest.corePath'); await settle(); expect(harness.events).toEqual([]); }); @@ -502,13 +502,16 @@ describe('the shell restart command', () => { it('resets the memo on a single-stack restart once it is the only consumer', async () => { harness.detected.delete('rstest'); - // Rstest retires through the gate; fmt is still live, so no reset yet. + // Rstest retires through the gate; lint and fmt still consume the memo, so + // no reset occurs yet. await run('rstack.rstest.restart'); expect(harness.nodeResets).toBe(0); - // fmt is now the only User-Node consumer and it is in the pass. The - // surviving lint controller does not hold the memo alive: it runs on the - // VS Code Node runtime and never reads it. + harness.detected.delete('rslint'); + await run('rstack.rslint.restart'); + expect(harness.nodeResets).toBe(0); + + // fmt is now the only User-Node consumer and it is in the pass. await run('rstack.fmt.restart'); expect(harness.nodeResets).toBe(1); }); diff --git a/packages/vscode/tests/lintDetection.test.ts b/packages/vscode/tests/lintDetection.test.ts new file mode 100644 index 0000000..5abaf34 --- /dev/null +++ b/packages/vscode/tests/lintDetection.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@rstest/core'; +import { decideRslintMode } from '../src/stacks/lint/resolution'; + +describe('Rslint folder ownership', () => { + 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/loadRstackConfig.test.ts b/packages/vscode/tests/loadRstackConfig.test.ts deleted file mode 100644 index b6cd53a..0000000 --- a/packages/vscode/tests/loadRstackConfig.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; -import { loadRstackConfig } from '../src/shared/vendored/loadRstackConfig'; - -// A stand-in for the project's own `rstack` install. It talks to the session -// storage exactly the way rstack's shipped `dist` chunk does — through -// `globalThis.__rstackConfigSessionStorage` — which is the interop contract the -// vendored loader depends on. -const FAKE_RSTACK = ` -const getSession = () => globalThis.__rstackConfigSessionStorage?.getStore(); - -const setConfig = (type, config) => { - const session = getSession(); - if (!session?.active) { - throw new Error('The "' + type + '" config must be defined while loading an Rstack config.'); - } - session.configs[type] = config; -}; - -export const define = { - lint: (config) => setConfig('lint', config), - test: (config) => setConfig('test', config), -}; -`; - -describe('vendored loadRstackConfig', () => { - let dir: string; - - beforeAll(() => { - dir = mkdtempSync(path.join(tmpdir(), 'rstack-config-')); - writeFileSync(path.join(dir, 'fake-rstack.mjs'), FAKE_RSTACK); - writeFileSync( - path.join(dir, 'rstack.config.mjs'), - [ - "import { define } from './fake-rstack.mjs';", - "define.lint([{ name: 'from-rstack-config' }]);", - "define.test({ name: 'test-project' });", - ].join('\n'), - ); - }); - - afterAll(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it('collects define.* calls made by a foreign module instance', async () => { - const configFilePath = path.join(dir, 'rstack.config.mjs'); - const { configs, filePath } = await loadRstackConfig({ configFilePath }); - - expect(filePath).toBe(configFilePath); - expect(configs.lint).toEqual([{ name: 'from-rstack-config' }]); - expect(configs.test).toEqual({ name: 'test-project' }); - }); - - it('probes a directory when no config path is given', async () => { - const { filePath } = await loadRstackConfig({ cwd: dir }); - expect(filePath).toBe(path.join(dir, 'rstack.config.mjs')); - }); - - it('reports "no stacks defined" for a directory without a config', async () => { - const empty = mkdtempSync(path.join(tmpdir(), 'rstack-empty-')); - try { - const { configs, filePath } = await loadRstackConfig({ cwd: empty }); - expect(filePath).toBeNull(); - expect(configs).toEqual({}); - } finally { - rmSync(empty, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/vscode/tests/migration.test.ts b/packages/vscode/tests/migration.test.ts index ef44e0e..4f7fc05 100644 --- a/packages/vscode/tests/migration.test.ts +++ b/packages/vscode/tests/migration.test.ts @@ -43,12 +43,10 @@ const folderReading = ( describe('LEGACY_MAPPINGS', () => { it('covers the full legacy inventory of both retired extensions', () => { - // 4 settings from rslint/packages/vscode-extension + 14 from - // rstest/packages/vscode, verified against their manifests. + // The two Rslint binary settings have no valid core-directory equivalent; + // the remaining 2 Rslint settings and all 14 Rstest settings are mapped. expect(LEGACY_MAPPINGS.map((mapping) => mapping.from)).toEqual([ 'rslint.enable', - 'rslint.binPath', - 'rslint.customBinPath', 'rslint.trace.server', 'rstest.nodeExecutable', 'rstest.rstestPackagePath', @@ -102,11 +100,9 @@ describe('LEGACY_MAPPINGS', () => { ]); }); - it('only rewrites the value of rslint.binPath', () => { + it('does not rewrite any migrated value', () => { const rewriting = LEGACY_MAPPINGS.filter((mapping) => mapping.mapValue); - expect(rewriting.map((mapping) => mapping.from)).toEqual([ - 'rslint.binPath', - ]); + expect(rewriting).toEqual([]); }); }); @@ -157,63 +153,10 @@ describe('planMigration — mechanical renames', () => { }); }); -describe('planMigration — rslint.binPath', () => { - it('maps an explicit built-in to local', () => { - const plan = planMigration([ - reading({ key: 'rslint.binPath', value: 'built-in' }), - ]); - expect(plan.scopes[0]?.writes[0]).toMatchObject({ - to: 'rstack.rslint.binPath', - fromValue: 'built-in', - value: 'local', - rewritten: true, - }); - }); - - it('carries local and custom over unchanged', () => { - for (const value of ['local', 'custom']) { - const plan = planMigration([reading({ key: 'rslint.binPath', value })]); - expect(plan.scopes[0]?.writes[0]).toMatchObject({ - value, - rewritten: false, - }); - } - }); - - it('carries the custom path over next to the mode', () => { - const plan = planMigration([ - reading({ key: 'rslint.binPath', value: 'custom' }), - reading({ key: 'rslint.customBinPath', value: '/opt/rslint' }), - ]); - expect( - plan.scopes[0]?.writes.map((write) => [write.to, write.value]), - ).toEqual([ - ['rstack.rslint.binPath', 'custom'], - ['rstack.rslint.customBinPath', '/opt/rslint'], - ]); - }); - - it('never invents a value when the setting was not set', () => { - // The dropped `built-in` default must not leak in as an explicit `local`. - expect(planMigration([]).writeCount).toBe(0); - }); - - it('skips values that are not in the new enum', () => { - for (const value of ['auto', '', 42, null]) { - const plan = planMigration([reading({ key: 'rslint.binPath', value })]); - expect(plan.writeCount).toBe(0); - expect(plan.skips[0]).toMatchObject({ - from: 'rslint.binPath', - reason: 'unsupported-value', - }); - } - }); -}); - describe('planMigration — layers', () => { it('groups writes per layer and orders them user, workspace, folder', () => { const plan = planMigration([ - folderReading('file:///w/app', 'app', 'rslint.customBinPath', '/x/bin'), + folderReading('file:///w/app', 'app', 'rstest.rstestPackagePath', '/x'), reading({ scopeId: 'workspace', layer: 'workspace', @@ -232,16 +175,16 @@ describe('planMigration — layers', () => { it('keeps same-named folders of a multi-root workspace apart', () => { const plan = planMigration([ - folderReading('file:///a/app', 'app', 'rslint.customBinPath', '/a/bin'), - folderReading('file:///b/app', 'app', 'rslint.customBinPath', '/b/bin'), + folderReading('file:///a/app', 'app', 'rstest.rstestPackagePath', '/a'), + folderReading('file:///b/app', 'app', 'rstest.rstestPackagePath', '/b'), ]); expect(plan.scopes.map((scope) => scope.scopeId)).toEqual([ 'file:///a/app', 'file:///b/app', ]); expect(plan.scopes.map((scope) => scope.writes[0]?.value)).toEqual([ - '/a/bin', - '/b/bin', + '/a', + '/b', ]); }); @@ -303,15 +246,15 @@ describe('planMigration — conflicts', () => { it('never overwrites a new key the user already set in the same layer', () => { const plan = planMigration([ reading({ - key: 'rslint.binPath', - value: 'built-in', - targetValue: 'custom', + key: 'rslint.trace.server', + value: 'messages', + targetValue: 'verbose', }), ]); expect(plan.writeCount).toBe(0); expect(plan.skips[0]).toMatchObject({ - from: 'rslint.binPath', - to: 'rstack.rslint.binPath', + from: 'rslint.trace.server', + to: 'rstack.rslint.trace.server', reason: 'target-already-set', }); }); @@ -350,28 +293,27 @@ describe('formatPreview', () => { it('shows the old -> new mapping under its layer heading', () => { const preview = formatPreview( planMigration([ - reading({ key: 'rslint.binPath', value: 'built-in' }), - folderReading('file:///w/app', 'app', 'rslint.customBinPath', '/x'), + reading({ key: 'rslint.trace.server', value: 'messages' }), + folderReading('file:///w/app', 'app', 'rstest.rstestPackagePath', '/x'), ]), ); expect(preview).toContain('User Settings'); - expect(preview).toContain('rslint.binPath -> rstack.rslint.binPath'); - expect(preview).toContain('"built-in" -> "local"'); + expect(preview).toContain( + 'rslint.trace.server -> rstack.rslint.trace.server', + ); expect(preview).toContain('Folder Settings — app'); expect(preview).toContain( - 'rslint.customBinPath -> rstack.rslint.customBinPath', + 'rstest.rstestPackagePath -> rstack.rstest.rstestPackagePath', ); }); it('lists what was left untouched and why', () => { const preview = formatPreview( planMigration([ - reading({ key: 'rslint.binPath', value: 'auto' }), folderReading('file:///w/app', 'app', 'rstest.applyDiagnostic', false), ]), ); expect(preview).toContain('Left untouched'); - expect(preview).toContain('is not a valid value of rstack.rslint.binPath'); expect(preview).toContain('window-scoped setting'); }); diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts new file mode 100644 index 0000000..b09fc85 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from '@rstest/core'; +import { + resolveRslint, + RslintResolutionError, +} from '../../../src/stacks/lint/resolution'; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'rslint-resolution-'), + ); + temporaryDirectories.push(directory); + return directory; +} + +function writePackage( + directory: string, + name: 'rstack' | '@rslint/core', + version: string, +): void { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ name, version }), + ); +} + +function installPackage( + root: string, + name: 'rstack' | '@rslint/core', + version: string, +): string { + const directory = path.join(root, 'node_modules', ...name.split('/')); + writePackage(directory, name, version); + return fs.realpathSync(directory); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('resolveRslint', () => { + it('resolves a native folder directly from its @rslint/core installation', () => { + const root = temporaryDirectory(); + const coreDir = installPackage(root, '@rslint/core', '0.8.0'); + + expect(resolveRslint({ folderRoot: root, mode: 'native' })).toEqual({ + mode: 'native', + coreDir, + coreVersion: '0.8.0', + }); + }); + + it('follows the rstack dependency chain for a bridged folder', () => { + const root = temporaryDirectory(); + const rstackDir = installPackage(root, 'rstack', '0.6.1'); + const coreDir = installPackage(rstackDir, '@rslint/core', '0.8.0'); + const shimPath = path.join(rstackDir, 'dist', 'rslintConfig.js'); + fs.mkdirSync(path.dirname(shimPath)); + fs.writeFileSync(shimPath, 'export default [];'); + + expect(resolveRslint({ folderRoot: root, mode: 'bridged' })).toEqual({ + mode: 'bridged', + coreDir, + coreVersion: '0.8.0', + rstackDir, + rstackVersion: '0.6.1', + shimPath, + }); + }); + + it('uses corePath for the core hop in both modes', () => { + const root = temporaryDirectory(); + const rstackDir = installPackage(root, 'rstack', '0.6.1'); + const shimPath = path.join(rstackDir, 'dist', 'rslintConfig.js'); + fs.mkdirSync(path.dirname(shimPath)); + fs.writeFileSync(shimPath, 'export default [];'); + const customCorePath = path.join(root, 'custom-core'); + writePackage(customCorePath, '@rslint/core', '0.8.1'); + const customCore = fs.realpathSync(customCorePath); + + for (const mode of ['native', 'bridged'] as const) { + const resolution = resolveRslint({ + folderRoot: root, + mode, + corePath: './custom-core', + }); + expect(resolution.coreDir).toBe(customCore); + expect(resolution.coreVersion).toBe('0.8.1'); + } + }); + + it('reports a missing rstack shim before resolving its core', () => { + const root = temporaryDirectory(); + installPackage(root, 'rstack', '0.6.1'); + + expect(() => resolveRslint({ folderRoot: root, mode: 'bridged' })).toThrow( + RslintResolutionError, + ); + try { + resolveRslint({ folderRoot: root, mode: 'bridged' }); + } catch (error) { + expect(error).toMatchObject({ code: 'missing-shim' }); + } + }); +}); diff --git a/packages/vscode/tests/stacks/lint/status.test.ts b/packages/vscode/tests/stacks/lint/status.test.ts new file mode 100644 index 0000000..d8a5c25 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/status.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from '@rstest/core'; +import { RslintResolutionError } from '../../../src/stacks/lint/resolution'; +import { + RslintVersionMismatchError, + runningRslintStatus, + statusForRslintStartFailure, +} from '../../../src/stacks/lint/status'; + +describe('Rslint status classification', () => { + it('disables a bridged folder whose rstack package is missing', () => { + expect( + statusForRslintStartFailure( + new RslintResolutionError('missing-rstack', 'missing rstack'), + ), + ).toMatchObject({ kind: 'disabled' }); + }); + + it('classifies missing core and worker failures as crashes', () => { + expect( + statusForRslintStartFailure( + new RslintResolutionError('missing-core', 'missing core'), + ), + ).toEqual({ kind: 'crashed', detail: 'missing core' }); + expect(statusForRslintStartFailure(new Error('worker stopped'))).toEqual({ + kind: 'crashed', + detail: 'worker stopped', + }); + }); + + it('classifies package and automatic Node floors as version mismatches', () => { + expect( + statusForRslintStartFailure( + new RslintVersionMismatchError( + '@rslint/core 0.7.3 is not supported, this extension requires >=0.8.0', + ), + ), + ).toEqual({ + kind: 'version-mismatch', + detail: + '@rslint/core 0.7.3 is not supported, this extension requires >=0.8.0', + }); + }); + + it('surfaces a configured Node advisory without stopping the worker', () => { + expect(runningRslintStatus()).toEqual({ kind: 'running' }); + expect(runningRslintStatus('Node 22.17 is below the floor')).toEqual({ + kind: 'version-mismatch', + detail: 'Node 22.17 is below the floor', + }); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts new file mode 100644 index 0000000..85128ac --- /dev/null +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -0,0 +1,187 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from '@rstest/core'; +import { createMessageConnection, NullLogger } from 'vscode-jsonrpc/node'; +import { + LINT_WORKER_USAGE, + parseWorkerArgs, + stampConfigRefresh, +} from '../../../src/stacks/lint/worker/cli'; +import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; + +const fakeGoSource = String.raw` +let buffer = Buffer.alloc(0); + +function send(message) { + const body = JSON.stringify(message); + process.stdout.write( + 'Content-Length: ' + Buffer.byteLength(body) + '\r\n\r\n' + body, + ); +} + +function handle(message) { + if (message.method === 'exit') { + process.exit(0); + } + if (message.id === undefined) return; + const hasParams = Object.prototype.hasOwnProperty.call(message, 'params'); + send({ + jsonrpc: '2.0', + id: message.id, + result: { + method: message.method, + hasParams, + ...(hasParams ? { params: message.params } : {}), + }, + }); +} + +function readMessages() { + for (;;) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = buffer.subarray(0, headerEnd).toString('ascii'); + const match = /Content-Length: (\d+)/i.exec(header); + if (!match) process.exit(2); + const contentLength = Number(match[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + contentLength; + if (buffer.length < bodyEnd) return; + const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8'); + buffer = buffer.subarray(bodyEnd); + handle(JSON.parse(body)); + } +} + +process.stdin.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + readMessages(); +}); +`; + +describe('lint worker CLI', () => { + it('accepts only absolute core and optional config paths', () => { + const coreDir = path.resolve('/project/node_modules/@rslint/core'); + const configPath = path.resolve( + '/project/node_modules/rstack/dist/config.js', + ); + expect(parseWorkerArgs(['--lsp', '--core', coreDir])).toEqual({ coreDir }); + expect( + parseWorkerArgs(['--lsp', '--core', coreDir, '--config', configPath]), + ).toEqual({ coreDir, configPath }); + }); + + it('rejects unknown, missing and relative arguments', () => { + for (const args of [ + [], + ['--core', '/core', '--lsp'], + ['--lsp', '--core', './core'], + ['--lsp', '--core', '/core', '--other', '/config'], + ]) { + expect(() => parseWorkerArgs(args)).toThrow(); + } + expect(LINT_WORKER_USAGE).toContain('--lsp --core'); + }); +}); + +describe('lint worker config refresh', () => { + it('stamps the core protocol and pins the explicit shim on every refresh', () => { + const configPath = path.resolve('/project/rslintConfig.js'); + for (const reason of ['initial', 'config-change', 'dependency-change']) { + expect(stampConfigRefresh({ reason }, 2, configPath)).toEqual({ + protocolVersion: 2, + reason, + configPath, + }); + } + }); + + it('leaves native discovery without an explicit config path', () => { + expect(stampConfigRefresh({ reason: 'initial' }, 2)).toEqual({ + protocolVersion: 2, + reason: 'initial', + }); + }); + + it('stamps requests sent to Go and preserves parameterless messages', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-worker-go-')); + const stubPath = path.join(directory, 'fake-go.cjs'); + fs.writeFileSync(stubPath, fakeGoSource); + const child = spawn(process.execPath, [stubPath], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + const childClosed = new Promise((resolve) => { + child.once('close', resolve); + }); + const editorToWorker = new PassThrough(); + const workerToEditor = new PassThrough(); + const editorConnection = createMessageConnection( + workerToEditor, + editorToWorker, + NullLogger, + ); + const workerConnection = createMessageConnection( + editorToWorker, + workerToEditor, + NullLogger, + ); + const goConnection = createMessageConnection( + child.stdout, + child.stdin, + NullLogger, + ); + const configPath = path.resolve('/project/rslintConfig.js'); + const observedReasons: unknown[] = []; + + try { + registerEditorProxy(workerConnection, goConnection, { + protocolVersion: 2, + configPath, + observeRefresh: (reason) => observedReasons.push(reason), + requestStop: () => undefined, + }); + goConnection.listen(); + workerConnection.listen(); + editorConnection.listen(); + + const refresh = await editorConnection.sendRequest<{ + readonly method: string; + readonly hasParams: boolean; + readonly params: Record; + }>('rslint/configRefresh', { reason: 'config-change' }); + expect(refresh).toEqual({ + method: 'rslint/configRefresh', + hasParams: true, + params: { + protocolVersion: 2, + reason: 'config-change', + configPath, + }, + }); + expect(observedReasons).toEqual(['config-change']); + + const shutdown = await editorConnection.sendRequest<{ + readonly method: string; + readonly hasParams: boolean; + }>('shutdown'); + expect(shutdown).toEqual({ method: 'shutdown', hasParams: false }); + + await editorConnection.sendNotification('exit'); + expect(await childClosed).toBe(0); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await childClosed; + } + editorConnection.dispose(); + workerConnection.dispose(); + goConnection.dispose(); + editorToWorker.destroy(); + workerToEditor.destroy(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/vscode/tests/stacks/test/bridge.test.ts b/packages/vscode/tests/stacks/test/bridge.test.ts index f37ac26..b03b582 100644 --- a/packages/vscode/tests/stacks/test/bridge.test.ts +++ b/packages/vscode/tests/stacks/test/bridge.test.ts @@ -53,7 +53,7 @@ const makeTmpDir = (): string => { * `dist/rstestConfig.js`. */ const createWorkspace = ({ - version = '0.5.2', + version = '0.6.1', shim = true, }: { version?: string | null; shim?: boolean } = {}): string => { const root = makeTmpDir(); @@ -103,7 +103,7 @@ describe('resolveRstackShim', () => { const shim = resolveRstackShim(configDir); expect(shim).toBeDefined(); - expect(shim?.version).toBe('0.5.2'); + expect(shim?.version).toBe('0.6.1'); // The same file `rs test` injects with `--config`. expect(shim?.configFilePath).toBe( path.join(configDir, 'node_modules', 'rstack', 'dist', 'rstestConfig.js'), @@ -134,14 +134,14 @@ describe('resolveRstackShim', () => { }); it('refuses an rstack older than the support matrix floor', () => { - const configDir = createWorkspace({ version: '0.5.1' }); + const configDir = createWorkspace({ version: '0.6.0' }); expect(resolveRstackShim(configDir)).toBeUndefined(); expect(reported).toEqual([ { kind: 'version-mismatch', detail: - 'rstack 0.5.1 is not supported, this extension requires >=0.5.2', + 'rstack 0.6.0 is not supported, this extension requires >=0.6.1', }, ]); }); diff --git a/packages/vscode/tests/versionCheck.test.ts b/packages/vscode/tests/versionCheck.test.ts index a350134..4db18ff 100644 --- a/packages/vscode/tests/versionCheck.test.ts +++ b/packages/vscode/tests/versionCheck.test.ts @@ -3,7 +3,6 @@ import { checkPackageVersion, checkVersion, formatVersionMismatch, - isSupportedConfigDiscoveryProtocolVersion, NODE_RUNTIME_RANGE, SUPPORT_MATRIX, } from '../src/shared/versionCheck'; @@ -11,19 +10,19 @@ import { describe('support matrix', () => { it('pins the launch support floors', () => { expect(SUPPORT_MATRIX).toEqual({ - '@rslint/core': '>=0.7.2', + '@rslint/core': '>=0.8.0', '@rstest/core': '>=0.6.0', - rstack: '>=0.5.2', + rstack: '>=0.6.1', }); }); }); describe('checkPackageVersion', () => { it('accepts versions at and above the floor', () => { - expect(checkPackageVersion('@rslint/core', '0.7.2').kind).toBe('ok'); + expect(checkPackageVersion('@rslint/core', '0.8.0').kind).toBe('ok'); expect(checkPackageVersion('@rslint/core', '1.2.3').kind).toBe('ok'); expect(checkPackageVersion('@rstest/core', '0.11.5').kind).toBe('ok'); - expect(checkPackageVersion('rstack', '0.5.2').kind).toBe('ok'); + expect(checkPackageVersion('rstack', '0.6.1').kind).toBe('ok'); }); it('accepts prereleases of a supported range', () => { @@ -54,10 +53,3 @@ describe('node runtime range', () => { expect(checkVersion('23.6.0', NODE_RUNTIME_RANGE).kind).toBe('ok'); }); }); - -describe('config discovery protocol', () => { - it('supports exactly the protocol versions the copied client speaks', () => { - expect(isSupportedConfigDiscoveryProtocolVersion(1)).toBe(true); - expect(isSupportedConfigDiscoveryProtocolVersion(2)).toBe(false); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60efde3..1bfa8d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^1.0.0-beta.1 version: 1.0.0-beta.1(typescript@5.9.3) '@rslint/core': - specifier: ^0.7.2 - version: 0.7.2(jiti@2.7.0) + specifier: ^0.8.0 + version: 0.8.0(jiti@2.7.0) '@rstackjs/load-config': specifier: ^0.1.2 version: 0.1.2(jiti@2.7.0) @@ -49,10 +49,10 @@ importers: version: 1.97.0 '@vscode/test-electron': specifier: ^3.1.0 - version: 3.1.0 + version: 3.1.0(supports-color@8.1.1) '@vscode/vsce': specifier: ^3.9.2 - version: 3.9.2 + version: 3.9.2(supports-color@8.1.1) birpc: specifier: ^4.0.0 version: 4.0.0 @@ -67,7 +67,7 @@ importers: version: 11.8.0 ovsx: specifier: ^1.0.2 - version: 1.1.0(@types/node@22.20.1) + version: 1.1.0(@types/node@22.20.1)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) picomatch: specifier: ^4.0.5 version: 4.0.5 @@ -86,6 +86,9 @@ importers: valibot: specifier: ^1.4.2 version: 1.4.2(typescript@5.9.3) + vscode-jsonrpc: + specifier: ^8.2.0 + version: 8.2.0 vscode-languageclient: specifier: ^9.0.1 version: 9.0.1 @@ -600,50 +603,103 @@ packages: jiti: optional: true + '@rslint/core@0.8.0': + resolution: {integrity: sha512-MfMC6lxiXoKPWsYRu9fuAxWA9mCD/I4E5Aa6Sl20a6q5w8r2C12fJM+bceC01AMGYuvWygKHqS3QBJdJHjniRw==} + hasBin: true + peerDependencies: + jiti: ^2.7.0 + peerDependenciesMeta: + jiti: + optional: true + '@rslint/native-darwin-arm64@0.7.2': resolution: {integrity: sha512-Q7Nx26S7O1zlELKNIyi3+ZBn6s+ZrGFmyMkqWT2UsXsq9jW3sUGJG44/BcvAFXtFYg7ONUl7LDYakz/VP7DzXQ==} cpu: [arm64] os: [darwin] + '@rslint/native-darwin-arm64@0.8.0': + resolution: {integrity: sha512-Bo6kXL1/TkjVUl6maZ3Sw+JEnZUkgpUD35v06jyBTcjpxlXE6yqFj66NAzB/G2CVu220ADp/FEE7l2Kocrefhg==} + cpu: [arm64] + os: [darwin] + '@rslint/native-darwin-x64@0.7.2': resolution: {integrity: sha512-ONbEKiPd/StrV+/enPMJz60/+oJCiuVK9cbMpymWjAv1qDNCiuTNIqb5RUc4OHxWy7QZ9LWbVw4X/5XcJf0ebQ==} cpu: [x64] os: [darwin] + '@rslint/native-darwin-x64@0.8.0': + resolution: {integrity: sha512-F5pabdH7dluxoj7PGuQjPYuoNU+yxnctcJzqrb/CZ122FLP3uJPrbiLufh+ZF9e5ui700rNyb7macJqnHlBV2w==} + cpu: [x64] + os: [darwin] + '@rslint/native-linux-arm64-gnu@0.7.2': resolution: {integrity: sha512-06C0QJF6gJ/VkLPBw6+SauH91PnUM83Kd7tBIqU5QP11q3iIK+aPFGMbSrKsKu6/+yVig424Z4nSxcQ2MzCmag==} cpu: [arm64] os: [linux] libc: [glibc] + '@rslint/native-linux-arm64-gnu@0.8.0': + resolution: {integrity: sha512-SSgSjyaeXI032Wk8bq6VmkLQTWOEqHZH5MWAk3831pd/G2rWr2XcoDi5Wf6w0o2rSCeYzkYbIejOePYwIKT4Lg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rslint/native-linux-arm64-musl@0.7.2': resolution: {integrity: sha512-KpgwL3sgRVNx3LciBcfmRxxIymuQKBo3vinEewHWdll+WkRlS08Ow1XhSu2YIrenOYQ5cKSD26PW57AUE8s2zA==} cpu: [arm64] os: [linux] libc: [musl] + '@rslint/native-linux-arm64-musl@0.8.0': + resolution: {integrity: sha512-50VDZQFAc9kp6rbOUOjyppVjC3d3AdbOJyA6JxlhK3mp8a/8sPIqWG0BlfcN/7ZpwdZrFsQt2Ds+0yMXITfu5A==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rslint/native-linux-x64-gnu@0.7.2': resolution: {integrity: sha512-TOTVGJvFW2uxMthV3g05HNik0BWUE8gabZp5mYiDF4dc+yFihqWw1kRO//4hZyd4m0CPkRpsBL89UCwAX6lBzA==} cpu: [x64] os: [linux] libc: [glibc] + '@rslint/native-linux-x64-gnu@0.8.0': + resolution: {integrity: sha512-wfc/UfnuTBAofwLPK5MLB2Hus1YYnsxP2MVchp380fUvMfQuGFPzz1WOAUY66zqrsrDKpUJsh3V1bx7c8DTlJA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rslint/native-linux-x64-musl@0.7.2': resolution: {integrity: sha512-rn4g1i8VVZeVnc/Qa1IJVvX0e4XQncB144CTwHeFnC2V8aMBL6kmfvBox2mL59nPRTlILf4GAMOMlD3tSD5N5Q==} cpu: [x64] os: [linux] libc: [musl] + '@rslint/native-linux-x64-musl@0.8.0': + resolution: {integrity: sha512-MTYcAMz6IZWb6ZmLo12pakCBA8mS3EW9cOI0jd3At6vs0Yia9YbeOAUiWbIC4Z9yyp75b8ZQCQMRSnY6rDnbaA==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rslint/native-win32-arm64-msvc@0.7.2': resolution: {integrity: sha512-j2kdE1+3TdXhjtmu+b9lWJThSaT3SZKcMa5dlEy20+bDwTCBmuhO6lR79/4BllXz8/avP8W0YmkfORitoVPxrA==} cpu: [arm64] os: [win32] + '@rslint/native-win32-arm64-msvc@0.8.0': + resolution: {integrity: sha512-hids2jgNWBxZf2mSBLZJxubWRLWlJapEfeJHVokzjpRpBZZNv7O06enoyXSb4Lswff9WaHssIqu1sfZIc9lC2g==} + cpu: [arm64] + os: [win32] + '@rslint/native-win32-x64-msvc@0.7.2': resolution: {integrity: sha512-qOXNWTn4Q9gf6/GCmJlJt5heVD+WIdbVSLRb2KbJLt055ZuVQV40Md8NkUSWF94j/J9+1d21/UoOvKDNt144fg==} cpu: [x64] os: [win32] + '@rslint/native-win32-x64-msvc@0.8.0': + resolution: {integrity: sha512-bun7uURKl6NdChwmw/i2mk3Yj9klZQyh1uRbIQG0RDEJ9oTIbU5M3c7K5sd/Rukat0TVCGgbBAzR+YB0DAXPZQ==} + cpu: [x64] + os: [win32] + '@rspack/binding-darwin-arm64@2.1.7': resolution: {integrity: sha512-DwxzrXRctueP/3Pyom9JHcIsRShuEAlHb+mrE5OPT+4cdHI1UnJpbzEvEDLTo4IKJhDb3vjXdHLtjqtL0SYbeA==} cpu: [arm64] @@ -2398,34 +2454,34 @@ snapshots: dependencies: tslib: 2.8.1 - '@azure/core-auth@1.11.0': + '@azure/core-auth@1.11.0(supports-color@8.1.1)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-util': 1.14.0 + '@azure/core-util': 1.14.0(supports-color@8.1.1) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/core-client@1.11.0': + '@azure/core-client@1.11.0(supports-color@8.1.1)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-auth': 1.11.0(supports-color@8.1.1) + '@azure/core-rest-pipeline': 1.25.0(supports-color@8.1.1) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@8.1.1) + '@azure/logger': 1.4.0(supports-color@8.1.1) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/core-rest-pipeline@1.25.0': + '@azure/core-rest-pipeline@1.25.0(supports-color@8.1.1)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 + '@azure/core-auth': 1.11.0(supports-color@8.1.1) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 - '@typespec/ts-http-runtime': 0.3.8 + '@azure/core-util': 1.14.0(supports-color@8.1.1) + '@azure/logger': 1.4.0(supports-color@8.1.1) + '@typespec/ts-http-runtime': 0.3.8(supports-color@8.1.1) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -2434,23 +2490,23 @@ snapshots: dependencies: tslib: 2.8.1 - '@azure/core-util@1.14.0': + '@azure/core-util@1.14.0(supports-color@8.1.1)': dependencies: '@azure/abort-controller': 2.2.0 - '@typespec/ts-http-runtime': 0.3.8 + '@typespec/ts-http-runtime': 0.3.8(supports-color@8.1.1) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/identity@4.13.1': + '@azure/identity@4.13.1(supports-color@8.1.1)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-auth': 1.11.0(supports-color@8.1.1) + '@azure/core-client': 1.11.0(supports-color@8.1.1) + '@azure/core-rest-pipeline': 1.25.0(supports-color@8.1.1) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@8.1.1) + '@azure/logger': 1.4.0(supports-color@8.1.1) '@azure/msal-browser': 5.17.3 '@azure/msal-node': 5.4.3 open: 10.2.0 @@ -2458,9 +2514,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/logger@1.4.0': + '@azure/logger@1.4.0(supports-color@8.1.1)': dependencies: - '@typespec/ts-http-runtime': 0.3.8 + '@typespec/ts-http-runtime': 0.3.8(supports-color@8.1.1) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -2823,30 +2879,68 @@ snapshots: '@rslint/native-win32-x64-msvc': 0.7.2 jiti: 2.7.0 + '@rslint/core@0.8.0(jiti@2.7.0)': + dependencies: + picomatch: 4.0.5 + optionalDependencies: + '@rslint/native-darwin-arm64': 0.8.0 + '@rslint/native-darwin-x64': 0.8.0 + '@rslint/native-linux-arm64-gnu': 0.8.0 + '@rslint/native-linux-arm64-musl': 0.8.0 + '@rslint/native-linux-x64-gnu': 0.8.0 + '@rslint/native-linux-x64-musl': 0.8.0 + '@rslint/native-win32-arm64-msvc': 0.8.0 + '@rslint/native-win32-x64-msvc': 0.8.0 + jiti: 2.7.0 + '@rslint/native-darwin-arm64@0.7.2': optional: true + '@rslint/native-darwin-arm64@0.8.0': + optional: true + '@rslint/native-darwin-x64@0.7.2': optional: true + '@rslint/native-darwin-x64@0.8.0': + optional: true + '@rslint/native-linux-arm64-gnu@0.7.2': optional: true + '@rslint/native-linux-arm64-gnu@0.8.0': + optional: true + '@rslint/native-linux-arm64-musl@0.7.2': optional: true + '@rslint/native-linux-arm64-musl@0.8.0': + optional: true + '@rslint/native-linux-x64-gnu@0.7.2': optional: true + '@rslint/native-linux-x64-gnu@0.8.0': + optional: true + '@rslint/native-linux-x64-musl@0.7.2': optional: true + '@rslint/native-linux-x64-musl@0.8.0': + optional: true + '@rslint/native-win32-arm64-msvc@0.7.2': optional: true + '@rslint/native-win32-arm64-msvc@0.8.0': + optional: true + '@rslint/native-win32-x64-msvc@0.7.2': optional: true + '@rslint/native-win32-x64-msvc@0.8.0': + optional: true + '@rspack/binding-darwin-arm64@2.1.7': optional: true @@ -2924,18 +3018,18 @@ snapshots: dependencies: '@secretlint/types': 10.2.2 - '@secretlint/config-loader@10.2.2': + '@secretlint/config-loader@10.2.2(supports-color@8.1.1)': dependencies: '@secretlint/profiler': 10.2.2 '@secretlint/resolver': 10.2.2 '@secretlint/types': 10.2.2 ajv: 8.20.0 debug: 4.4.3(supports-color@8.1.1) - rc-config-loader: 4.1.4 + rc-config-loader: 4.1.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@secretlint/core@10.2.2': + '@secretlint/core@10.2.2(supports-color@8.1.1)': dependencies: '@secretlint/profiler': 10.2.2 '@secretlint/types': 10.2.2 @@ -2944,11 +3038,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@secretlint/formatter@10.2.2': + '@secretlint/formatter@10.2.2(supports-color@8.1.1)': dependencies: '@secretlint/resolver': 10.2.2 '@secretlint/types': 10.2.2 - '@textlint/linter-formatter': 15.8.0 + '@textlint/linter-formatter': 15.8.0(supports-color@8.1.1) '@textlint/module-interop': 15.8.0 '@textlint/types': 15.8.0 chalk: 5.6.2 @@ -2960,11 +3054,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@secretlint/node@10.2.2': + '@secretlint/node@10.2.2(supports-color@8.1.1)': dependencies: - '@secretlint/config-loader': 10.2.2 - '@secretlint/core': 10.2.2 - '@secretlint/formatter': 10.2.2 + '@secretlint/config-loader': 10.2.2(supports-color@8.1.1) + '@secretlint/core': 10.2.2(supports-color@8.1.1) + '@secretlint/formatter': 10.2.2(supports-color@8.1.1) '@secretlint/profiler': 10.2.2 '@secretlint/source-creator': 10.2.2 '@secretlint/types': 10.2.2 @@ -3002,7 +3096,7 @@ snapshots: '@textlint/ast-node-types@15.8.0': {} - '@textlint/linter-formatter@15.8.0': + '@textlint/linter-formatter@15.8.0(supports-color@8.1.1)': dependencies: '@azu/format-text': 1.0.2 '@azu/style-format': 1.0.1 @@ -3062,18 +3156,18 @@ snapshots: '@types/vscode@1.97.0': {} - '@typespec/ts-http-runtime@0.3.8': + '@typespec/ts-http-runtime@0.3.8(supports-color@8.1.1)': dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@8.1.1) + https-proxy-agent: 7.0.6(supports-color@8.1.1) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@vscode/test-electron@3.1.0': + '@vscode/test-electron@3.1.0(supports-color@8.1.1)': dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@8.1.1) + https-proxy-agent: 7.0.6(supports-color@8.1.1) jszip: 3.10.1 ora: 8.2.0 semver: 7.8.5 @@ -3119,10 +3213,10 @@ snapshots: '@vscode/vsce-sign-win32-arm64': 2.0.6 '@vscode/vsce-sign-win32-x64': 2.0.6 - '@vscode/vsce@3.9.2': + '@vscode/vsce@3.9.2(supports-color@8.1.1)': dependencies: - '@azure/identity': 4.13.1 - '@secretlint/node': 10.2.2 + '@azure/identity': 4.13.1(supports-color@8.1.1) + '@secretlint/node': 10.2.2(supports-color@8.1.1) '@secretlint/secretlint-formatter-sarif': 10.2.2 '@secretlint/secretlint-rule-no-dotenv': 10.2.2 '@secretlint/secretlint-rule-preset-recommend': 10.2.2 @@ -3142,7 +3236,7 @@ snapshots: minimatch: 10.2.6 parse-semver: 1.1.1 read: 1.0.7 - secretlint: 10.2.2 + secretlint: 10.2.2(supports-color@8.1.1) semver: 7.8.5 tmp: 0.2.7 typed-rest-client: 1.8.11 @@ -3552,7 +3646,9 @@ snapshots: flat@5.0.2: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@8.1.1)): + optionalDependencies: + debug: 4.4.3(supports-color@8.1.1) foreground-child@3.3.1: dependencies: @@ -3673,14 +3769,14 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@8.1.1): dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@8.1.1): dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@8.1.1) @@ -4016,13 +4112,13 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ovsx@1.1.0(@types/node@22.20.1): + ovsx@1.1.0(@types/node@22.20.1)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@inquirer/prompts': 7.10.1(@types/node@22.20.1) - '@vscode/vsce': 3.9.2 + '@vscode/vsce': 3.9.2(supports-color@8.1.1) commander: 6.2.1 cross-keychain: 1.1.0(@types/node@22.20.1) - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1)) is-ci: 2.0.0 leven: 3.1.0 semver: 7.8.5 @@ -4139,7 +4235,7 @@ snapshots: dependencies: safe-buffer: 5.2.1 - rc-config-loader@4.1.4: + rc-config-loader@4.1.4(supports-color@8.1.1): dependencies: debug: 4.4.3(supports-color@8.1.1) js-yaml: 4.3.1 @@ -4237,11 +4333,11 @@ snapshots: sax@1.6.1: {} - secretlint@10.2.2: + secretlint@10.2.2(supports-color@8.1.1): dependencies: '@secretlint/config-creator': 10.2.2 - '@secretlint/formatter': 10.2.2 - '@secretlint/node': 10.2.2 + '@secretlint/formatter': 10.2.2(supports-color@8.1.1) + '@secretlint/node': 10.2.2(supports-color@8.1.1) '@secretlint/profiler': 10.2.2 debug: 4.4.3(supports-color@8.1.1) globby: 14.1.0 From 1344b99ece6f7e0d13e1d9703282e405cabc46e4 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 18 Aug 2026 14:22:44 +0800 Subject: [PATCH 2/4] feat(vscode): migrate rslint.corePath and surface dropped Rslint binary settings --- packages/vscode/src/migration.ts | 89 +++++++++++++++++-------- packages/vscode/tests/migration.test.ts | 45 ++++++++++++- 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/packages/vscode/src/migration.ts b/packages/vscode/src/migration.ts index f12a59c..3b86c70 100644 --- a/packages/vscode/src/migration.ts +++ b/packages/vscode/src/migration.ts @@ -45,7 +45,9 @@ export type SkipReason = /** The new key already has an explicit value in the same layer. */ | 'target-already-set' /** A folder-layer value for a window-scoped target setting. */ - | 'not-folder-scoped'; + | 'not-folder-scoped' + /** The legacy setting represented a feature that no longer exists. */ + | 'no-equivalent-setting'; type ValueMapping = | { readonly kind: 'value'; readonly value: unknown } @@ -98,10 +100,8 @@ const RSTEST_KEYS: readonly (readonly [string, 'resource' | 'window'])[] = [ ]; /** - * The migratable legacy inventory: 2 Rslint keys + 14 Rstest keys. The old - * Rslint binary settings are intentionally absent: a binary path cannot be - * translated into the `@rslint/core` package directory the worker requires. - * Kept in one table so the preview, writer and tests cannot disagree. + * The migratable legacy inventory: 3 Rslint keys + 14 Rstest keys. Kept in one + * table so the preview, writer and tests cannot disagree. */ export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ { @@ -112,6 +112,11 @@ export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ to: 'rstack.rslint.enable', targetScope: 'window', }, + { + from: 'rslint.corePath', + to: 'rstack.rslint.corePath', + targetScope: 'resource', + }, { from: 'rslint.trace.server', to: 'rstack.rslint.trace.server', @@ -132,6 +137,22 @@ export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ })), ]; +/** + * These retired binary-only settings cannot name the core package directory + * required by the lint worker. Detect them for the preview, but never rewrite + * or remove them. + */ +const DROPPED_LEGACY_KEYS = ['rslint.binPath', 'rslint.customBinPath'] as const; + +const LEGACY_SOURCE_KEYS: readonly string[] = [ + ...LEGACY_MAPPINGS.map((mapping) => mapping.from), + ...DROPPED_LEGACY_KEYS, +]; + +const DROPPED_LEGACY_KEY_SET: ReadonlySet = new Set( + DROPPED_LEGACY_KEYS, +); + const MAPPINGS_BY_KEY = new Map( LEGACY_MAPPINGS.map((mapping) => [mapping.from, mapping]), ); @@ -174,7 +195,7 @@ export interface PlannedSkip { readonly layer: MigrationLayer; readonly folderLabel?: string; readonly from: string; - readonly to: string; + readonly to?: string; readonly value: unknown; readonly reason: SkipReason; } @@ -221,8 +242,8 @@ const LAYER_ORDER: Readonly> = { * Turns raw readings into the exact set of writes to perform, grouped by the * scope they are written to. Pure: same readings in, same plan out. * - * Readings for keys outside {@link LEGACY_MAPPINGS} and readings whose value is - * `undefined` (i.e. not explicitly set in that layer) are ignored. + * Readings for unknown keys and readings whose value is `undefined` (i.e. not + * explicitly set in that layer) are ignored. */ export const planMigration = ( readings: readonly LegacyReading[], @@ -248,9 +269,7 @@ export const planMigration = ( return writes; }; - const order = new Map( - LEGACY_MAPPINGS.map((mapping, index) => [mapping.from, index]), - ); + const order = new Map(LEGACY_SOURCE_KEYS.map((key, index) => [key, index])); const sorted = [...readings].sort((a, b) => { const byLayer = LAYER_ORDER[a.layer] - LAYER_ORDER[b.layer]; if (byLayer !== 0) { @@ -260,10 +279,22 @@ export const planMigration = ( }); for (const reading of sorted) { - const mapping = MAPPINGS_BY_KEY.get(reading.key); - if (!mapping || reading.value === undefined) { + if (reading.value === undefined) { continue; } + if (DROPPED_LEGACY_KEY_SET.has(reading.key)) { + skips.push({ + scopeId: reading.scopeId, + layer: reading.layer, + folderLabel: reading.folderLabel, + from: reading.key, + value: reading.value, + reason: 'no-equivalent-setting', + }); + continue; + } + const mapping = MAPPINGS_BY_KEY.get(reading.key); + if (!mapping) continue; const skip = (reason: SkipReason): void => { skips.push({ @@ -338,10 +369,13 @@ const SKIP_EXPLANATIONS: Readonly< Record string> > = { 'unsupported-value': (skip) => - `${formatValue(skip.value)} is not a valid value of ${skip.to}`, - 'target-already-set': (skip) => `${skip.to} is already set here`, + `${formatValue(skip.value)} is not a valid value of ${skip.to ?? 'the replacement setting'}`, + 'target-already-set': (skip) => + `${skip.to ?? 'the replacement setting'} is already set here`, 'not-folder-scoped': (skip) => `${skip.to} is a window-scoped setting and cannot be set per folder`, + 'no-equivalent-setting': () => + 'this binary-only setting has no equivalent; configure rstack.rslint.corePath with an @rslint/core package directory if an override is still needed', }; /** @@ -417,19 +451,18 @@ export const collectLegacyReadings = (): { folders.set(folder.uri.toString(), folder); } - for (const mapping of LEGACY_MAPPINGS) { - const legacy = vscode.workspace - .getConfiguration() - .inspect(mapping.from); - const target = vscode.workspace - .getConfiguration() - .inspect(mapping.to); + for (const key of LEGACY_SOURCE_KEYS) { + const mapping = MAPPINGS_BY_KEY.get(key); + const legacy = vscode.workspace.getConfiguration().inspect(key); + const target = mapping + ? vscode.workspace.getConfiguration().inspect(mapping.to) + : undefined; if (legacy?.globalValue !== undefined) { readings.push({ scopeId: USER_SCOPE, layer: 'user', - key: mapping.from, + key, value: legacy.globalValue, targetValue: target?.globalValue, }); @@ -438,7 +471,7 @@ export const collectLegacyReadings = (): { readings.push({ scopeId: WORKSPACE_SCOPE, layer: 'workspace', - key: mapping.from, + key, value: legacy.workspaceValue, targetValue: target?.workspaceValue, }); @@ -446,7 +479,7 @@ export const collectLegacyReadings = (): { for (const folder of workspaceFolders) { const scoped = vscode.workspace.getConfiguration(undefined, folder.uri); - const value = scoped.inspect(mapping.from)?.workspaceFolderValue; + const value = scoped.inspect(key)?.workspaceFolderValue; if (value === undefined) { continue; } @@ -454,9 +487,11 @@ export const collectLegacyReadings = (): { scopeId: folder.uri.toString(), layer: 'folder', folderLabel: folder.name, - key: mapping.from, + key, value, - targetValue: scoped.inspect(mapping.to)?.workspaceFolderValue, + targetValue: mapping + ? scoped.inspect(mapping.to)?.workspaceFolderValue + : undefined, }); } } diff --git a/packages/vscode/tests/migration.test.ts b/packages/vscode/tests/migration.test.ts index 4f7fc05..aa09752 100644 --- a/packages/vscode/tests/migration.test.ts +++ b/packages/vscode/tests/migration.test.ts @@ -42,11 +42,12 @@ const folderReading = ( }); describe('LEGACY_MAPPINGS', () => { - it('covers the full legacy inventory of both retired extensions', () => { + it('covers the migratable legacy inventory of both retired extensions', () => { // The two Rslint binary settings have no valid core-directory equivalent; - // the remaining 2 Rslint settings and all 14 Rstest settings are mapped. + // the remaining 3 Rslint settings and all 14 Rstest settings are mapped. expect(LEGACY_MAPPINGS.map((mapping) => mapping.from)).toEqual([ 'rslint.enable', + 'rslint.corePath', 'rslint.trace.server', 'rstest.nodeExecutable', 'rstest.rstestPackagePath', @@ -107,6 +108,19 @@ describe('LEGACY_MAPPINGS', () => { }); describe('planMigration — mechanical renames', () => { + it('maps the standalone Rslint core package override', () => { + const plan = planMigration([ + reading({ key: 'rslint.corePath', value: './vendor/rslint-core' }), + ]); + + expect(plan.scopes[0]?.writes[0]).toMatchObject({ + from: 'rslint.corePath', + to: 'rstack.rslint.corePath', + value: './vendor/rslint-core', + rewritten: false, + }); + }); + it('carries values over untouched', () => { const plan = planMigration([ reading({ key: 'rstest.nodeExecArgs', value: ['--flag'] }), @@ -143,6 +157,33 @@ describe('planMigration — mechanical renames', () => { expect(plan.skips).toEqual([]); }); + it('reports retired Rslint binary settings without migrating them', () => { + const plan = planMigration([ + reading({ key: 'rslint.binPath', value: 'custom' }), + reading({ key: 'rslint.customBinPath', value: '/opt/rslint' }), + ]); + + expect(plan.writeCount).toBe(0); + expect(plan.skips).toMatchObject([ + { + from: 'rslint.binPath', + value: 'custom', + reason: 'no-equivalent-setting', + }, + { + from: 'rslint.customBinPath', + value: '/opt/rslint', + reason: 'no-equivalent-setting', + }, + ]); + const preview = formatPreview(plan); + expect(preview).toContain('Left untouched'); + expect(preview).toContain('rslint.binPath'); + expect(preview).toContain('rslint.customBinPath'); + expect(preview).toContain('this binary-only setting has no equivalent'); + expect(preview).toContain('rstack.rslint.corePath'); + }); + it('ignores readings whose value is undefined', () => { // `inspect()` reports `undefined` for a layer that does not set the key; // migrating it would materialise the default into the settings file. From 73fa62741a497940818ead0f9d8fadf9ffff9663 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 18 Aug 2026 14:31:14 +0800 Subject: [PATCH 3/4] chore(vscode): drop the lint worker ticket and tidy leftovers from the move - Remove docs/tickets/lint-worker-bridge.md: the ADR carries the decision, the ticket was working material. - Restore the note explaining why rslint.json stays in the lint watch list. - Drop dead surface left by the move into the worker (unused package name, exported factory type, default cwd) and fold the dropped-legacy-key constants in the settings migration. - Fix the rstack floor rationale in versionCheck.ts and the worker cwd in CONTEXT.md (folder root, not config root). --- CONTEXT.md | 2 +- docs/tickets/lint-worker-bridge.md | 66 ------------------- packages/vscode/src/migration.ts | 16 ++--- packages/vscode/src/shared/versionCheck.ts | 8 +-- packages/vscode/src/stacks/lint/Rslint.ts | 7 +- .../src/stacks/lint/worker/PluginLintPool.ts | 2 +- .../vscode/src/stacks/lint/worker/core.ts | 3 +- .../vscode/src/stacks/lint/worker/index.ts | 1 - 8 files changed, 20 insertions(+), 85 deletions(-) delete mode 100644 docs/tickets/lint-worker-bridge.md diff --git a/CONTEXT.md b/CONTEXT.md index c01ce17..ee725e5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -29,7 +29,7 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev ## lint -- **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 config 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). +- **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. diff --git a/docs/tickets/lint-worker-bridge.md b/docs/tickets/lint-worker-bridge.md deleted file mode 100644 index dba549b..0000000 --- a/docs/tickets/lint-worker-bridge.md +++ /dev/null @@ -1,66 +0,0 @@ -# Ticket: lint through an editor-shipped worker; bridge `define.lint()` from `rstack.config.*` - -Implements `docs/adr/0003-lint-through-editor-worker.md` (accepted). Read that ADR, `CONTEXT.md` (Ownership, Lint worker, Bridged folder, Native folder) and `packages/vscode/AGENTS.md` first. Terms below are the glossary's. - -## Outcome - -- A **bridged folder** (no `rslint.config.*` anywhere in the workspace folder, a `rstack.config.*` at its root) lints from `define.lint()`, through rstack's own shipped shim, with the same diagnostics `rs lint` prints in a terminal. -- A **native folder** keeps linting exactly as today from the user's point of view (ported E2E suites stay green with their assertion semantics), but its config evaluation and plugin rules now run in the **lint worker** on a User Node runtime, not in the extension host. -- No project code is imported into the extension host by the lint stack any more. - -## Non-goals - -- No generated shim, no editor-side interpretation of the Rstack config (never read `configs.lint`, never call `loadRstackConfig` from the extension). The only Rstack artefact the editor touches is the path `/dist/rslintConfig.js`, treated as stable. -- No `rs lint --lsp`, no rslint upstream change. The worker is written vscode-free so it can move to `@rslint/core` later; do not add editor coupling to it. -- No sync of rslint #1617's per-document runtime model — issue #13. Keep `WorkspaceRslintCoordinator` per folder. -- No `configPath`-style user setting. - -## Facts the implementer needs (verified 2026-08-18) - -**rslint protocol (Go side, `@rslint/core` 0.8.0, rslint PR #1630)** — `internal/lsp/config_discovery.go`: - -- `rslint/configRefresh` request `{protocolVersion: 2, reason, configPath?}`. `reason` of the first refresh must be `'initial'`. `configPath` is an absolute **native** path (no `file:` URI), extension `.js/.mjs/.cjs/.ts/.mts/.cts`. Once the first refresh has decided (present or absent), the choice is **locked for the process** — a later refresh that changes it errors with `InvalidParams`; changing mode = new process. -- Explicit mode: Go loads exactly that module, keeps the **spawn cwd** as the matching root (`files`/`ignores`/`parserOptions.project`), skips `rslint.json` fallback, and stops watching ancestor JS configs — **the client owns change notifications for the explicit path** (`architecture.md` ~L785). Its `.gitignore` watcher stays. -- Reverse requests Go sends its client: `rslint/loadConfigs`, `rslint/activateConfigs`, `rslint/commitConfigs`, `rslint/abortConfigs`, `rslint/pluginLint`. Handlers must be registered before the first refresh. -- The Go binary is `@rslint/native-*`; the CLI/LSP takes only `--lsp`; argv is otherwise ignored. - -**`@rslint/core` 0.8.0 exports** (`./config-loader`): `ConfigModuleHost`, `CONFIG_DISCOVERY_PROTOCOL_VERSION` (=2), `resolveRslintBinary()`, protocol types; (`./eslint-plugin`): `createPluginLintHost`. Upstream's `CoreResolver.ts` derives everything from one core directory this way. - -**rstack** (`0.6.1`, depends on `@rslint/core ~0.8.0`; `0.5.2` still `~0.7.3`): `rs lint` = `runCLI({argv:[..., '--config', join(import.meta.dirname, 'rslintConfig.js')]})`. `dist/rslintConfig.js` = `loadRstackConfig()` → `configs.lint ?? []` → call if function → default export. `loadRstackConfig()` with no args reads the CLI's `globalThis.__rstackCliState.configPath` else searches `process.cwd()` for `rstack.config.{ts,js,mts,mjs}` via `@rstackjs/load-config` `loader:'native'`, `fresh:true`. `dist/*` is not in `exports`; locate the package via its `package.json`. - -**Our code today** (`packages/vscode/src`): spawn at `stacks/lint/Rslint.ts:630-642` (`LanguageServerProcessOwner(binPath, ['--lsp'], folderRoot)`), reverse handlers `Rslint.ts:732-783`, refresh `Rslint.ts:957-961`, watch glob `Rslint.ts:126`; JS host pieces `configLoader.ts`, `ConfigTransactionAdapter.ts`, `PluginLintPool.ts`, `projectModules.ts`, `jitiPreflight.ts`; resolution `stacks/lint/resolution.ts`; detection `detection.ts:176-212`; floors `shared/versionCheck.ts:27-31`, protocol set `:154`; User Node selection `shared/nodeResolution.ts` (+ `USER_NODE_STACKS` in `extension.ts:34`, `nodeExecutableSetting.ts`); fmt's spawn-on-User-Node reference `stacks/fmt/index.ts:329-371, 429`; the rstest worker bundling pattern `rslib.config.mts:44-110`; settings `package.json` "Rstack › Rslint" block; migration mapping tests `tests/migration.test.ts:186-192, 362`. - -## Work breakdown - -1. **Lint worker** (`src/stacks/lint/worker/`, own rslib entry like the rstest worker; CJS, `target: node`, no `vscode` import). - - CLI: `--lsp --core [--config ]`. Anything else is a usage error. - - Load `/config-loader` and `/eslint-plugin` (ESM `import()` from the core dir; mirror upstream `CoreResolver.ts` structural checks). Binary = that core's `resolveRslintBinary()`; spawn it with `--lsp`, cwd = `process.cwd()`, stdio pipes. - - Proxy JSON-RPC between `process.stdin/stdout` (extension) and the Go child (vscode-jsonrpc star handlers both ways, cancellation and ids preserved). Own the Go child's lifetime (SIGTERM → SIGKILL, exit when either side closes). - - Answer Go's five reverse requests locally: port `LspConfigTransactionAdapter` + `PluginLintPool` + fingerprinting logic into the worker with their behaviour intact (`loadMode:'fresh'` forcing, generation grace, protocol validation, cancellation → AbortSignal). - - Intercept the extension's `rslint/configRefresh {reason}`: stamp `protocolVersion` from the core and, when started with `--config`, the same `configPath` every time; forward to Go; return Go's response. - - Log to stderr only; stdout is the LSP channel. -2. **Extension-side lint stack** (`Rslint.ts` keeps the language-client half only). - - Resolution (`resolution.ts`, pure, unit-tested): native folder → `@rslint/core` from the folder root; bridged folder → `rstack` from the folder root, then `@rslint/core` from rstack's directory (`createRequire`-style, physical `node_modules`, no PnP); `rstack.rslint.corePath` overrides the core hop in both modes. Result: `{ mode, coreDir, coreVersion, rstackDir?, rstackVersion?, shimPath? }`. Gates: `@rslint/core >= 0.8.0`; bridged additionally `rstack >= 0.6.1` (raise `SUPPORT_MATRIX` — `rstack: '>=0.6.1'`, `'@rslint/core': '>=0.8.0'`; delete `SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS` and the protocol-mismatch classes/messages). - - Runtime: pick the User Node through `resolveUserNodeOnce` with lint's own consequence sentence; add `'rslint'` to `USER_NODE_STACKS`; honour `rstack.nodeExecutable`. Spawn ` --lsp --core [--config ]` via `LanguageServerProcessOwner`, cwd = folder root. - - Delete from the extension host: `configLoader.ts`, `ConfigTransactionAdapter.ts`, `PluginLintPool.ts`, `projectModules.ts`, `jitiPreflight.ts` (they move to the worker or die), and `shared/vendored/loadRstackConfig.ts` if nothing else imports it. Remove every `TODO(rstack-bridge)` marker. - - Refresh: keep the watcher-driven `requestConfigRefresh` (send `{reason}` only). Bridged folder: watch glob adds the root `rstack.config.{ts,js,mts,mjs}` (mind: no nested brace groups). Mode flip (a native config appearing/disappearing, root rstack config appearing/disappearing) restarts through the coordinator's replacement path. - - Status: bridged folder — no `rstack` → `disabled`; `rstack`/chained `@rslint/core` below floor or no Node clearing the floor → `version mismatch` (message names package + required version, or the shared preflight message + lint consequence); worker/Go dies → `crashed`. Native folder missing `@rslint/core` stays `crashed`. -3. **Detection** (`detection.ts`): `rslint.detected = rslintConfigFiles.length > 0 || rootRstackConfigExists`; expose which (mode) so the stack does not re-scan; presence only, never contents. Update `tests/lintDetection.test.ts`, `tests/detection.test.ts`, `e2e/suite/detection.test.ts` (the rstack row now lights all three stacks). -4. **Settings/migration**: remove `rstack.rslint.binPath`, `rstack.rslint.customBinPath` (and `rslint.customBinPath` from the migration mapping + tests); add `rstack.rslint.corePath` (string, resource-scoped, description mirroring upstream); `restartOnSettings = ['corePath', 'trace.server']`. README (user-facing) settings table updated. -5. **Docs**: `packages/vscode/AGENTS.md` — adaptation #7 (lint worker + bridge), rewrite the "bridge was built and deliberately removed" gotcha, drop "Lint still loads project code on the VS Code Node runtime" from adaptation #6, add worker gotchas (vscode-free; explicit paths only; refresh vs restart line). `docs/adr/0001-node-runtime-selection.md`: move lint from the debt list to "retired by ADR 0003". Root `README.md`/`packages/vscode/README.md` only if user-facing behaviour is described there. -6. **Tests**: - - Unit: resolution chain + mode decision + status classification as pure modules; worker CLI parsing / configRefresh stamping with a fake Go (spawn a small stdio JSON-RPC stub). - - E2E: bump fixtures to `rstack@0.6.1`, `@rslint/core@^0.8.0`; `e2e/fixtures/rstack/rstack.config.ts` already carries `define.lint([...no-debugger...])` — add a lint bridge suite (diagnostic from that rule in a folder with no `rslint.config.*`, a `rstack.config.ts` edit refreshing it, a native config appearing flipping the folder). All ported lint suites (`e2e/lint/suite*`) must pass unchanged in assertion semantics. Add the slice to `SLICES` if a new entry is needed. - -## Verification (report real output) - -- `pnpm lint && pnpm test:unit` -- `VSCODE_CLI=1 pnpm test:e2e lint vscode smoke` (lint slice = ported suites + bridge; `vscode` = detection; `smoke` uses the rslint fixture — update if it imports the removed in-host plugin host path) -- Manual: open `packages/vscode/e2e/fixtures/rstack` alone in the Extension Development Host; expect a `no-debugger` diagnostic and status `running`; delete `rstack` from `node_modules` → `disabled`. - -## Guardrails - -- Never delete `packages/vscode/.vscode-test/`. -- Fixture `node_modules` are disposable, never committed. -- Do not reintroduce a per-request child, a generated file, or any Rstack-config semantics in the extension. -- Do not add native dependencies to the VSIX. diff --git a/packages/vscode/src/migration.ts b/packages/vscode/src/migration.ts index 3b86c70..61b6061 100644 --- a/packages/vscode/src/migration.ts +++ b/packages/vscode/src/migration.ts @@ -142,17 +142,16 @@ export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ * required by the lint worker. Detect them for the preview, but never rewrite * or remove them. */ -const DROPPED_LEGACY_KEYS = ['rslint.binPath', 'rslint.customBinPath'] as const; +const DROPPED_LEGACY_KEY_SET: ReadonlySet = new Set([ + 'rslint.binPath', + 'rslint.customBinPath', +]); const LEGACY_SOURCE_KEYS: readonly string[] = [ ...LEGACY_MAPPINGS.map((mapping) => mapping.from), - ...DROPPED_LEGACY_KEYS, + ...DROPPED_LEGACY_KEY_SET, ]; -const DROPPED_LEGACY_KEY_SET: ReadonlySet = new Set( - DROPPED_LEGACY_KEYS, -); - const MAPPINGS_BY_KEY = new Map( LEGACY_MAPPINGS.map((mapping) => [mapping.from, mapping]), ); @@ -369,9 +368,8 @@ const SKIP_EXPLANATIONS: Readonly< Record string> > = { 'unsupported-value': (skip) => - `${formatValue(skip.value)} is not a valid value of ${skip.to ?? 'the replacement setting'}`, - 'target-already-set': (skip) => - `${skip.to ?? 'the replacement setting'} is already set here`, + `${formatValue(skip.value)} is not a valid value of ${skip.to}`, + 'target-already-set': (skip) => `${skip.to} is already set here`, 'not-folder-scoped': (skip) => `${skip.to} is a window-scoped setting and cannot be set per folder`, 'no-equivalent-setting': () => diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index 274f308..1093ad4 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -10,10 +10,10 @@ import { readPackageJson } from './packageResolve'; * Launch floors (verified against npm): * - `@rslint/core >= 0.8.0` — explicit protocol-2 config selection. * - `@rstest/core >= 0.6.0` — the existing `MIN_CORE_VERSION` upstream. - * - `rstack >= 0.6.1` — the release whose lint shim uses @rslint/core 0.8. - * There is no stdin fallback for older - * releases: the editor would then format through a different code path than - * the one it is tested against, so the floor is a version gate instead. + * - `rstack >= 0.6.1` — the first release depending on `@rslint/core ~0.8.0`, + * whose lint shim the bridged lint worker pins (protocol 2). It also carries + * `rs fmt --lsp`; there is no stdin fallback for older releases, since the + * editor would then format through a code path it is not tested against. * The floor is **uniform across consumers by decision**: the Rstest bridge * checks the same entry, so an older rstack reports `version mismatch` for * tests too, even when that stack's own API would still work. One matrix diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index af0bcfd..d3a0c03 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -57,6 +57,11 @@ const LOCKFILE_NAMES = [ 'yarn.lock', ] as const; +/** + * Kept verbatim from upstream, JSON names included: they are not detection + * signals but watching them is harmless, and the Go server does load + * `rslint.json` from its cwd as a no-JS-config fallback in automatic mode. + */ const RSLINT_CONFIG_WATCH_NAMES = [ 'rslint.config.js', 'rslint.config.mjs', @@ -364,7 +369,7 @@ export class Rslint implements Disposable { const resolution = resolveRslint({ folderRoot, mode, corePath }); this.assertStartCurrent(epoch, signal); - if (resolution.rstackDir !== undefined) { + if (mode === 'bridged') { const rstackCheck = checkPackageVersion( 'rstack', resolution.rstackVersion, diff --git a/packages/vscode/src/stacks/lint/worker/PluginLintPool.ts b/packages/vscode/src/stacks/lint/worker/PluginLintPool.ts index 4290173..ded2e48 100644 --- a/packages/vscode/src/stacks/lint/worker/PluginLintPool.ts +++ b/packages/vscode/src/stacks/lint/worker/PluginLintPool.ts @@ -7,7 +7,7 @@ import type { import type { CancellationToken } from 'vscode-jsonrpc/node'; import type { WorkerLogger } from './logger'; -export type PluginHostFactory = ( +type PluginHostFactory = ( configs: ConfigDescriptor[], onLog: (record: { level: string; source: string; text: string }) => void, ) => Promise; diff --git a/packages/vscode/src/stacks/lint/worker/core.ts b/packages/vscode/src/stacks/lint/worker/core.ts index e31baea..82e1e96 100644 --- a/packages/vscode/src/stacks/lint/worker/core.ts +++ b/packages/vscode/src/stacks/lint/worker/core.ts @@ -25,7 +25,6 @@ interface PluginHostModule { } interface CorePackageJson { - readonly name: string; readonly version: string; } @@ -75,7 +74,7 @@ async function readPackageJson( `${packageJsonPath} is not a valid ${CORE_PACKAGE_NAME} package`, ); } - return { name: parsed.name, version: parsed.version }; + return { version: parsed.version }; } function resolveExport(packageDirectory: string, subpath: string): string { diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index 1bdc3b8..830278d 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -80,7 +80,6 @@ async function spawnRslint( binaryPath: string, ): Promise { const child = spawn(binaryPath, ['--lsp'], { - cwd: process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], }); await new Promise((resolve, reject) => { From 54dd2ea4c03aa3d534190f3bf456fa39eec8711b Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 18 Aug 2026 14:39:52 +0800 Subject: [PATCH 4/4] docs(vscode): record that Yarn Plug'n'Play is unsupported by decision --- packages/vscode/AGENTS.md | 1 + packages/vscode/README.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index b446175..838a97e 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -35,6 +35,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Gotchas — decisions that look wrong but aren't - 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. - 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`. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 9e519fc..a244589 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -26,6 +26,8 @@ The extension activates on startup, then decides **per workspace folder** which | Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` | | rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` | +Tools resolve from the folder's physical `node_modules` (this extension ships no tool binaries or packages); Yarn Plug'n'Play layouts are not supported. + Config files and lockfiles are watched, so detection re-runs without a window reload. When something changes that none of those files record — a reinstall that leaves the lockfile untouched, or a `node_modules` that ends up broken — run **Rstack: Relaunch Extension** from the Command Palette (also on the status bar hover) to tear every tool down and start over. To rebuild a single tool, use **Rstack: Restart Rslint** / **Restart Rstest** / **Restart rs fmt**. A restart re-resolves every binary and package version and respawns every tool process. Deprecated `rslint.json` / `rslint.jsonc` configs are **not** detection signals — migrate them with `rslint --init`.