From 678fd84419458c6dae0ee0dc54fe071a1c17fc7b Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 11 Aug 2026 21:51:30 +0800 Subject: [PATCH 01/11] feat(vscode): lint bridged folders from rstack.config via a generated shim A workspace folder with no native rslint.config.* and an rstack.config.* at its root now lints through the Rstack config: the extension writes a generated shim (loadRstackConfig with the absolute config path baked in, lint section default-exported) and pins the language server to it through the optional configPath of rslint/configRefresh (config-discovery protocol 2, rslint PR web-infra-dev/rslint#1630). Bridged mode is gated on the capability, not a version guess: the project's @rslint/core/config-loader must report protocol >= 2. Below that (every released version today) the folder reports a version mismatch naming the resolved version instead of starting a half-bridge, and never reports crashed - @rslint/core reaches such projects only as rstack's transitive dependency. Ownership stays folder-level: one native config anywhere and the bridge yields; a mode flip restarts the server through the coordinator's replacement path, since the explicit-config choice is fixed for a server process. The E2E bridge suite asserts the gate path against today's @rslint/core 0.7.3 and carries the explicit-mode happy path behind a runtime skip that unlocks when a protocol-2 release ships. --- CONTEXT.md | 2 + README.md | 2 +- docs/adr/0001-node-runtime-selection.md | 2 +- packages/vscode/AGENTS.md | 9 +- packages/vscode/README.md | 11 +- .../e2e/fixtures/rstack/rstack.config.ts | 11 +- packages/vscode/e2e/lint/runTest.ts | 70 ++- .../lint/suite-rstack-bridge/bridge.test.ts | 265 ++++++++++ .../e2e/lint/suite-rstack-bridge/index.ts | 6 + packages/vscode/e2e/run.mjs | 8 +- packages/vscode/e2e/suite/detection.test.ts | 17 +- packages/vscode/src/detection.ts | 48 +- .../src/shared/vendored/loadRstackConfig.ts | 15 +- packages/vscode/src/shared/versionCheck.ts | 9 +- packages/vscode/src/stacks/lint/Rslint.ts | 321 +++++++++++- packages/vscode/src/stacks/lint/index.ts | 158 +++++- .../vscode/src/stacks/lint/rstackBridge.ts | 482 ++++++++++++++++++ packages/vscode/tests/lintDetection.test.ts | 126 +++++ .../tests/stacks/lint/rstackBridge.test.ts | 462 +++++++++++++++++ packages/vscode/tests/versionCheck.test.ts | 7 +- 20 files changed, 1916 insertions(+), 115 deletions(-) create mode 100644 packages/vscode/e2e/lint/suite-rstack-bridge/bridge.test.ts create mode 100644 packages/vscode/e2e/lint/suite-rstack-bridge/index.ts create mode 100644 packages/vscode/src/stacks/lint/rstackBridge.ts create mode 100644 packages/vscode/tests/lintDetection.test.ts create mode 100644 packages/vscode/tests/stacks/lint/rstackBridge.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 951f397..eb74c28 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,6 +24,8 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **Rstack config** — the unified `rstack.config.*` file consumed by rstack-cli (`rs`), holding per-tool sections. Tools never read it themselves; `rs` hands each tool its section through a shim. - **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. +- **Generated shim** — a shim the extension writes itself for a bridged folder, baking in the absolute Rstack config path via the loader rstack publishes (`rstack/config`). Used where the tool's channel evaluates modules away from the project directory, so rstack-cli's shipped shim (which probes the current directory) cannot apply. +- **Bridged folder** — a workspace folder whose lint runs against a Rstack config: no native Rslint config exists anywhere in the folder, a Rstack config sits at the folder root, and the language server is pinned to a generated shim for its whole lifetime. _Avoid_: bridged workspace. - **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. diff --git a/README.md b/README.md index 63826de..0d4c27e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The extension takes its configuration from five sources. The tool-native configs | `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.lint()` in `rstack.config.*` | **Supported, with requirements.** A workspace folder with no `rslint.config.*` anywhere and an `rstack.config.*` at its root is linted from `define.lint()`, evaluated through rstack's own config loader so the editor and `rs lint` see the same config. It needs `@rslint/core` installed in the project — `rstack` depends on it, but pnpm does not expose transitive dependencies, so add it to your `devDependencies` — and new enough to let the editor pin the language server to a config; until both hold, the status bar says which one is missing. A TypeScript `rstack.config.ts` additionally needs a VS Code build whose Node can strip types, since rstack's config loader has no fallback. A folder that does have an `rslint.config.*` keeps using it, unchanged. | ## License diff --git a/docs/adr/0001-node-runtime-selection.md b/docs/adr/0001-node-runtime-selection.md index e0a0732..057af92 100644 --- a/docs/adr/0001-node-runtime-selection.md +++ b/docs/adr/0001-node-runtime-selection.md @@ -39,7 +39,7 @@ Note that _worker_ names a process, not a runtime. The worker is our own code; t 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: - **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** 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. The lint × rstack bridge widens this entry: a bridged folder evaluates the user's `rstack.config.*` on the same runtime, through rstack's `loadRstackConfig` — the `loader: 'native'`, no-jiti-fallback path this ADR analysed for the worker floor. The generated shim itself is plain JS, and `@rslint/core`'s jiti fallback covers only the entry config file it loads, not the imports that file makes — so a `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively, a condition VS Code's release cadence owns, not the user's environment. 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. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 58377c7..25a26bc 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -5,9 +5,9 @@ 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). @@ -15,6 +15,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 6. **Node runtime selection** (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. +7. **Rstack config bridge** (lint) — a **bridged folder** (no native `rslint.config.*` anywhere in the folder, an `rstack.config.*` at its root) is linted from the Rstack config: the extension writes a **generated shim** into the project and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`. Native mode stays byte-identical to upstream — the field is absent, so the server keeps doing its own discovery. In `Rslint.ts` every line of it sits between a `--- rstack config bridge ---` / `--- end rstack config bridge ---` marker pair (no single-line markers — the pairing is what an upstream-sync diff greps for); the rule, the shim and the gate live in `stacks/lint/rstackBridge.ts`. The shim's lifecycle follows the pin: written before the server starts, re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode. ## Rules @@ -33,7 +34,9 @@ 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 is complete, and every part of it that looks like an arbitrary restriction is forced by the language server. Ownership is decided per **folder**, not per directory (one native config anywhere and the bridge yields entirely, silently), and only a **root** `rstack.config.*` bridges, because the explicit-config choice is fixed for a server process, there is one server per workspace folder, and its cwd is the folder root. A subdirectory `rstack.config.*` therefore does not light lint at all — a documented limitation, not an oversight. The shim is **generated** rather than rstack's shipped `dist/rslintConfig.js`, because that one calls `loadRstackConfig()` with no arguments and probes the _evaluation_ cwd, which for the editor is the extension host's meaningless cwd; the generated one bakes the absolute config path in. It still never interprets the config: `define.lint`'s value is an Rslint flat config, taken through the project's own `rstack/config` export. A mode flip is a **restart** (the controller replays the folder through the coordinator's replacement path), never a message to a live server. +- Bridged mode is gated on a **capability**, not a version number: the project's `@rslint/core/config-loader` must report config-discovery protocol >= 2 (the version that carries `configPath`, rslint PR #1630). Below that the extension does not start a half-bridge — it reports `version mismatch` naming the resolved version and telling the user to upgrade `@rslint/core`. Do not replace the probe with a guessed release number, and do not "fall back" to automatic discovery for a bridged folder: the server would find no config and report nothing, which reads as a broken extension. +- A bridged folder that cannot start reports `version mismatch`, never `crashed` — including when `@rslint/core` does not resolve at all (one `RstackBridgeGateError` for both gates, differing only in message; the coordinator is told it is an expected failure). That is not politeness: `@rslint/core` reaches such a project only as `rstack`'s **transitive** dependency, which pnpm's isolated layout does not expose, so the mainstream rstack-cli project hits it. Nobody in that folder asked for Rslint by name, `crashed` outranks every other folder in the status aggregation, and the status detail is the only place the fix can be stated. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`. - 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. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 11617ee..9e26615 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -22,7 +22,7 @@ The extension activates on startup, then decides **per workspace folder** which | Tool | Started when the folder contains | | --- | --- | -| Rslint | `rslint.config.{js,mjs,ts,mts}` | +| Rslint | `rslint.config.{js,mjs,ts,mts}`, or an `rstack.config.*` at the folder root when the folder has no `rslint.config.*` at all | | Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` | | rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` | @@ -42,6 +42,15 @@ The project-resolved packages are checked against a support matrix at runtime; a `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. +Linting a folder from `define.lint()` in `rstack.config.*` asks more: + +- `rstack` must publish its config loader (`>=0.4.0`). +- `@rslint/core` must be **installed in the project**. `rstack` depends on it, but package managers with an isolated `node_modules` layout (pnpm by default) do not expose transitive dependencies, so add `@rslint/core` to your `devDependencies`. +- `@rslint/core` must be new enough for the editor to pin the language server to a config. That is not a version number the extension can name yet — no released `@rslint/core` has it. +- A TypeScript `rstack.config.ts` must be loadable by the VS Code extension host's Node: rstack's config loader relies on native type stripping and has no fallback, so on an older VS Code build use `rstack.config.mjs` (or `.js`). + +Until all of them hold, such a folder shows `version mismatch` with a message naming the missing piece instead of linting; adding an `rslint.config.*` is the way out today. Folders with an `rslint.config.*` are unaffected. + ## Auto-fix on save (Rslint) To automatically fix lint issues when saving, add this to your VS Code settings (`.vscode/settings.json`): diff --git a/packages/vscode/e2e/fixtures/rstack/rstack.config.ts b/packages/vscode/e2e/fixtures/rstack/rstack.config.ts index 9003997..822c8d5 100644 --- a/packages/vscode/e2e/fixtures/rstack/rstack.config.ts +++ b/packages/vscode/e2e/fixtures/rstack/rstack.config.ts @@ -2,10 +2,13 @@ // // 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 -// `rs test` inject rstack's own shim configs, so a tool-native file never has -// to exist. +// presence alone must light all three stacks — `rs lint` and `rs test` inject +// rstack's own shim configs, so a tool-native file never has to exist. +// +// It sits at the folder root and no `rslint.config.*` exists anywhere, which +// makes this fixture a *bridged folder*: the lint slice's `suite-rstack-bridge` +// asserts what the extension does with it. `define.lint()`'s value IS an Rslint +// flat config — no translation happens anywhere in the chain. import { define } from 'rstack'; define.lint([ diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index 42c9fc3..c7675ff 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -76,6 +76,8 @@ async function runIsolatedSuite( extensionDevelopmentPath: string, vscodeExecutablePath: string, suite: TestSuite, + /** The fixture's package boundary — resolved (and checked) by the caller. */ + packageRoot: string, ): Promise { // Go discovery intentionally searches strict cwd ancestors, so keep each // fixture in a clean physical workspace outside this repository. Use short @@ -92,10 +94,16 @@ async function runIsolatedSuite( // Suites intentionally create, rewrite, and delete config files. Run them // against a private copy so even an Extension Host crash cannot mutate a // tracked fixture in the checkout. + // + // `node_modules` is never copied: the dependency lookup is preserved by + // symlinking the install root's `node_modules` next to the copy (below), + // which is both faster and the only shape that works for a fixture whose + // install root *is* the workspace (the shared `rstack` fixture). await fs.promises.cp(suite.workspace, workspaceCopy, { recursive: true, force: false, errorOnExist: true, + filter: (source) => path.basename(source) !== 'node_modules', }); const expectedWorkspaceFolders = await Promise.all( (suite.workspaceFolders ?? ['.']).map((folder) => @@ -117,7 +125,6 @@ async function runIsolatedSuite( // Preserve the fixture install root's package boundary and dependency // lookup (the project-resolved `@rslint/core`) without // placing a writable node_modules link inside the test workspace. - const packageRoot = await findPackageRoot(suite.workspace); await fs.promises.copyFile( path.join(packageRoot, 'package.json'), path.join(profileRoot, 'package.json'), @@ -193,25 +200,6 @@ async function main(): Promise { 'dist/extension.js is missing — run `pnpm build` before `pnpm test:e2e:lint`.', ); } - // One shared install root serves every lint fixture workspace (published - // @rslint/core) — that is what the guard probes. - if (!fs.existsSync(path.join(fixturesRoot, 'node_modules'))) { - throw new Error( - 'the lint E2E fixtures are not installed — run `pnpm test:e2e:fixtures lint`.', - ); - } - - // Resolve the executable once so every suite explicitly uses the same one. - // `VSCODE_TEST_EXECUTABLE`/`VSCODE_TEST_VERSION` mirror the other runners in - // this repo; a cached download under `.vscode-test/` is reused. - const vscodeExecutablePath = - process.env.VSCODE_TEST_EXECUTABLE || - (await downloadAndUnzipVSCode({ - version: process.env.VSCODE_TEST_VERSION ?? 'stable', - timeout: 60_000, - extensionDevelopmentPath, - })); - const suites: TestSuite[] = [ { // Upstream "JSON config tests": same suite, JS-config fixture (JSON @@ -269,6 +257,16 @@ async function main(): Promise { workspace: fixture('eslint-plugins'), tests: suiteDir('suite-eslint-plugins'), }, + { + // Not a ported suite and not a `fixtures/` workspace: the bridge needs a + // project that installs `rstack` itself, which is the shared `rstack` + // fixture (also used by the vscode and rstest slices). Its install root + // *is* the workspace, which the sandbox handles by never copying + // `node_modules` and symlinking the install root's next to the copy. + name: 'Rstack config bridge tests', + workspace: path.join(extensionDevelopmentPath, 'e2e/fixtures/rstack'), + tests: suiteDir('suite-rstack-bridge'), + }, ]; // Optional development filter: `RSTACK_LINT_E2E_SUITES="No config,Monorepo"` @@ -292,14 +290,44 @@ async function main(): Promise { ); } - const failures: unknown[] = []; + // Every fixture workspace resolves its dependencies from its own package + // boundary — one shared install root for the ported suites, the `rstack` + // fixture itself for the bridge suite (it is the project that has to provide + // `rstack`). The same walk-up the sandbox uses answers "is it installed", so + // the check is one rule over the *selected* suites: a filtered run must not + // demand fixtures no selected suite touches. + const packageRoots = new Map(); for (const suite of selectedSuites) { + const packageRoot = await findPackageRoot(suite.workspace); + if (!fs.existsSync(path.join(packageRoot, 'node_modules'))) { + throw new Error( + `the E2E fixture for "${suite.name}" is not installed: ${packageRoot} has no node_modules — run \`pnpm test:e2e:fixtures\`.`, + ); + } + packageRoots.set(suite, packageRoot); + } + + // Resolve the executable once so every suite explicitly uses the same one. + // `VSCODE_TEST_EXECUTABLE`/`VSCODE_TEST_VERSION` mirror the other runners in + // this repo; a cached download under `.vscode-test/` is reused. + const vscodeExecutablePath = + process.env.VSCODE_TEST_EXECUTABLE || + (await downloadAndUnzipVSCode({ + version: process.env.VSCODE_TEST_VERSION ?? 'stable', + timeout: 60_000, + extensionDevelopmentPath, + })); + + const failures: unknown[] = []; + // Insertion order is `selectedSuites` order. + for (const [suite, packageRoot] of packageRoots) { console.log(`\n=== ${suite.name} ===`); try { await runIsolatedSuite( extensionDevelopmentPath, vscodeExecutablePath, suite, + packageRoot, ); } catch (error) { console.error(`${suite.name} failed:`, error); diff --git a/packages/vscode/e2e/lint/suite-rstack-bridge/bridge.test.ts b/packages/vscode/e2e/lint/suite-rstack-bridge/bridge.test.ts new file mode 100644 index 0000000..c461cd7 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-rstack-bridge/bridge.test.ts @@ -0,0 +1,265 @@ +/** + * The lint × Rstack config bridge, end to end. + * + * This suite runs against the shared `rstack` fixture — the one workspace in + * the repo that is a *bridged folder*: `rstack.config.ts` with a `define.lint()` + * section at the root, no `rslint.config.*` anywhere, and `rstack` + + * `@rslint/core` installed. + * + * Which half of the suite runs is decided at runtime by the project's own + * `@rslint/core`, resolved exactly the way the extension resolves it: + * + * - config-discovery protocol < 2 (every release up to and including 0.7.3): + * the language server has no channel to be pinned to a config, so the + * capability gate must refuse explicit mode and say so in the status. That + * path is asserted today. + * - protocol >= 2 (rslint PR #1630 onwards): the happy path — diagnostics + * produced by a rule that exists only inside `rstack.config.ts`, and a + * config-change refresh when that file is edited. Written now, runtime-skipped + * until a release carries the protocol bump; nothing else has to change to + * unlock it. + */ +import * as assert from 'assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import { + BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION, + generatedShimPath as productionShimPath, +} from '../../../src/stacks/lint/rstackBridge'; +import { loadConfigLoaderModule } from '../../../src/stacks/lint/configLoader'; +import { Logger } from '../../../src/stacks/lint/logger'; +import { resolveRslint } from '../../../src/stacks/lint/resolution'; +import type { StackState } from '../../../src/types'; +// Cross-tree, on purpose: `e2e/suite/helpers.ts` owns the polling primitives +// for the whole E2E harness, and both trees compile into `tests-dist/e2e/…` +// under one `rootDir`, so this relative path survives compilation. +import { delay, eventually } from '../../suite/helpers'; +import { + getRslintDiagnostics, + waitForRslintDiagnostics as waitForDiagnostics, +} from '../utils/diagnostics'; +import { extensionExports } from '../utils/extension'; + +interface RslintStackExports { + getFolderStates(): ReadonlyMap; +} + +function isRslintStackExports(value: unknown): value is RslintStackExports { + return ( + typeof value === 'object' && + value !== null && + typeof (value as RslintStackExports).getFolderStates === 'function' + ); +} + +function workspaceFolder(): vscode.WorkspaceFolder { + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder, 'the bridged fixture folder is not open'); + return folder; +} + +function folderState(): StackState { + const exports = extensionExports().getStackExports('rslint'); + assert.ok(exports, 'the lint stack is not registered'); + assert.ok(isRslintStackExports(exports), 'unexpected lint stack exports'); + const folder = workspaceFolder(); + const state = exports.getFolderStates().get(folder.uri.toString()); + // `assert.fail` rather than `assert.ok(state, msg)`: this runs on every poll, + // and the message must not be built for the passing case. + if (!state) assert.fail(`no lint state for ${folder.name}`); + return state; +} + +/** Polls a folder-state predicate; every transition here is asynchronous. */ +function waitForFolderState( + predicate: (state: StackState) => boolean, + description: string, + timeoutMs = 90_000, +): Promise { + return eventually( + () => { + const state = folderState(); + if (!predicate(state)) { + assert.fail(`last state: ${JSON.stringify(state)}`); + } + return state; + }, + description, + timeoutMs, + 200, + ); +} + +/** + * The project's config-discovery protocol version, read through the very + * modules the extension uses — never a version-number guess, and never a + * duplicate of the resolution rules. + */ +async function resolveProtocolVersion(): Promise { + const channel = vscode.window.createOutputChannel('rstack-bridge-e2e', { + log: true, + }); + try { + const resolution = await resolveRslint( + workspaceFolder(), + new Logger(channel), + ); + const configLoader = await loadConfigLoaderModule(resolution); + return configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION; + } finally { + channel.dispose(); + } +} + +/** + * The production path function, not a copy of the literal: the assertion that + * actually runs today is a *negative* one, so a hand-written path would keep + * passing after the shim moved — while the extension quietly wrote a file the + * suite no longer looks at. + */ +const generatedShimPath = (): string => + productionShimPath(workspaceFolder().uri.fsPath); + +suite('rslint × rstack config bridge', function () { + this.timeout(180_000); + + let protocolVersion = 0; + let bridgeSupported = false; + + suiteSetup(async () => { + protocolVersion = await resolveProtocolVersion(); + bridgeSupported = + protocolVersion >= BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION; + console.log( + `[bridge] @rslint/core config-discovery protocol ${protocolVersion} (bridge ${bridgeSupported ? 'supported' : 'gated'})`, + ); + }); + + test('the shell lights the lint stack for a bridged folder', () => { + // No `rslint.config.*` exists anywhere in the fixture: the only reason the + // stack is registered at all is the root `rstack.config.ts`. + const configs = fs + .readdirSync(workspaceFolder().uri.fsPath) + .filter((entry) => entry.startsWith('rslint.config.')); + assert.deepEqual(configs, [], 'the fixture must have no native config'); + assert.ok( + fs.existsSync( + path.join(workspaceFolder().uri.fsPath, 'rstack.config.ts'), + ), + 'the fixture must have a root rstack.config.ts', + ); + // `runSuite.ts` already blocked on `whenStackActive('rslint')`, so reaching + // this line is the registration assertion; the state map proves the folder + // itself (not some other root) is what lit it. + assert.ok(folderState(), 'the bridged folder has no lint state'); + }); + + test('the capability gate refuses explicit mode on protocol < 2', async function () { + if (bridgeSupported) { + this.skip(); + return; + } + const state = await waitForFolderState( + (candidate) => candidate.kind === 'version-mismatch', + 'the capability gate to surface a version-mismatch status', + ); + assert.equal(state.kind, 'version-mismatch'); + const detail = state.kind === 'version-mismatch' ? state.detail : ''; + // The message has to name the package to upgrade and the version actually + // resolved — "unsupported" alone is not actionable. + assert.match(detail, /upgrade @rslint\/core/); + assert.match(detail, /protocol/); + assert.match(detail, /rstack\.config/); + + // No explicit mode means no generated shim: the gate runs before anything + // is written, so a refused folder leaves no file behind. + assert.equal( + fs.existsSync(generatedShimPath()), + false, + 'a gated folder must not have a generated shim', + ); + + // And no diagnostics, because no language server started for this folder. + const document = await vscode.workspace.openTextDocument( + path.join(workspaceFolder().uri.fsPath, 'src', 'index.ts'), + ); + await delay(3_000); + assert.deepEqual( + getRslintDiagnostics(document).map((entry) => entry.message), + [], + 'a gated folder must not produce diagnostics', + ); + }); + + test('lints from define.lint() with no native config', async function () { + if (!bridgeSupported) { + // Unlocked by the first @rslint/core release containing rslint PR #1630 + // (`configPath` on `rslint/configRefresh`). + // + // One thing does have to hold when it unlocks: the fixture's config is + // `rstack.config.ts`, and rstack's loader hardcodes native type stripping + // with no jiti fallback, so this test also needs a VS Code build whose + // Node strips types (the rstack-loader preflight surfaces the same + // condition to users as a status note). Should this fail on that, the + // answer is a `.mjs` fixture config, not a change to the bridge. + this.skip(); + return; + } + await waitForFolderState( + (state) => state.kind === 'running', + 'the bridged language server to run', + ); + assert.ok( + fs.existsSync(generatedShimPath()), + 'the generated shim must exist in the project', + ); + const shim = fs.readFileSync(generatedShimPath(), 'utf8'); + assert.match(shim, /rstack\.config\.ts/); + + const document = await vscode.workspace.openTextDocument( + path.join(workspaceFolder().uri.fsPath, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + const diagnostics = await waitForDiagnostics(document, (entries) => + entries.some((entry) => /debugger/i.test(entry.message)), + ); + // `no-debugger` exists only inside `rstack.config.ts` — the rule firing is + // the proof that `define.lint()` reached the server unchanged. + assert.ok(diagnostics.length > 0); + }); + + test('reloads when the rstack config changes', async function () { + if (!bridgeSupported) { + this.skip(); + return; + } + const configPath = path.join( + workspaceFolder().uri.fsPath, + 'rstack.config.ts', + ); + const original = fs.readFileSync(configPath, 'utf8'); + const document = await vscode.workspace.openTextDocument( + path.join(workspaceFolder().uri.fsPath, 'src', 'index.ts'), + ); + try { + // The sandbox is a private copy, so mutating the config here is safe. + fs.writeFileSync( + configPath, + original.replace(`'no-debugger': 'error'`, `'no-debugger': 'off'`), + 'utf8', + ); + // A `config-change` refresh carrying the same `configPath` — the watch + // glob of a bridged folder includes `rstack.config.*` for exactly this. + await waitForDiagnostics( + document, + (entries) => !entries.some((entry) => /debugger/i.test(entry.message)), + ); + } finally { + fs.writeFileSync(configPath, original, 'utf8'); + } + await waitForDiagnostics(document, (entries) => + entries.some((entry) => /debugger/i.test(entry.message)), + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-rstack-bridge/index.ts b/packages/vscode/e2e/lint/suite-rstack-bridge/index.ts new file mode 100644 index 0000000..fcf947c --- /dev/null +++ b/packages/vscode/e2e/lint/suite-rstack-bridge/index.ts @@ -0,0 +1,6 @@ +import { createRun } from '../runSuite'; + +// A bridged folder lights the lint stack exactly like a native one: the shell +// registers the controller, and only then does the capability gate decide +// whether a language server actually starts. +export const run = createRun(); diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index f86d0d5..2e30d73 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -39,10 +39,12 @@ const SLICES = [ compile: true, }, { - // The ported Rslint suites; `RSTACK_LINT_E2E_SUITES=` filters - // which of them run. + // The ported Rslint suites, plus `suite-rstack-bridge/`, which runs + // against the shared `rstack` fixture (its own install root — the only + // lint workspace whose `node_modules` lives inside the workspace itself). + // `RSTACK_LINT_E2E_SUITES=` filters which of them 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..6e49108 100644 --- a/packages/vscode/e2e/suite/detection.test.ts +++ b/packages/vscode/e2e/suite/detection.test.ts @@ -14,14 +14,15 @@ 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` - * is additionally confirmed by the bin probe (`rstack`'s two bins are `rs` and - * `rstack`). + * The rstack row is the interesting one: a single `rstack.config.*` lights all + * three stacks even though no tool-native config exists. Rslint is lit because + * the folder is a *bridged folder* — the Rstack config sits at the folder root + * and no `rslint.config.*` exists anywhere (whether the language server then + * actually starts is a capability question, asserted in the lint slice's + * `suite-rstack-bridge`). `rs fmt` is additionally confirmed by the bin probe + * (`rstack`'s two bins are `rs` and `rstack`). * * This runs the extension's own `detectFolder` inside the extension host, * against the real fixture workspaces, through the real `workspace.findFiles` @@ -49,7 +50,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/src/detection.ts b/packages/vscode/src/detection.ts index d765af5..62d47c1 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -1,4 +1,12 @@ import vscode from 'vscode'; +// The Ownership rule for lint lives with the bridge that implements it: the +// shell asks it whether the stack lights up, the stack asks it which mode to +// start in. It is a pure module over strings and pulls no `vscode` in, which is +// also why it — not this file — owns the Rstack config name list. +import { + lintIsDetected, + RSTACK_CONFIG_PROBE_ORDER, +} from './stacks/lint/rstackBridge'; import { type DetectionSnapshot, type FolderDetection, @@ -8,23 +16,22 @@ import { } from './types'; /** - * `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()`. + * `rstack.config.*` is a config source for every stack: an rstack-cli user may + * only have `rstack.config.ts` with `define.test()` / `define.fmt()` / + * `define.lint()`. + * + * Rstest and rs fmt are lit by an `rstack.config.*` **anywhere** in the folder. + * Rslint is not: it is lit only for a *bridged folder* — no native + * `rslint.config.*` anywhere plus an `rstack.config.*` at the folder root. That + * asymmetry is the language server's, not a policy choice; the rule and its + * rationale live in `stacks/lint/rstackBridge.ts`, which owns the decision for + * both this pass and the stack itself. * - * 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. + * The names themselves are that module's `RSTACK_CONFIG_PROBE_ORDER` — one + * order-sensitive list for the whole extension, re-exported here under the + * name the watch table and the test stack already use. */ -export const RSTACK_CONFIG_NAMES = [ - 'rstack.config.ts', - 'rstack.config.js', - 'rstack.config.mts', - 'rstack.config.mjs', -] as const; +export const RSTACK_CONFIG_NAMES = RSTACK_CONFIG_PROBE_ORDER; export const RSTACK_CONFIG_GLOB = '**/rstack.config.{ts,js,mts,mjs}'; @@ -189,9 +196,14 @@ export const detectFolder = async ( 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, + // Native config anywhere, or a bridged folder (root `rstack.config.*` + // and no native config anywhere). A `rstack.config.*` in a subdirectory + // alone does not light lint — see `stacks/lint/rstackBridge.ts`. + detected: lintIsDetected({ + folderPath: folder.uri.fsPath, + rslintConfigPaths: rslintConfigFiles.map((uri) => uri.fsPath), + rstackConfigPaths: rstackConfigFiles.map((uri) => uri.fsPath), + }), configFiles: rslintConfigFiles, rstackConfigFiles, }, diff --git a/packages/vscode/src/shared/vendored/loadRstackConfig.ts b/packages/vscode/src/shared/vendored/loadRstackConfig.ts index d17d95f..66dd8d5 100644 --- a/packages/vscode/src/shared/vendored/loadRstackConfig.ts +++ b/packages/vscode/src/shared/vendored/loadRstackConfig.ts @@ -1,14 +1,13 @@ // 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. +// NOTE: no stack evaluates an Rstack config through this copy. The lint bridge +// deliberately does not: it generates a shim that calls the *project's own* +// `rstack/config` export (`stacks/lint/rstackBridge.ts`), so the editor's +// results come from the exact loader the CLI uses instead of a vendored one +// that could drift. The formatter leaves `define.fmt()` evaluation to the CLI. +// This copy remains for `nativeTypeStrippingAvailable` — the probe behind both +// loader preflights — 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: diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index 95229e4..beb3a10 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -150,9 +150,16 @@ export const reportVersionCheck = ( * the package version, so `semver.satisfies` is necessary but * not sufficient: the client additionally validates the `protocolVersion` * carried by `rslint/configRefresh`. + * + * Version 2 adds one optional field (`configPath`) to that request and changes + * nothing else, so a v2 loader is served correctly by the same reverse-request + * handlers as a v1 one — omitting the field reproduces v1 behaviour exactly. + * It is listed here because it is the version the lint × Rstack config bridge + * needs (`stacks/lint/rstackBridge.ts`); refusing it would break native mode on + * the very releases that make the bridge possible. */ export const SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS: ReadonlySet = - new Set([1]); + new Set([1, 2]); export const isSupportedConfigDiscoveryProtocolVersion = ( version: number, diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 5acb2bc..37602f4 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -14,20 +14,22 @@ // 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. +// 5. watch glob — `CONFIG_REFRESH_WATCH_GLOB` kept verbatim; bridged folders +// install `BRIDGED_CONFIG_REFRESH_WATCH_GLOB` instead. +// 6. the Rstack config bridge — everything between a +// `--- rstack config bridge ---` marker pair. A *bridged folder* (no native +// `rslint.config.*` anywhere, an `rstack.config.*` at the folder root) gets +// a *generated shim* written into the project, and the language server is +// pinned to it through the optional `configPath` field of +// `rslint/configRefresh` (config-discovery protocol >= 2, rslint PR #1630). +// The choice is fixed for the server process lifetime: the same value on +// every refresh, and a mode flip is a restart, not a message. Native mode is +// byte-identical to upstream — the `configPath` key is absent entirely, so +// the server keeps doing automatic discovery. The rule, the shim and the +// capability gate live in `rstackBridge.ts`; this file only wires them. // -// 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. +// Plus the three compatibility diagnostics: the config-discovery protocol +// handshake, the jiti preflight, and the rstack-loader preflight. import { workspace, @@ -98,6 +100,23 @@ import { isJitiMissingError, JITI_INSTALL_HINT, } from './jitiPreflight'; +// --- rstack config bridge --- +import { nativeTypeStrippingAvailable } from '../../shared/vendored/loadRstackConfig'; +import { + decideLintConfigMode, + describeRstackConfigLoaderPreflight, + formatBridgeProtocolGate, + formatBridgeToolchainGap, + removeGeneratedShim, + resolveRstackConfigLoader, + RSTACK_CONFIG_PROBE_ORDER, + RstackBridgeGateError, + supportsExplicitConfigPath, + writeGeneratedShim, + type GeneratedShim, + type LintConfigMode, +} from './rstackBridge'; +// --- end rstack config bridge --- /** * Workspace-relative lockfiles whose individual metadata feeds the * plugin-host fingerprint. A dependency install can swap a plugin's @@ -123,13 +142,32 @@ 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. +// Upstream's glob, kept verbatim: this is what a native-mode folder watches. export const CONFIG_REFRESH_WATCH_GLOB = `**/{${[ ...RSLINT_CONFIG_WATCH_NAMES, ...LOCKFILE_NAMES, ].join(',')}}`; +// --- rstack config bridge --- +/** + * The bridged-folder glob: upstream's names plus `rstack.config.*`, whose + * edits are the config source of a bridged folder and must produce the same + * debounced `config-change` refresh. Installed *only* in bridged mode, so a + * native folder's watcher stays exactly upstream's. + * + * One brace group, no nesting — VS Code's glob parser silently fails on nested + * groups. The only list here that is not local is `RSTACK_CONFIG_PROBE_ORDER`, + * whose entries are asserted to be plain file names in + * `tests/stacks/lint/rstackBridge.test.ts`, so appending to it cannot nest a + * group in this glob. + */ +export const BRIDGED_CONFIG_REFRESH_WATCH_GLOB = `**/{${[ + ...RSLINT_CONFIG_WATCH_NAMES, + ...RSTACK_CONFIG_PROBE_ORDER, + ...LOCKFILE_NAMES, +].join(',')}}`; +// --- end rstack config bridge --- + /** * The project's `@rslint/core` is outside the support matrix. * Distinct from a crash so the status bar can show `version mismatch` with the @@ -154,6 +192,16 @@ interface ConfigRefreshRequest { */ protocolVersion: number; reason: ConfigRefreshReason; + // --- rstack config bridge --- + /** + * Absolute native path (not a `file:` URI) of the generated shim in bridged + * mode. Sent on the *first* refresh (reason `initial`) and identically on + * every later one — the server rejects a changed or newly appearing value + * with `InvalidParams`. Absent in native mode, which is what selects the + * server's automatic discovery. + */ + configPath?: string; + // --- end rstack config bridge --- } export type ConfigRefreshRequester = ( @@ -399,6 +447,14 @@ function observeClientStopped( export interface RslintFolderConfigPaths { /** Native `rslint.config.{js,mjs,ts,mts}` files. */ readonly rslintConfigPaths: readonly string[]; + // --- rstack config bridge --- + /** + * Every `rstack.config.*` in the folder — the whole list, not just the root + * one: "a native config anywhere wins" and "the Rstack config must be at the + * root" are one decision, taken in `decideLintConfigMode`. + */ + readonly rstackConfigPaths: readonly string[]; + // --- end rstack config bridge --- } /** @@ -438,6 +494,28 @@ export class Rslint implements Disposable { /** Set by `startImpl` before anything can use a project-resolved module. */ private resolution: RslintResolution | undefined; private configLoader: ConfigLoaderModule | undefined; + // --- rstack config bridge --- + /** + * This lifecycle's bridged state, or `undefined` in native mode. Set once, as + * a whole, before the client starts: the pin must be identical on every + * `rslint/configRefresh` of that server, and the two halves are never + * individually meaningful — a folder is either pinned to a shim generated + * from a known Rstack config, or it is in native mode. + */ + private bridge: + | { + /** The generated shim the server is pinned to. */ + readonly configPath: string; + /** + * The Rstack config the shim was generated from, kept for the *only* + * thing that may rewrite the shim after the start: a dependency change, + * which can delete it (it lives under `node_modules`) or dangle the + * store path baked into it. + */ + readonly rstackConfigPath: string; + } + | undefined; + // --- end rstack config bridge --- /** Non-fatal notes appended to the folder's status detail. */ private statusNotes: string[] = []; private readonly lspOutputChannel: OutputChannel | undefined; @@ -558,7 +636,12 @@ export class Rslint implements Disposable { } if ( error instanceof RslintVersionMismatchError || - error instanceof ConfigDiscoveryProtocolMismatchError + error instanceof ConfigDiscoveryProtocolMismatchError || + // --- rstack config bridge --- + // A refused bridged folder is a package problem with a written-out fix, + // never a crash. + error instanceof RstackBridgeGateError + // --- end rstack config bridge --- ) { this.report({ kind: 'version-mismatch', detail: error.message }); return; @@ -579,10 +662,31 @@ export class Rslint implements Disposable { this.statusNotes = []; this.report({ kind: 'starting' }); + // --- rstack config bridge --- + // Ownership is decided before anything else, because it changes what a + // resolution failure *means*: a bridged folder was lit by an + // `rstack.config.*` alone, where `@rslint/core` is only rstack's transitive + // dependency and an isolated `node_modules` layout legitimately hides it. + // The decision itself is taken once per lifecycle and never revisited — the + // server's explicit-config choice is immutable, so a folder that flips + // Ownership is restarted by the controller instead of re-signalled here. + const bridgeConfigPaths = this.getConfigPaths(); + const mode = decideLintConfigMode({ + folderPath: this.workspaceFolder.uri.fsPath, + rslintConfigPaths: bridgeConfigPaths.rslintConfigPaths, + rstackConfigPaths: bridgeConfigPaths.rstackConfigPaths, + }); + // --- end rstack config bridge --- + // 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); + // --- rstack config bridge --- + // Upstream calls `resolveRslint(this.workspaceFolder, this.logger)` here; + // the wrapper adds nothing but the reclassification of a bridged folder's + // failure. + const resolution = await this.resolveToolchain(mode); + // --- end rstack config bridge --- this.assertStartCurrent(epoch, signal); this.resolution = resolution; this.logger.info( @@ -627,6 +731,31 @@ export class Rslint implements Disposable { this.statusNotes.push(jitiNote); } + // --- rstack config bridge --- + if (mode.kind === 'bridged') { + // Published as one value only once the gate let the folder through, so a + // refused folder holds no half-state. + this.bridge = { + configPath: this.prepareBridgedFolder( + mode, + configLoader, + resolution.coreVersion, + ), + rstackConfigPath: mode.rstackConfigPath, + }; + } else { + this.bridge = undefined; + // A folder that flipped back to native mode keeps no artifact of the mode + // it left: the shim is a file named `rslint.config.mjs` naming a config + // that no longer governs anything. + if (removeGeneratedShim(this.workspaceFolder.uri.fsPath)) { + this.logger.info( + 'Removed the generated Rslint config shim left by an earlier bridged start', + ); + } + } + // --- end rstack config bridge --- + const binPath = resolution.binPath; this.logger.info('Rslint binary path:', binPath); @@ -859,6 +988,131 @@ export class Rslint implements Disposable { } } + // --- rstack config bridge --- + /** + * Upstream's project resolution, plus one reclassification: in a bridged + * folder a missing `@rslint/core` is not a crash. + * + * Nothing in such a folder asked for Rslint by name — detection lit the stack + * off an `rstack.config.*`, and `@rslint/core` reaches the project only as + * rstack's transitive dependency, which an isolated `node_modules` layout + * (pnpm) does not expose. Reporting that as `crashed` would put the loudest + * state in the status bar for a project that is not broken, mask every other + * folder's real state, and never reach the gate message that explains the + * fix. Native mode's failure path is untouched. + */ + private async resolveToolchain( + mode: LintConfigMode, + ): Promise { + try { + return await resolveRslint(this.workspaceFolder, this.logger); + } catch (error) { + if (mode.kind === 'bridged' && error instanceof RslintResolutionError) { + throw new RstackBridgeGateError( + formatBridgeToolchainGap(mode.rstackConfigPath, error.message), + { cause: error }, + ); + } + throw error; + } + } + + /** + * Writes the generated shim from the project's *current* `rstack/config` + * resolution. + * + * The loader is re-resolved on every call by design: its path is realpath'd + * into a version-pinned store, and both callers are moments where an install + * may just have moved it. + */ + private materializeShim(rstackConfigPath: string): GeneratedShim { + const folderPath = this.workspaceFolder.uri.fsPath; + return writeGeneratedShim({ + folderPath, + configLoaderPath: resolveRstackConfigLoader(folderPath), + rstackConfigPath, + }); + } + + /** + * Re-materializes the generated shim under its existing path. + * + * Called on a dependency change and nowhere else. A reinstall can delete the + * shim outright (it lives under `node_modules`) or leave the loader path + * baked into it dangling, since that path is realpath'd into a + * version-pinned store — and the `configPath` the server was pinned to is + * immutable for its process lifetime, so the file has to come back at the + * same path rather than the pin moving to a new one. + */ + private refreshGeneratedShim(): void { + const bridge = this.bridge; + if (!bridge) { + return; + } + try { + const shim = this.materializeShim(bridge.rstackConfigPath); + if (shim.written) { + this.logger.info( + `Re-materialized the generated Rslint config shim after a dependency change: ${shim.path}`, + ); + } + } catch (error) { + // The pin cannot move, so this is as far as recovery goes: say what + // happened and what clears it. + this.logger.error( + 'Failed to re-materialize the generated Rslint config shim', + error, + ); + this.addStatusNote( + 'the generated Rslint config shim could not be rewritten after a dependency change; run Rstack: Restart Rslint', + ); + } + } + + /** + * Prepares a bridged folder and returns the `configPath` its server is + * pinned to. + * + * Order matters. The capability gate runs *first*: without protocol >= 2 + * there is no channel to pin anything to, so writing a shim would leave a + * file behind for a mode that cannot start. The failure is a + * `version mismatch` status, not a crash — the fix is a package upgrade. + */ + private prepareBridgedFolder( + mode: Extract, + configLoader: ConfigLoaderModule, + coreVersion: string | undefined, + ): string { + const protocolVersion = configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION; + if (!supportsExplicitConfigPath(protocolVersion)) { + throw new RstackBridgeGateError( + formatBridgeProtocolGate(protocolVersion, coreVersion), + ); + } + const shim = this.materializeShim(mode.rstackConfigPath); + this.logger.info( + `${shim.written ? 'Wrote' : 'Reused'} the generated Rslint config shim for ${mode.rstackConfigPath}: ${shim.path}`, + ); + + // Compatibility seam (4): the rstack-loader preflight. rstack's loader + // hardcodes native type stripping with no jiti fallback, so a TypeScript + // Rstack config can be unloadable on this host even though the CLI loads + // it fine. + const loaderNote = describeRstackConfigLoaderPreflight({ + rstackConfigPath: mode.rstackConfigPath, + nativeTypeStripping: nativeTypeStrippingAvailable(), + }); + if (loaderNote) { + this.logger.warn(loaderNote); + this.statusNotes.push(loaderNote); + } + this.statusNotes.push( + `linting from ${path.basename(mode.rstackConfigPath)}`, + ); + return shim.path; + } + // --- end rstack config bridge --- + /** * Surfaces the two failure classes that must stay actionable * instead of generic: a config-discovery protocol disagreement and the @@ -904,7 +1158,16 @@ export class Rslint implements Disposable { // 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), + new RelativePattern( + this.workspaceFolder, + // --- rstack config bridge --- + // A bridged folder also watches its config source; native mode keeps + // upstream's glob exactly. + this.bridge === undefined + ? CONFIG_REFRESH_WATCH_GLOB + : BRIDGED_CONFIG_REFRESH_WATCH_GLOB, + // --- end rstack config bridge --- + ), ); const refreshConfig = (uri: Uri) => { const reason = configRefreshReasonForPath(uri.fsPath); @@ -954,9 +1217,26 @@ export class Rslint implements Disposable { ) { return; } + // --- rstack config bridge --- + // The one thing that can invalidate a bridged folder's pin without + // changing it: a reinstall under the shim, whose file the server is about + // to be told to reload. + if (reason === 'dependency-change') { + this.refreshGeneratedShim(); + } + // --- end rstack config bridge --- const request: ConfigRefreshRequest = { protocolVersion: configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION, reason, + // --- rstack config bridge --- + // Spread, not `configPath: this.bridge?.configPath`: an explicit + // `undefined` still serializes as a present key on some transports, + // and "present" is what selects explicit mode. In native mode the + // request must stay byte-identical to upstream's. + ...(this.bridge === undefined + ? {} + : { configPath: this.bridge.configPath }), + // --- end rstack config bridge --- }; await client.sendRequest('rslint/configRefresh', request); }); @@ -1054,6 +1334,11 @@ export class Rslint implements Disposable { this.serverRestartWatcher = undefined; disposeSafely(this.configWatcher); this.configWatcher = undefined; + // --- rstack config bridge --- + // The pin belongs to the server process that is going away; the next start + // decides again from scratch. + this.bridge = undefined; + // --- end rstack config bridge --- disposeSafely(this.configTransactionAdapter); this.configTransactionAdapter = undefined; for (const handler of this.requestHandlers.splice(0)) { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index e29c921..97258c8 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -3,10 +3,16 @@ import type { DetectionSnapshot, StackContext, StackController, + StackDetection, StackState, } from '../../types'; import { Logger } from './logger'; import { Rslint, type RslintFolderConfigPaths } from './Rslint'; +import { + decideLintConfigMode, + lintConfigModeSignature, + RstackBridgeGateError, +} from './rstackBridge'; import { WorkspaceDocumentRouter } from './WorkspaceDocumentRouter'; import { WorkspaceRslintCoordinator, @@ -37,6 +43,15 @@ const STATE_RANK: Readonly> = { interface FolderStatus { readonly name: string; readonly state: StackState; + /** + * The config mode the folder's runtime was started in + * (`stacks/lint/rstackBridge.ts`). A language server's explicit-config choice + * is fixed for its process lifetime, so a folder whose Ownership flipped — + * an `rslint.config.*` appearing in or vanishing from a bridged folder, a + * root `rstack.config.*` being renamed — needs a *restart*, which a plain + * reconcile deliberately never does (it leaves live runtimes alone). + */ + readonly configMode: string; } const detailOf = (state: StackState): string | undefined => { @@ -61,7 +76,9 @@ const detailOf = (state: StackState): string | undefined => { * is not actionable. */ export const aggregateFolderStates = ( - statuses: readonly FolderStatus[], + // Only the two fields it actually folds: the mode signature riding along on + // `FolderStatus` is restart bookkeeping and no input to the status bar. + statuses: readonly Pick[], ): StackState => { if (statuses.length === 0) { return { kind: 'starting' }; @@ -107,6 +124,33 @@ export const aggregateFolderStates = ( } }; +const configPathsOf = ( + detection: StackDetection | undefined, +): RslintFolderConfigPaths => ({ + rslintConfigPaths: (detection?.configFiles ?? []).map((uri) => uri.fsPath), + rstackConfigPaths: (detection?.rstackConfigFiles ?? []).map( + (uri) => uri.fsPath, + ), +}); + +const configModeSignatureOf = ( + folder: vscode.WorkspaceFolder, + paths: RslintFolderConfigPaths, +): string => + lintConfigModeSignature( + decideLintConfigMode({ + folderPath: folder.uri.fsPath, + rslintConfigPaths: paths.rslintConfigPaths, + rstackConfigPaths: paths.rstackConfigPaths, + }), + ); + +interface DetectedLintFolder { + readonly folder: vscode.WorkspaceFolder; + /** `lintConfigModeSignature` of the mode this folder starts in. */ + readonly configMode: string; +} + class RslintController implements StackController { readonly id = 'rslint' as const; // A `binPath`/`customBinPath` change must re-resolve the binary, which only @@ -166,22 +210,28 @@ class RslintController implements StackController { }; } - /** The workspace folders detection lit up for Rslint. */ - private detectedFolders(): vscode.WorkspaceFolder[] { - return (this.#snapshot?.foldersFor('rslint') ?? []).map( - (entry) => entry.folder, - ); + /** + * The workspace folders detection lit up for Rslint, each with the config + * mode it would start in. + * + * Both come out of the same detection entry in one pass: the entry already + * carries the folder *and* its config file lists, so the mode signature costs + * nothing beyond the decision itself. + */ + private detectedFolders(): DetectedLintFolder[] { + return (this.#snapshot?.foldersFor('rslint') ?? []).map((entry) => ({ + folder: entry.folder, + configMode: configModeSignatureOf( + entry.folder, + configPathsOf(entry.stacks.rslint), + ), + })); } private configPathsFor( folder: vscode.WorkspaceFolder, ): RslintFolderConfigPaths { - const detection = this.#snapshot?.forFolder(folder)?.stacks.rslint; - return { - rslintConfigPaths: (detection?.configFiles ?? []).map( - (uri) => uri.fsPath, - ), - }; + return configPathsOf(this.#snapshot?.forFolder(folder)?.stacks.rslint); } private startCoordinator(): void { @@ -209,19 +259,28 @@ class RslintController implements StackController { getConfigPaths: () => this.configPathsFor(workspaceFolder), }), logger, + // A bridge-eligible folder whose `@rslint/core` is missing or too old to + // be pinned to a config deliberately does not start; the user sees the + // reason as a `version mismatch` status, so logging it as a failure would + // only add noise. + (error) => error instanceof RstackBridgeGateError, ); this.#coordinator = coordinator; - const folders = this.detectedFolders(); - for (const folder of folders) { - this.setFolderState(workspaceRootKey(folder), folder.name, { - kind: 'starting', - }); + const detected = this.detectedFolders(); + for (const { folder, configMode } of detected) { + this.setFolderState( + workspaceRootKey(folder), + folder.name, + { kind: 'starting' }, + configMode, + ); } // Adaptation #1: activation must not wait for a language server. Upstream // awaits this promise (and rejects activation when every root fails); here // failures are reported per folder through the status reporter. + const folders = detected.map((entry) => entry.folder); void coordinator.initialize(folders).catch((error: unknown) => { if (coordinator !== this.#coordinator) { return; @@ -230,37 +289,82 @@ class RslintController implements StackController { }); } + /** + * Brings the tracked folders back in line with detection — and carries the + * config-mode flip with it. + * + * The flip is not a second pass: this one already walks every detected folder + * with its mode signature in hand, prunes the folders that vanished, and ends + * in exactly one `handleWorkspaceFoldersChanged`. A flipped folder therefore + * just joins that event as both removed and added, which is the coordinator's + * existing URI-preserving replacement path — the one path that bumps a root's + * generation, closes the live runtime and starts a fresh one, which is + * exactly what an immutable explicit-config choice needs. + */ private reconcileFolders(event: vscode.WorkspaceFoldersChangeEvent): void { const coordinator = this.#coordinator; if (!coordinator || this.#disposed) { return; } - const folders = this.detectedFolders(); - const keys = new Set(folders.map(workspaceRootKey)); + const detected = this.detectedFolders(); + const keys = new Set(); + const flipped: vscode.WorkspaceFolder[] = []; + for (const { folder, configMode } of detected) { + const key = workspaceRootKey(folder); + keys.add(key); + const previous = this.#folderStates.get(key); + if (previous === undefined) { + // A root with no runtime yet cannot have flipped: record the mode it + // is about to start in, so only later changes count as a flip. + this.setFolderState(key, folder.name, { kind: 'starting' }, configMode); + continue; + } + if (previous.configMode === configMode) { + continue; + } + this.#logger?.info( + `Lint config mode changed for ${folder.name} (${previous.configMode} -> ${configMode}); restarting its language server`, + ); + this.#folderStates.set(key, { ...previous, configMode }); + flipped.push(folder); + } for (const key of [...this.#folderStates.keys()]) { if (!keys.has(key)) { this.#folderStates.delete(key); } } - for (const folder of folders) { - const key = workspaceRootKey(folder); - if (!this.#folderStates.has(key)) { - this.setFolderState(key, folder.name, { kind: 'starting' }); - } - } this.publishStatus(); - coordinator.handleWorkspaceFoldersChanged(event, folders); + const folders = detected.map((entry) => entry.folder); + coordinator.handleWorkspaceFoldersChanged( + flipped.length === 0 + ? event + : { + added: [...event.added, ...flipped], + removed: [...event.removed, ...flipped], + }, + folders, + ); } private setFolderState( rootKey: string, name: string, state: StackState, + // Passed only where the mode is decided (a folder being recorded for the + // first time). A status update from a live runtime preserves it: the + // reconcile that recorded the folder owns the value, and it always ran + // before any runtime for that folder existed. + configMode?: string, ): void { if (this.#disposed) { return; } - this.#folderStates.set(rootKey, { name, state }); + this.#folderStates.set(rootKey, { + name, + state, + configMode: + configMode ?? this.#folderStates.get(rootKey)?.configMode ?? '', + }); this.publishStatus(); } diff --git a/packages/vscode/src/stacks/lint/rstackBridge.ts b/packages/vscode/src/stacks/lint/rstackBridge.ts new file mode 100644 index 0000000..ab0ac17 --- /dev/null +++ b/packages/vscode/src/stacks/lint/rstackBridge.ts @@ -0,0 +1,482 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + findPackageJsonUncached, + readPackageJson, +} from '../../shared/packageResolve'; +// The sibling preflight owns the "is this config TypeScript" predicate; both +// seams answer the same question about the same host, so they share one +// definition rather than two regexes that can drift. `jitiPreflight` imports +// `vscode` for types only, so this stays a `vscode`-free module graph. +import { isTypeScriptConfigPath } from './jitiPreflight'; + +/** + * The lint × Rstack config bridge. + * + * A **bridged folder** is a workspace folder where + * + * 1. no native `rslint.config.{js,mjs,ts,mts}` exists *anywhere* in the folder, + * and + * 2. an `rstack.config.{ts,js,mts,mjs}` sits at the folder **root**. + * + * Only then does lint light up through the Rstack config. Ownership is decided + * per folder, not per directory: any native Rslint config anywhere in the + * folder means pure native mode and the bridge yields entirely — no hint, no + * warning. That granularity is forced by the LSP, not chosen: the + * explicit-config choice is fixed for the whole server process lifetime, there + * is one language server per workspace folder, and the server's cwd is the + * folder root. A `rstack.config.*` in a **subdirectory** therefore does not + * light lint at all; the folder root is the only position the bridge can + * honour. + * + * The bridge never re-implements Rstack config semantics. It writes a + * **generated shim** — a tiny ESM module inside the project — that calls the + * project's *own* `rstack` package through its published `./config` export and + * re-exports `configs.lint`, mirroring `rs lint`'s injected + * `/dist/rslintConfig.js` exactly. rstack's shipped shim cannot be used + * directly: it calls `loadRstackConfig()` with no arguments, which probes the + * **evaluation** cwd — and config evaluation happens in the extension host, + * whose cwd is meaningless. The generated shim bakes the absolute config path + * in instead. + * + * Everything here is a pure function over strings plus two filesystem + * primitives, so the whole decision table is unit-testable without a `vscode` + * stub. + */ + +/** + * The Rstack config file names in `loadRstackConfig`'s own probe order + * (`rstack.config.ts`, `.js`, `.mts`, `.mjs` — verified against + * `RSTACK_CONFIG_FILE_NAMES` in `shared/vendored/loadRstackConfig.ts`, the + * vendored copy of that loader). + * + * The order carries meaning: it decides which file the generated shim points at + * when a folder root somehow holds more than one, so the answer is stable + * instead of filesystem-order dependent. + * + * This is also the single owner of the name list for the whole extension — + * `detection.ts` re-exports it as `RSTACK_CONFIG_NAMES` for the watch table. + * This module is the one that can own it: it pulls in no `vscode`. + */ +export const RSTACK_CONFIG_PROBE_ORDER = [ + 'rstack.config.ts', + 'rstack.config.js', + 'rstack.config.mts', + 'rstack.config.mjs', +] as const; + +/** + * Where the generated shim goes: inside the project, so module and plugin + * resolution from the shim anchors on the project rather than on the + * extension. + * + * A bridged folder normally already has a `node_modules` — `rstack` has to be + * installed for the bridge to work at all — but that is a tendency, not an + * invariant: `rstack` is resolved by a `node_modules` walk-*up*, so a package + * inside a hoisting monorepo can be bridged with its own `node_modules` + * absent. The directory is then created, deliberately: the alternative is + * writing the shim to a path the project's own resolver cannot see through, + * and `node_modules/.cache/` is the conventional home for exactly this kind of + * tool-generated file. + */ +const GENERATED_SHIM_RELATIVE_DIR = path.join( + 'node_modules', + '.cache', + 'rstack-editor', +); + +/** + * `.mjs`, and named like a config on purpose: the language server accepts a + * JS/TS config path, and an ESM extension keeps the shim independent of the + * project's `package.json` `type` field. + * + * Living under `node_modules` is what keeps the name safe. Detection excludes + * every `node_modules` path outright, so the generated shim can never be + * mistaken for a native config — which would flip Ownership to native and + * restart the folder into the mode that deletes the shim's reason to exist. The + * config-refresh watcher is separately protected by being written before it is + * installed, and by only being rewritten when its content actually changes. + */ +const GENERATED_SHIM_BASENAME = 'rslint.config.mjs'; + +/** + * The config-discovery protocol version that first carries the optional + * `configPath` field on `rslint/configRefresh` (rslint PR #1630). Bridged mode + * is gated on the *capability*, read from the project's own + * `@rslint/core/config-loader` constant, rather than on a release number — + * the release containing the PR is not known here, and guessing one would + * either strand users or start a mode the server cannot honour. + */ +export const BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION = 2; + +/** A bridged folder whose prerequisites could not be met. */ +export class RstackBridgeError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'RstackBridgeError'; + } +} + +/** + * A bridged folder the extension refuses to start, because the project's own + * packages cannot support explicit mode. Distinct from `RstackBridgeError` (and + * from a crash) so it can be reported as `version mismatch` — the state whose + * whole meaning is "the project's packages need attention", and whose detail is + * the instruction. + * + * The distinction matters most where detection is the *only* reason the stack + * exists: a bridged folder was lit by an `rstack.config.*`, so its user never + * asked for Rslint by name and must not be shown a crash. + * + * Two gates raise it, and they differ only in their message — one class, + * because that is the whole granularity anything consumes (`Rslint`'s + * start-failure classification and the coordinator's expected-failure + * predicate both test exactly this type): + * + * 1. the **capability** gate — the project's `@rslint/core` speaks a + * config-discovery protocol with no `configPath`, so there is no channel to + * pin a config to (`formatBridgeProtocolGate`); + * 2. the **toolchain** gate — the folder is bridge-eligible but `@rslint/core` + * does not resolve from it at all (`formatBridgeToolchainGap`). That is the + * *common* shape, not an edge case: `@rslint/core` is a transitive + * dependency of `rstack`, and an isolated `node_modules` layout (pnpm by + * default) deliberately does not expose transitive dependencies, so the + * mainstream bridged project — an rstack-cli app whose only config is + * `rstack.config.ts` — cannot resolve it. Native mode's identical failure + * stays a crash: there the user wrote an `rslint.config.*`, an explicit + * request for a tool that is missing. + */ +export class RstackBridgeGateError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'RstackBridgeGateError'; + } +} + +export const formatBridgeToolchainGap = ( + rstackConfigPath: string, + detail: string, +): string => + `linting from ${path.basename( + rstackConfigPath, + )} needs @rslint/core installed in the project: ${detail} rstack depends on @rslint/core only transitively, and package managers with an isolated node_modules layout (pnpm) do not expose transitive dependencies — add @rslint/core to the project's devDependencies, or add an rslint.config.* file`; + +export type LintConfigMode = + /** Automatic server-side discovery — byte-identical to upstream. */ + | { readonly kind: 'native' } + | { readonly kind: 'bridged'; readonly rstackConfigPath: string }; + +export interface LintConfigModeInput { + /** Absolute path of the workspace folder root. */ + readonly folderPath: string; + /** Every native `rslint.config.*` found anywhere in the folder. */ + readonly rslintConfigPaths: readonly string[]; + /** Every `rstack.config.*` found anywhere in the folder. */ + readonly rstackConfigPaths: readonly string[]; +} + +/** + * The folder-root Rstack config, in `loadRstackConfig`'s probe order. + * `undefined` when every candidate sits in a subdirectory — or carries a name + * outside the probe order, which the loader would not find either. + */ +export const folderRootRstackConfigPath = ( + folderPath: string, + rstackConfigPaths: readonly string[], +): string | undefined => { + const root = path.resolve(folderPath); + const atRoot = rstackConfigPaths.filter( + (candidate) => path.resolve(path.dirname(candidate)) === root, + ); + for (const name of RSTACK_CONFIG_PROBE_ORDER) { + const match = atRoot.find((candidate) => path.basename(candidate) === name); + if (match) return match; + } + return undefined; +}; + +/** + * The Ownership decision for one workspace folder. Native wins whenever a + * native config exists anywhere; a folder with neither signal stays `native`, + * which for the client means "send no `configPath`" — exactly today's + * behaviour. + */ +export const decideLintConfigMode = ( + input: LintConfigModeInput, +): LintConfigMode => { + if (input.rslintConfigPaths.length > 0) { + return { kind: 'native' }; + } + const rstackConfigPath = folderRootRstackConfigPath( + input.folderPath, + input.rstackConfigPaths, + ); + return rstackConfigPath === undefined + ? { kind: 'native' } + : { kind: 'bridged', rstackConfigPath }; +}; + +/** + * Whether detection must light the lint stack for this folder: a native config + * anywhere, or a bridged folder. One rule, one place — the shell's detection + * and the stack's mode selection are the same decision seen twice. + */ +export const lintIsDetected = (input: LintConfigModeInput): boolean => + input.rslintConfigPaths.length > 0 || + decideLintConfigMode(input).kind === 'bridged'; + +/** + * A stable identity for the mode a folder is in. A change means the live + * language server is pinned the wrong way and has to be restarted — the choice + * is immutable for a server process. + */ +export const lintConfigModeSignature = (mode: LintConfigMode): string => + mode.kind === 'bridged' ? `bridged:${mode.rstackConfigPath}` : 'native'; + +export const supportsExplicitConfigPath = (protocolVersion: number): boolean => + protocolVersion >= BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION; + +export const formatBridgeProtocolGate = ( + protocolVersion: number, + coreVersion: string | undefined, +): string => + `linting from rstack.config.* needs a newer @rslint/core: the project's @rslint/core ${ + coreVersion ?? 'of unknown version' + } speaks config-discovery protocol ${String(protocolVersion)}, and pinning the language server to a config needs protocol ${String( + BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION, + )} or newer — upgrade @rslint/core, or add an rslint.config.* file`; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +/** + * The export conditions an ESM `import` of the generated shim matches, most + * specific first. `require` is deliberately absent: the shim is `.mjs` and + * imports the target by `file:` URL, so taking a `require` branch would pin it + * to a CommonJS build the moment `rstack` splits the entry. + */ +const IMPORT_EXPORT_CONDITIONS = [ + 'import', + 'node', + 'module', + 'default', +] as const; + +/** + * The target an ESM importer would take out of one `exports` entry: a string + * is the target, an object is a condition map (recursed into, conditions in + * the order above), an array is a fallback list (first entry that yields one). + * + * Not `createRequire(...).resolve('rstack/config')`: Node's require-resolution + * matches the `require` conditions, while the generated shim is ESM. + */ +const pickImportTarget = (entry: unknown): string | undefined => { + if (typeof entry === 'string') return entry; + if (Array.isArray(entry)) { + for (const candidate of entry) { + const target = pickImportTarget(candidate); + if (target !== undefined) return target; + } + return undefined; + } + if (!isRecord(entry)) return undefined; + for (const condition of IMPORT_EXPORT_CONDITIONS) { + const target = pickImportTarget(entry[condition]); + if (target !== undefined) return target; + } + return undefined; +}; + +/** The raw `./config` entry of `rstack`'s `exports` map, if it declares one. */ +const readConfigExportEntry = ( + packageJson: Record, +): unknown => { + const exportsField = packageJson.exports; + return isRecord(exportsField) ? exportsField['./config'] : undefined; +}; + +/** + * Resolves the project's own `rstack/config` entry (`dist/configExports.js`) + * to an absolute path. + * + * The `package.json` is located by an uncached `node_modules` walk-up, not by + * `require.resolve`: a fresh install has to be picked up without a window + * reload, and Node caches successful resolutions for the process lifetime. The + * entry itself is then read out of the package's own `exports` map, so a + * future layout change in `rstack` follows automatically. + */ +export const resolveRstackConfigLoader = (folderPath: string): string => { + const packageJsonPath = findPackageJsonUncached('rstack', folderPath); + if (packageJsonPath === undefined) { + throw new RstackBridgeError( + `Could not resolve the "rstack" package from ${folderPath}. Linting from rstack.config.* needs the project's own rstack install — run the project's package manager.`, + ); + } + const packageJson = readPackageJson(packageJsonPath); + const entry = packageJson ? readConfigExportEntry(packageJson) : undefined; + if (entry === undefined) { + throw new RstackBridgeError( + `The installed "rstack" (${packageJsonPath}) does not export "./config". Upgrade rstack to a version that publishes the config loader (>= 0.4.0), or add an rslint.config.* file.`, + ); + } + // An entry that exists but yields nothing is a shape this extension does not + // understand, not an outdated install — saying "upgrade" would send the user + // somewhere that cannot help. + const target = pickImportTarget(entry); + if (target === undefined) { + throw new RstackBridgeError( + `The installed "rstack" (${packageJsonPath}) exports "./config" as ${JSON.stringify( + entry, + )}, which has no target an ESM import can take. Add an rslint.config.* file, or report this against the Rstack extension.`, + ); + } + const loaderPath = path.resolve(path.dirname(packageJsonPath), target); + if (!fs.existsSync(loaderPath)) { + throw new RstackBridgeError( + `The installed "rstack" exports "./config" as ${target}, which is missing at ${loaderPath}.`, + ); + } + return loaderPath; +}; + +export interface GeneratedShimInput { + /** Absolute path of the project's `rstack/config` entry. */ + readonly configLoaderPath: string; + /** Absolute path of the folder-root `rstack.config.*`. */ + readonly rstackConfigPath: string; +} + +/** + * Renders the generated shim. + * + * The body mirrors rstack's shipped `dist/rslintConfig.js`: take + * `configs.lint ?? []`, await it when it is a function, default-export the + * result. `define.lint`'s value *is* an Rslint flat config — there is no + * translation step and there must never be one. + * + * Both baked-in paths are absolute and both go through an escaping step, so a + * Windows path (`C:\Users\…`) survives verbatim: the loader is embedded as a + * `file:` URL (forward slashes, percent-encoded) and the config path as a JSON + * string literal (backslashes escaped). + */ +export const renderGeneratedShim = ({ + configLoaderPath, + rstackConfigPath, +}: GeneratedShimInput): string => + `// Generated by the Rstack VS Code extension — do not edit, do not commit. +// +// This folder is linted from an Rstack config: no rslint.config.* exists and +// ${path.basename(rstackConfigPath)} sits at the folder root. The language +// server is pinned to this file, which is the editor-side equivalent of the +// shim \`rs lint\` injects, with the config path baked in — config evaluation +// happens in the VS Code extension host, whose cwd cannot be probed for it. +import { loadRstackConfig } from ${JSON.stringify(pathToFileURL(configLoaderPath).href)}; + +const { configs } = await loadRstackConfig({ + configFilePath: ${JSON.stringify(rstackConfigPath)}, +}); +const lintExports = configs.lint ?? []; +export default typeof lintExports === 'function' + ? await lintExports() + : lintExports; +`; + +export interface WriteGeneratedShimInput extends GeneratedShimInput { + /** Absolute path of the workspace folder root. */ + readonly folderPath: string; +} + +export interface GeneratedShim { + /** Absolute path of the shim — what the client sends as `configPath`. */ + readonly path: string; + /** False when the file was already byte-identical. */ + readonly written: boolean; +} + +export const generatedShimPath = (folderPath: string): string => + path.join(folderPath, GENERATED_SHIM_RELATIVE_DIR, GENERATED_SHIM_BASENAME); + +/** + * Writes the generated shim, but only when its content actually differs. + * + * The no-churn rule is not tidiness: the shim lives under a path the config + * watcher can observe, and every rewrite is a config mutation the language + * server would have to reload. + */ +export const writeGeneratedShim = ({ + folderPath, + configLoaderPath, + rstackConfigPath, +}: WriteGeneratedShimInput): GeneratedShim => { + const shimPath = generatedShimPath(folderPath); + const source = renderGeneratedShim({ configLoaderPath, rstackConfigPath }); + let current: string | undefined; + try { + current = fs.readFileSync(shimPath, 'utf8'); + } catch { + // Absent or unreadable: write it. + } + if (current === source) { + return { path: shimPath, written: false }; + } + try { + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(shimPath, source, 'utf8'); + } catch (error) { + throw new RstackBridgeError( + `Could not write the generated Rslint config shim to ${shimPath}.`, + { cause: error }, + ); + } + return { path: shimPath, written: true }; +}; + +/** + * Deletes a generated shim left behind by an earlier bridged lifecycle, and + * reports whether there was one. + * + * A folder flips to native mode the moment an `rslint.config.*` appears, and + * the artifact must not outlive the mode that justified it: it is a file + * literally named `rslint.config.mjs` sitting in the project, naming a config + * path that is no longer the governing one. Detection cannot be confused by it + * (`node_modules` is excluded outright), which is why this is hygiene rather + * than correctness — but hygiene the extension owes for a file it wrote. + */ +export const removeGeneratedShim = (folderPath: string): boolean => { + const shimPath = generatedShimPath(folderPath); + try { + fs.rmSync(shimPath); + return true; + } catch { + // Absent (the overwhelmingly common case) or not ours to delete. + return false; + } +}; + +export interface RstackConfigLoaderPreflightInput { + readonly rstackConfigPath: string; + /** `nativeTypeStrippingAvailable()` on the extension host. */ + readonly nativeTypeStripping: boolean; +} + +/** + * The rstack-loader seam of the version-compatibility contract, the sibling of + * the jiti preflight. + * + * `loadRstackConfig` hardcodes `loader: 'native'` and has **no** jiti + * fallback, so a TypeScript Rstack config cannot be loaded at all on a VS Code + * build whose Node cannot strip types. The CLI never hits this (it runs on the + * user's own Node); the editor can. Diagnostics only — the loader behaviour is + * deliberately left alone. + */ +export const describeRstackConfigLoaderPreflight = ({ + rstackConfigPath, + nativeTypeStripping, +}: RstackConfigLoaderPreflightInput): string | undefined => { + if (nativeTypeStripping) return undefined; + if (!isTypeScriptConfigPath(rstackConfigPath)) return undefined; + return `this VS Code build cannot strip TypeScript types, and rstack's config loader has no fallback, so ${path.basename( + rstackConfigPath, + )} may fail to load: rename it to rstack.config.js/.mjs, or add an rslint.config.* file`; +}; diff --git a/packages/vscode/tests/lintDetection.test.ts b/packages/vscode/tests/lintDetection.test.ts new file mode 100644 index 0000000..e0ff5f8 --- /dev/null +++ b/packages/vscode/tests/lintDetection.test.ts @@ -0,0 +1,126 @@ +import path from 'node:path'; +import { beforeEach, describe, expect, it, rs } from '@rstest/core'; +import type vscode from 'vscode'; + +/** + * Detection's lint row, end to end through `detectFolder` itself. + * + * `tests/stacks/lint/rstackBridge.test.ts` covers the Ownership rule as a pure + * decision table; this file covers the *wiring* — that the shell feeds the rule + * the right two file lists and reports its answer as `stacks.rslint.detected`. + * `detectFolder` only exists against `workspace.findFiles`, so the namespace is + * stubbed with a glob-keyed file table (the E2E `vscode` slice runs the same + * function against real fixtures and a real file index). + */ + +interface FileTable { + [glob: string]: string[]; +} + +const state: { files: FileTable; bins: string[] } = { files: {}, bins: [] }; + +rs.mock('vscode', () => { + const toUri = (fsPath: string) => ({ + fsPath, + path: fsPath, + scheme: 'file', + toString: () => `file://${fsPath}`, + }); + const vscodeStub = { + Uri: { + file: toUri, + joinPath: (base: { fsPath: string }, ...segments: string[]) => + toUri(path.join(base.fsPath, ...segments)), + }, + RelativePattern: class { + constructor( + readonly base: unknown, + readonly pattern: string, + ) {} + }, + EventEmitter: class { + readonly event = () => ({ dispose: () => undefined }); + fire(): void {} + dispose(): void {} + }, + workspace: { + workspaceFolders: undefined, + onDidChangeWorkspaceFolders: () => ({ dispose: () => undefined }), + onDidChangeConfiguration: () => ({ dispose: () => undefined }), + getConfiguration: () => ({ get: () => undefined }), + findFiles: (pattern: { pattern: string }) => + Promise.resolve((state.files[pattern.pattern] ?? []).map(toUri)), + fs: { + stat: (uri: { fsPath: string }) => + state.bins.includes(uri.fsPath) + ? Promise.resolve({}) + : Promise.reject(new Error('ENOENT')), + }, + }, + }; + return { ...vscodeStub, default: vscodeStub }; +}); + +import { + detectFolder, + RSLINT_CONFIG_GLOB, + RSTACK_CONFIG_GLOB, +} from '../src/detection'; + +const FOLDER_PATH = path.resolve('/projects/app'); +const folder = { + uri: { fsPath: FOLDER_PATH, path: FOLDER_PATH, toString: () => 'file://app' }, + name: 'app', + index: 0, +} as unknown as vscode.WorkspaceFolder; + +const at = (...segments: string[]): string => + path.join(FOLDER_PATH, ...segments); + +const withFiles = (files: { rslint?: string[]; rstack?: string[] }): void => { + state.files = { + [RSLINT_CONFIG_GLOB]: files.rslint ?? [], + [RSTACK_CONFIG_GLOB]: files.rstack ?? [], + }; +}; + +describe('detection lights Rslint', () => { + beforeEach(() => { + state.files = {}; + state.bins = []; + }); + + it('for a native config anywhere', async () => { + withFiles({ rslint: [at('packages', 'ui', 'rslint.config.mjs')] }); + const detection = await detectFolder(folder); + expect(detection.stacks.rslint.detected).toBe(true); + }); + + it('for a bridged folder: root rstack.config.* and no native config', async () => { + withFiles({ rstack: [at('rstack.config.ts')] }); + const detection = await detectFolder(folder); + expect(detection.stacks.rslint.detected).toBe(true); + // The lint row still reports *native* configs in `configFiles`; the Rstack + // config reaches the stack through `rstackConfigFiles`. + expect(detection.stacks.rslint.configFiles).toEqual([]); + expect( + detection.stacks.rslint.rstackConfigFiles.map((uri) => uri.fsPath), + ).toEqual([at('rstack.config.ts')]); + }); + + it('not for a rstack.config.* that only sits in a subdirectory', async () => { + withFiles({ rstack: [at('packages', 'ui', 'rstack.config.ts')] }); + const detection = await detectFolder(folder); + expect(detection.stacks.rslint.detected).toBe(false); + // The other two stacks are lit by a Rstack config anywhere — the + // asymmetry is the language server's, and this is where it shows. + expect(detection.stacks.rstest.detected).toBe(true); + expect(detection.stacks.fmt.detected).toBe(true); + }); + + it('not for a folder with no config at all', async () => { + withFiles({}); + const detection = await detectFolder(folder); + expect(detection.stacks.rslint.detected).toBe(false); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/rstackBridge.test.ts b/packages/vscode/tests/stacks/lint/rstackBridge.test.ts new file mode 100644 index 0000000..a824579 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/rstackBridge.test.ts @@ -0,0 +1,462 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterAll, describe, expect, it } from '@rstest/core'; +import { + BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION, + decideLintConfigMode, + describeRstackConfigLoaderPreflight, + folderRootRstackConfigPath, + formatBridgeProtocolGate, + formatBridgeToolchainGap, + generatedShimPath, + lintConfigModeSignature, + lintIsDetected, + removeGeneratedShim, + renderGeneratedShim, + resolveRstackConfigLoader, + RSTACK_CONFIG_PROBE_ORDER, + RstackBridgeError, + RstackBridgeGateError, + supportsExplicitConfigPath, + writeGeneratedShim, +} from '../../../src/stacks/lint/rstackBridge'; +import { isSupportedConfigDiscoveryProtocolVersion } from '../../../src/shared/versionCheck'; + +/** + * `rstackBridge.ts` is deliberately a pure module over strings plus two + * filesystem primitives, so the whole Ownership decision table is exercised + * here without a `vscode` stub or an extension host. + */ + +const FOLDER = path.resolve('/projects/app'); +const at = (...segments: string[]): string => path.join(FOLDER, ...segments); + +describe('Ownership: which config source lints a folder', () => { + it('bridges a folder whose only config is a root rstack.config.*', () => { + const mode = decideLintConfigMode({ + folderPath: FOLDER, + rslintConfigPaths: [], + rstackConfigPaths: [at('rstack.config.ts')], + }); + expect(mode).toEqual({ + kind: 'bridged', + rstackConfigPath: at('rstack.config.ts'), + }); + expect( + lintIsDetected({ + folderPath: FOLDER, + rslintConfigPaths: [], + rstackConfigPaths: [at('rstack.config.ts')], + }), + ).toBe(true); + }); + + it('yields to a native config anywhere in the folder, root or not', () => { + // Folder-level Ownership, not directory-level: one native config in a + // nested package puts the whole folder in native mode, with no hint and no + // warning, because the server's config choice is fixed per folder. + const input = { + folderPath: FOLDER, + rslintConfigPaths: [at('packages', 'ui', 'rslint.config.mjs')], + rstackConfigPaths: [at('rstack.config.ts')], + }; + expect(decideLintConfigMode(input)).toEqual({ kind: 'native' }); + expect(lintIsDetected(input)).toBe(true); + }); + + it('does not light lint for a rstack.config.* below the folder root', () => { + const input = { + folderPath: FOLDER, + rslintConfigPaths: [], + rstackConfigPaths: [at('packages', 'ui', 'rstack.config.ts')], + }; + // A documented limitation: one language server per folder, its cwd is the + // folder root, so a subdirectory config has no position to be pinned from. + expect(decideLintConfigMode(input)).toEqual({ kind: 'native' }); + expect(lintIsDetected(input)).toBe(false); + }); + + it('stays in native mode when the folder has no config at all', () => { + const input = { + folderPath: FOLDER, + rslintConfigPaths: [], + rstackConfigPaths: [], + }; + expect(decideLintConfigMode(input)).toEqual({ kind: 'native' }); + expect(lintIsDetected(input)).toBe(false); + }); + + it('picks the root config by loadRstackConfig probe order', () => { + expect( + folderRootRstackConfigPath(FOLDER, [ + at('rstack.config.mjs'), + at('nested', 'rstack.config.ts'), + at('rstack.config.js'), + at('rstack.config.ts'), + ]), + ).toBe(at('rstack.config.ts')); + }); + + it('keeps the probe order glob-safe for the bridged watch pattern', () => { + // `BRIDGED_CONFIG_REFRESH_WATCH_GLOB` folds this list into one brace group, + // and VS Code's glob parser silently fails on a nested group — so an entry + // carrying glob syntax would break watching without any error. + for (const name of RSTACK_CONFIG_PROBE_ORDER) { + expect(name).not.toMatch(/[{},*]/); + } + }); + + it('gives each mode a distinct restart signature', () => { + expect(lintConfigModeSignature({ kind: 'native' })).toBe('native'); + expect( + lintConfigModeSignature({ + kind: 'bridged', + rstackConfigPath: at('rstack.config.ts'), + }), + ).not.toBe( + lintConfigModeSignature({ + kind: 'bridged', + rstackConfigPath: at('rstack.config.js'), + }), + ); + }); +}); + +describe('the capability gate', () => { + it('refuses explicit mode below the protocol that carries configPath', () => { + expect(supportsExplicitConfigPath(1)).toBe(false); + expect( + supportsExplicitConfigPath(BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION), + ).toBe(true); + expect( + supportsExplicitConfigPath( + BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION + 1, + ), + ).toBe(true); + }); + + it('names the package to upgrade and the version actually resolved', () => { + const message = formatBridgeProtocolGate(1, '0.7.3'); + expect(message).toContain('@rslint/core 0.7.3'); + expect(message).toContain('upgrade @rslint/core'); + expect(message).toContain('protocol 1'); + }); + + it('survives an unreadable @rslint/core version', () => { + expect(formatBridgeProtocolGate(1, undefined)).toContain('unknown version'); + }); + + it('names the transitive-dependency trap when @rslint/core is absent', () => { + // The mainstream bridged project is an rstack-cli app on pnpm, where + // `@rslint/core` is rstack's transitive dependency and simply not exposed. + // "Could not resolve @rslint/core" alone reads as a broken extension. + const message = formatBridgeToolchainGap( + at('rstack.config.ts'), + 'Could not resolve @rslint/core from /projects/app.', + ); + expect(message).toContain('rstack.config.ts'); + expect(message).toContain('devDependencies'); + expect(message).toContain('pnpm'); + }); + + it('separates a refusal from a crash', () => { + // `Rslint.reportStartFailure` and the coordinator's expected-failure + // predicate both branch on exactly this class — one type for both gates, + // because that is the whole granularity anything consumes. + expect(new RstackBridgeGateError('gate')).toBeInstanceOf(Error); + // A plain bridge failure is *not* a gate: it stays a crash. + expect(new RstackBridgeError('broken')).not.toBeInstanceOf( + RstackBridgeGateError, + ); + }); + + it('asks for a protocol the copied client itself supports', () => { + // The two constants live on opposite sides of the port: the bridge names + // the protocol it needs, `shared/versionCheck.ts` names what this client + // speaks. Raising the bridge floor past that set would gate every bridged + // folder on a protocol the client would then reject anyway. + expect( + isSupportedConfigDiscoveryProtocolVersion( + BRIDGE_MIN_CONFIG_DISCOVERY_PROTOCOL_VERSION, + ), + ).toBe(true); + }); +}); + +describe('the generated shim', () => { + const loaderPath = at('node_modules', 'rstack', 'dist', 'configExports.js'); + const configPath = at('rstack.config.ts'); + + it('bakes both absolute paths in and mirrors rstack shipped shim', () => { + const source = renderGeneratedShim({ + configLoaderPath: loaderPath, + rstackConfigPath: configPath, + }); + expect(source).toContain(JSON.stringify(pathToFileURL(loaderPath).href)); + expect(source).toContain(JSON.stringify(configPath)); + // The shim body is the mirror of `/dist/rslintConfig.js`: take + // `configs.lint ?? []`, await the function form, default-export it. + expect(source).toContain('configs.lint ?? []'); + expect(source).toContain('await lintExports()'); + expect(source).toContain('export default'); + // Never the no-argument call: it probes the evaluation cwd, which in the + // extension host is meaningless. + expect(source).not.toContain('loadRstackConfig()'); + }); + + it('escapes Windows paths in both baked-in positions', () => { + const source = renderGeneratedShim({ + configLoaderPath: 'C:\\Users\\dev\\app\\node_modules\\rstack\\dist\\c.js', + rstackConfigPath: 'C:\\Users\\dev\\app\\rstack.config.ts', + }); + // In the one position where a raw backslash run *would* be an escape + // sequence — the string literal — every separator is doubled. + expect(source).toContain( + 'configFilePath: "C:\\\\Users\\\\dev\\\\app\\\\rstack.config.ts"', + ); + // The loader is embedded as a `file:` URL, which never carries a raw + // backslash: on Windows `pathToFileURL` turns separators into `/`, and + // anything left over is percent-encoded. (The rendering runs on the host + // that owns the path, so the POSIX result of this very case is only + // interesting for that invariant.) + const importLine = source + .split('\n') + .find((line) => line.startsWith('import ')); + expect(importLine).toBeDefined(); + expect(importLine).toContain('file:'); + expect(importLine).not.toContain('\\'); + }); +}); + +describe('writing the generated shim', () => { + const roots: string[] = []; + const makeFolder = (): string => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstack-bridge-')); + roots.push(root); + return root; + }; + + afterAll(() => { + for (const root of roots) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('writes into the project so resolution anchors there', () => { + const folder = makeFolder(); + const input = { + folderPath: folder, + configLoaderPath: path.join(folder, 'node_modules/rstack/dist/c.js'), + rstackConfigPath: path.join(folder, 'rstack.config.ts'), + }; + const result = writeGeneratedShim(input); + expect(result.written).toBe(true); + expect(result.path).toBe(generatedShimPath(folder)); + expect(path.relative(folder, result.path).split(path.sep)).toEqual([ + 'node_modules', + '.cache', + 'rstack-editor', + 'rslint.config.mjs', + ]); + // What landed on disk is exactly what the renderer produces — the writer + // adds nothing of its own. + expect(fs.readFileSync(result.path, 'utf8')).toBe( + renderGeneratedShim(input), + ); + }); + + it('does not rewrite an already identical shim', () => { + const folder = makeFolder(); + const input = { + folderPath: folder, + configLoaderPath: path.join(folder, 'node_modules/rstack/dist/c.js'), + rstackConfigPath: path.join(folder, 'rstack.config.ts'), + }; + const first = writeGeneratedShim(input); + const stat = fs.statSync(first.path); + const second = writeGeneratedShim(input); + // No churn: every rewrite is a config mutation the watcher would observe. + expect(second.written).toBe(false); + expect(fs.statSync(second.path).mtimeMs).toBe(stat.mtimeMs); + }); + + it('removes a shim left behind when the folder flips to native mode', () => { + const folder = makeFolder(); + const written = writeGeneratedShim({ + folderPath: folder, + configLoaderPath: path.join(folder, 'node_modules/rstack/dist/c.js'), + rstackConfigPath: path.join(folder, 'rstack.config.ts'), + }); + expect(removeGeneratedShim(folder)).toBe(true); + expect(fs.existsSync(written.path)).toBe(false); + // Idempotent: a folder that was never bridged is the common case. + expect(removeGeneratedShim(folder)).toBe(false); + }); + + it('rewrites when the baked-in config path changes', () => { + const folder = makeFolder(); + const base = { + folderPath: folder, + configLoaderPath: path.join(folder, 'node_modules/rstack/dist/c.js'), + }; + writeGeneratedShim({ + ...base, + rstackConfigPath: path.join(folder, 'rstack.config.ts'), + }); + const second = writeGeneratedShim({ + ...base, + rstackConfigPath: path.join(folder, 'rstack.config.mjs'), + }); + expect(second.written).toBe(true); + expect(fs.readFileSync(second.path, 'utf8')).toContain('rstack.config.mjs'); + }); +}); + +describe('resolving the project rstack config loader', () => { + const roots: string[] = []; + const makeProject = ( + packageJson: unknown, + { withEntry = true }: { withEntry?: boolean } = {}, + ): string => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstack-resolve-')); + roots.push(root); + const packageDir = path.join(root, 'node_modules', 'rstack'); + fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify(packageJson), + ); + if (withEntry) { + fs.writeFileSync( + path.join(packageDir, 'dist', 'configExports.js'), + 'export const loadRstackConfig = () => {};', + ); + } + return root; + }; + + afterAll(() => { + for (const root of roots) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('reads the ./config target out of the exports map', () => { + const root = makeProject({ + name: 'rstack', + exports: { + './config': { + types: './dist/configExports.d.ts', + default: './dist/configExports.js', + }, + }, + }); + expect(resolveRstackConfigLoader(root)).toBe( + fs.realpathSync( + path.join(root, 'node_modules/rstack/dist/configExports.js'), + ), + ); + }); + + it('takes the ESM branch of a split entry, never the CommonJS default', () => { + // The shim is `.mjs` and imports the target by `file:` URL, so `import` + // beats `default` — a fixed preference list ending in `default` would grab + // the CommonJS build the moment `rstack` splits the entry. + const root = makeProject({ + name: 'rstack', + exports: { + './config': { + types: './dist/configExports.d.ts', + import: './dist/configExports.js', + require: './dist/configExports.cjs', + default: './dist/configExports.cjs', + }, + }, + }); + expect(resolveRstackConfigLoader(root)).toBe( + fs.realpathSync( + path.join(root, 'node_modules/rstack/dist/configExports.js'), + ), + ); + }); + + it('descends into a nested condition map', () => { + // The runtime-then-format shape: a `node` branch that itself splits into + // `import`/`require`. Reading only the top level would fall through to + // `default` and hand the shim a CommonJS build. + const root = makeProject({ + name: 'rstack', + exports: { + './config': { + types: './dist/configExports.d.ts', + node: { + require: './dist/configExports.cjs', + import: './dist/configExports.js', + }, + default: './dist/configExports.cjs', + }, + }, + }); + expect(resolveRstackConfigLoader(root)).toBe( + fs.realpathSync( + path.join(root, 'node_modules/rstack/dist/configExports.js'), + ), + ); + }); + + it('asks for an upgrade when rstack publishes no ./config', () => { + const root = makeProject({ + name: 'rstack', + exports: { '.': './dist/index.js' }, + }); + expect(() => resolveRstackConfigLoader(root)).toThrow(RstackBridgeError); + expect(() => resolveRstackConfigLoader(root)).toThrow(/0\.4\.0/); + }); + + it('reports an unrecognised ./config shape without asking for an upgrade', () => { + // The entry is there, so the install is not old — telling the user to + // upgrade would send them somewhere that cannot help. + const root = makeProject({ + name: 'rstack', + exports: { './config': { types: './dist/configExports.d.ts' } }, + }); + expect(() => resolveRstackConfigLoader(root)).toThrow(RstackBridgeError); + expect(() => resolveRstackConfigLoader(root)).toThrow( + /no target an ESM import can take/, + ); + expect(() => resolveRstackConfigLoader(root)).not.toThrow(/0\.4\.0/); + }); + + it('reports a missing rstack install instead of guessing a path', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstack-empty-')); + roots.push(root); + expect(() => resolveRstackConfigLoader(root)).toThrow( + /Could not resolve the "rstack" package/, + ); + }); +}); + +describe('the rstack-loader preflight', () => { + it('warns only for a TypeScript config on a host without type stripping', () => { + expect( + describeRstackConfigLoaderPreflight({ + rstackConfigPath: at('rstack.config.ts'), + nativeTypeStripping: false, + }), + ).toMatch(/rstack\.config\.ts/); + expect( + describeRstackConfigLoaderPreflight({ + rstackConfigPath: at('rstack.config.ts'), + nativeTypeStripping: true, + }), + ).toBeUndefined(); + expect( + describeRstackConfigLoaderPreflight({ + rstackConfigPath: at('rstack.config.mjs'), + nativeTypeStripping: false, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/vscode/tests/versionCheck.test.ts b/packages/vscode/tests/versionCheck.test.ts index a350134..bc7bbc6 100644 --- a/packages/vscode/tests/versionCheck.test.ts +++ b/packages/vscode/tests/versionCheck.test.ts @@ -58,6 +58,11 @@ describe('node runtime range', () => { describe('config discovery protocol', () => { it('supports exactly the protocol versions the copied client speaks', () => { expect(isSupportedConfigDiscoveryProtocolVersion(1)).toBe(true); - expect(isSupportedConfigDiscoveryProtocolVersion(2)).toBe(false); + // 2 adds one optional request field (`configPath`, the lint × Rstack + // config bridge) and changes nothing the reverse-request handlers touch, + // so the copied client speaks it as well. + expect(isSupportedConfigDiscoveryProtocolVersion(2)).toBe(true); + expect(isSupportedConfigDiscoveryProtocolVersion(3)).toBe(false); + expect(isSupportedConfigDiscoveryProtocolVersion(0)).toBe(false); }); }); From 97e7521e81aabf630f8d29d70b3f547ef0f02ee2 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 14 Aug 2026 15:49:25 +0800 Subject: [PATCH 02/11] docs: record the lint rstack bridge decision as ADR 0003 Distilled from the rationale that shipped inline with the bridge: the generated shim, folder-level Ownership, the capability gate and the version-mismatch-not-crashed rule now have a decision record, following the precedent ADR 0002 set for fmt. The AGENTS.md gotchas keep their operational rules and point at the ADR for the why; ADR 0001's lint entry cross-references it. Protocol 2 is recorded as first released in @rslint/core 0.8.0 (verified against the npm package). --- docs/adr/0001-node-runtime-selection.md | 2 +- docs/adr/0003-lint-rstack-bridge.md | 41 +++++++++++++++++++++++++ packages/vscode/AGENTS.md | 8 ++--- 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0003-lint-rstack-bridge.md diff --git a/docs/adr/0001-node-runtime-selection.md b/docs/adr/0001-node-runtime-selection.md index 057af92..465c2ea 100644 --- a/docs/adr/0001-node-runtime-selection.md +++ b/docs/adr/0001-node-runtime-selection.md @@ -39,7 +39,7 @@ Note that _worker_ names a process, not a runtime. The worker is our own code; t 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: - **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. The lint × rstack bridge widens this entry: a bridged folder evaluates the user's `rstack.config.*` on the same runtime, through rstack's `loadRstackConfig` — the `loader: 'native'`, no-jiti-fallback path this ADR analysed for the worker floor. The generated shim itself is plain JS, and `@rslint/core`'s jiti fallback covers only the entry config file it loads, not the imports that file makes — so a `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively, a condition VS Code's release cadence owns, not the user's environment. +- **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. The lint × rstack bridge (`0003-lint-rstack-bridge.md`) widens this entry: a bridged folder evaluates the user's `rstack.config.*` on the same runtime, through rstack's `loadRstackConfig` — the `loader: 'native'`, no-jiti-fallback path this ADR analysed for the worker floor. The generated shim itself is plain JS, and `@rslint/core`'s jiti fallback covers only the entry config file it loads, not the imports that file makes — so a `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively, a condition VS Code's release cadence owns, not the user's environment. 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. diff --git a/docs/adr/0003-lint-rstack-bridge.md b/docs/adr/0003-lint-rstack-bridge.md new file mode 100644 index 0000000..65f0227 --- /dev/null +++ b/docs/adr/0003-lint-rstack-bridge.md @@ -0,0 +1,41 @@ +# Linting bridged folders through a generated shim + +A **bridged folder** — no native `rslint.config.*` anywhere in the workspace folder, an `rstack.config.*` at its root — is linted from `define.lint()`: the extension writes a **generated shim** into the project (`node_modules/.cache/rstack-editor/rslint.config.mjs`) and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`, added by config-discovery protocol 2 (web-infra-dev/rslint#1630, first released in `@rslint/core` 0.8.0). Native mode stays byte-identical to upstream: the field is simply absent and the server keeps doing its own discovery. The rule, the shim and the gates live in `stacks/lint/rstackBridge.ts`, a pure module the shell's detection and the stack's mode selection both consult — one decision seen twice. + +## Why a generated shim + +`rs lint`'s own answer to "lint from the Rstack config" is a shim rstack ships (`dist/rslintConfig.js`) and injects through Rslint's ordinary explicit-config channel. The editor cannot point the server at that file: it calls `loadRstackConfig()` with no arguments, which probes the **evaluation** cwd — and the server evaluates config modules in the extension host, whose cwd is meaningless. So the extension renders its own shim with two absolute paths baked in: the project's `rstack/config` export (resolved out of the package's own `exports` map, so a layout change in rstack follows automatically) and the folder-root `rstack.config.*`. The body mirrors rstack's shipped shim — take `configs.lint ?? []`, await a function, default-export the result. `define.lint`'s value **is** an Rslint flat config; no translation happens anywhere in the chain, and none may ever be added. + +The shim lives inside the project, not in extension storage, so module and plugin resolution from it anchors on the project. `node_modules/.cache/` is the conventional home for tool-generated files, and living under `node_modules` is what makes its deliberately config-like basename safe: detection excludes `node_modules` outright, so the shim can never be mistaken for a native config and flip Ownership against itself. Its lifecycle follows the pin: written before the server starts, rewritten only when its content actually differs (it sits under the config watcher — every rewrite is a config mutation), re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode. + +## Why Ownership is per folder, and only a root config bridges + +The granularity is the language server's, not a policy choice: the explicit-config decision is fixed for a server process's whole lifetime, there is one server per workspace folder, and its cwd is the folder root. One native `rslint.config.*` anywhere in the folder therefore means pure native mode — the bridge yields entirely, silently — and a `rstack.config.*` in a subdirectory does not light lint at all. That is a documented limitation with the same remedy fmt gives (ADR 0002): a subproject that needs its own config becomes its own workspace folder. A mode flip is a **restart** of the folder's server through the coordinator's existing replacement path, never a message to a live one — `lintConfigModeSignature` is exactly the identity the controller compares to notice one. + +## Why a capability gate, not a version number + +Bridged mode starts only when the project's own `@rslint/core/config-loader` reports `CONFIG_DISCOVERY_PROTOCOL_VERSION >= 2` — the version whose `rslint/configRefresh` carries `configPath`. The gate reads the constant instead of comparing release numbers because the capability is the thing the mode needs: a guessed release floor would either strand users whose build already speaks protocol 2 or start a mode the server cannot honour. Below the gate the extension starts nothing rather than a half-bridge: "falling back" to automatic discovery in a bridged folder would find no config and report nothing, which reads as a broken extension. The client-side set (`SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS` in `shared/versionCheck.ts`) accepts both 1 and 2, so native mode keeps working on the very releases that make the bridge possible — v2 adds one optional field and changes nothing else. + +## Why a gated folder reports `version mismatch`, never `crashed` + +Two gates refuse a bridged folder, raised as one `RstackBridgeGateError` differing only in message: the **capability** gate above, and the **toolchain** gate — `@rslint/core` does not resolve from the folder at all. The toolchain gap is the _mainstream_ shape, not an edge case: `@rslint/core` reaches a bridged project only as `rstack`'s transitive dependency, and isolated `node_modules` layouts (pnpm's default) deliberately do not expose transitive dependencies, so the typical rstack-cli app — whose only config is `rstack.config.ts` — hits it. Nobody in that folder asked for Rslint by name; `crashed` outranks every other folder in the status aggregation; and the status detail is the only place the fix can be stated (add `@rslint/core` to `devDependencies`, or upgrade it, or write an `rslint.config.*`). Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`, an explicit request for a tool that is missing. + +## Considered options + +**Pointing the server at rstack's shipped shim** — rejected above: it probes the evaluation cwd, which only means the right thing when `rs` runs it from the project directory. + +**Re-implementing Rstack config semantics in the extension** — rejected everywhere in this repo (the test bridge set the precedent): the editor and the CLI must evaluate the config identically, which only holds when both go through the project's own `rstack` package. + +**Automatic-discovery fallback below the gate** — rejected: a server with no config silently lints nothing; a `version mismatch` status names the version it found and the way out. + +**Gating on a release number** — rejected in favour of the capability probe; the protocol constant is published by the exact module whose behaviour is in question. + +**Evaluating the Rstack config on a User Node runtime** — not taken now. The bridge evaluates the config on the VS Code Node runtime through rstack's `loadRstackConfig` — the `loader: 'native'`, no-jiti-fallback path ADR 0001 analysed — which widens the lint entry on that ADR's debt list rather than retiring it. Moving lint's config host and plugin pool onto a User Node runtime is its own spawn-and-protocol project, recorded there as known debt. + +## Consequences + +- A `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively — `@rslint/core`'s jiti fallback covers the entry config file it loads, not the imports that file makes, and the generated shim's import of the config goes through rstack's loader. The preflight (`describeRstackConfigLoaderPreflight`) turns that into a diagnostic, never a behaviour change; VS Code's release cadence owns the condition. +- pnpm users must add `@rslint/core` to `devDependencies` for bridged linting; the README states it and the toolchain gate's status repeats it. +- The generated shim is a build artifact the extension owes hygiene for: never committed, deleted on a native flip, and `writeGeneratedShim`'s no-churn rule keeps the config watcher from seeing phantom edits. +- The mode choice is immutable per server process, so anything that changes it — a native config appearing, the root Rstack config vanishing — restarts that folder's server; there is no message path to a live one. +- The bridge holds only for the folder root; monorepo subpackages with their own `rstack.config.*` lint through the bridge only when opened as their own workspace folder. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 25a26bc..6443a89 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -15,7 +15,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 6. **Node runtime selection** (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. -7. **Rstack config bridge** (lint) — a **bridged folder** (no native `rslint.config.*` anywhere in the folder, an `rstack.config.*` at its root) is linted from the Rstack config: the extension writes a **generated shim** into the project and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`. Native mode stays byte-identical to upstream — the field is absent, so the server keeps doing its own discovery. In `Rslint.ts` every line of it sits between a `--- rstack config bridge ---` / `--- end rstack config bridge ---` marker pair (no single-line markers — the pairing is what an upstream-sync diff greps for); the rule, the shim and the gate live in `stacks/lint/rstackBridge.ts`. The shim's lifecycle follows the pin: written before the server starts, re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode. +7. **Rstack config bridge** (lint) — a **bridged folder** (no native `rslint.config.*` anywhere in the folder, an `rstack.config.*` at its root) is linted from the Rstack config: the extension writes a **generated shim** into the project and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`. Native mode stays byte-identical to upstream — the field is absent, so the server keeps doing its own discovery. In `Rslint.ts` every line of it sits between a `--- rstack config bridge ---` / `--- end rstack config bridge ---` marker pair (no single-line markers — the pairing is what an upstream-sync diff greps for); the rule, the shim and the gate live in `stacks/lint/rstackBridge.ts`. The shim's lifecycle follows the pin: written before the server starts, re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode. The full rule and rationale: `docs/adr/0003-lint-rstack-bridge.md`. ## Rules @@ -34,9 +34,9 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Gotchas — decisions that look wrong but aren't -- The lint × `rstack.config.*` bridge is complete, and every part of it that looks like an arbitrary restriction is forced by the language server. Ownership is decided per **folder**, not per directory (one native config anywhere and the bridge yields entirely, silently), and only a **root** `rstack.config.*` bridges, because the explicit-config choice is fixed for a server process, there is one server per workspace folder, and its cwd is the folder root. A subdirectory `rstack.config.*` therefore does not light lint at all — a documented limitation, not an oversight. The shim is **generated** rather than rstack's shipped `dist/rslintConfig.js`, because that one calls `loadRstackConfig()` with no arguments and probes the _evaluation_ cwd, which for the editor is the extension host's meaningless cwd; the generated one bakes the absolute config path in. It still never interprets the config: `define.lint`'s value is an Rslint flat config, taken through the project's own `rstack/config` export. A mode flip is a **restart** (the controller replays the folder through the coordinator's replacement path), never a message to a live server. -- Bridged mode is gated on a **capability**, not a version number: the project's `@rslint/core/config-loader` must report config-discovery protocol >= 2 (the version that carries `configPath`, rslint PR #1630). Below that the extension does not start a half-bridge — it reports `version mismatch` naming the resolved version and telling the user to upgrade `@rslint/core`. Do not replace the probe with a guessed release number, and do not "fall back" to automatic discovery for a bridged folder: the server would find no config and report nothing, which reads as a broken extension. -- A bridged folder that cannot start reports `version mismatch`, never `crashed` — including when `@rslint/core` does not resolve at all (one `RstackBridgeGateError` for both gates, differing only in message; the coordinator is told it is an expected failure). That is not politeness: `@rslint/core` reaches such a project only as `rstack`'s **transitive** dependency, which pnpm's isolated layout does not expose, so the mainstream rstack-cli project hits it. Nobody in that folder asked for Rslint by name, `crashed` outranks every other folder in the status aggregation, and the status detail is the only place the fix can be stated. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`. +- The lint × `rstack.config.*` bridge is complete, and every part of it that looks like an arbitrary restriction is forced by the language server. Ownership is decided per **folder**, not per directory — one native config anywhere and the bridge yields entirely, silently — and only a **root** `rstack.config.*` bridges; a subdirectory one does not light lint at all, a documented limitation, not an oversight. The shim is **generated**, never rstack's shipped `dist/rslintConfig.js`, and never interprets the config: `define.lint`'s value is an Rslint flat config, taken through the project's own `rstack/config` export. A mode flip is a **restart** (the controller replays the folder through the coordinator's replacement path), never a message to a live server. Why all of it: `docs/adr/0003-lint-rstack-bridge.md`. +- Bridged mode is gated on a **capability**, not a version number: the project's `@rslint/core/config-loader` must report config-discovery protocol >= 2 (the version that carries `configPath`, rslint PR #1630). Do not replace the probe with a guessed release number, and do not "fall back" to automatic discovery for a bridged folder: the server would find no config and report nothing, which reads as a broken extension. +- A bridged folder that cannot start reports `version mismatch`, never `crashed` — one `RstackBridgeGateError` for both gates (protocol too old, or `@rslint/core` not resolving at all — the mainstream pnpm shape, since `@rslint/core` reaches such a project only as `rstack`'s transitive dependency), and the coordinator is told it is an expected failure. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`. The rationale is ADR 0003's. - 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. From ed49e119f507af2e0d0878fa58062f0840a69c64 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 14 Aug 2026 16:00:50 +0800 Subject: [PATCH 03/11] test(vscode): unlock the bridge happy path on @rslint/core 0.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @rslint/core 0.8.0 is the first release carrying rslint#1630 (config-discovery protocol 2), and rstack 0.6.1 depends on it. Bumping the shared rstack fixture to rstack 0.6.1 flips the bridge E2E suite's runtime switch: the explicit-mode happy path now runs for real and the capability-gate test skips — the same switch, other direction; the gate stays covered as a pure unit. The two native install roots move to ^0.8.0 as well, so the ported Rslint suites exercise the protocol {1, 2} client set against the release that actually speaks 2. --- packages/vscode/e2e/fixtures/rslint/package.json | 2 +- packages/vscode/e2e/fixtures/rstack/package.json | 4 ++-- packages/vscode/e2e/lint/fixtures/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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..bfd6e97 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 the Rstest, rs fmt and (bridged) Rslint stacks.", "dependencies": { - "rstack": "0.5.2" + "rstack": "0.6.1" }, "devDependencies": { "jiti": "^2.0.0" 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" } } From 16f00946a0d7a9498a1d75582dc0e853270ff218 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 14 Aug 2026 16:34:13 +0800 Subject: [PATCH 04/11] refactor(vscode): drop the vestigial Yarn PnP resolution path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompted by PR #10 review: the bridge crashed a PnP project with a misleading missing-rstack error after resolving @rslint/core through .pnp.cjs. The PnP lookup covered only that first hop — config evaluation, the plugin host and the fmt/test stacks all resolve without PnP hooks — so it could never light a working folder, and upstream's own resolver has since gone 'no PnP or fallback'. Remove the hop instead of extending it: resolution walks physical node_modules only, and a PnP project surfaces the ordinary resolution failure, whose message now names the layout as the blocker (a .pnp.* probe kept as diagnostic only). For a bridged folder that reports through the existing toolchain gate as version mismatch. Also from the same review round: the bridged-lint README requirement now names @rslint/core >= 0.8.0 as the protocol-2 release instead of claiming no release has it, and the settings surfaces stop advertising PnP support. Decision recorded in ADR 0003; gotcha in AGENTS.md. --- docs/adr/0003-lint-rstack-bridge.md | 1 + packages/vscode/AGENTS.md | 1 + packages/vscode/README.md | 4 +- packages/vscode/package.json | 2 +- packages/vscode/src/stacks/lint/Rslint.ts | 3 +- packages/vscode/src/stacks/lint/resolution.ts | 177 +++++------------- packages/vscode/src/stacks/lint/utils.ts | 2 +- packages/vscode/src/stacks/test/master.ts | 3 +- 8 files changed, 58 insertions(+), 135 deletions(-) diff --git a/docs/adr/0003-lint-rstack-bridge.md b/docs/adr/0003-lint-rstack-bridge.md index 65f0227..5033ea8 100644 --- a/docs/adr/0003-lint-rstack-bridge.md +++ b/docs/adr/0003-lint-rstack-bridge.md @@ -39,3 +39,4 @@ Two gates refuse a bridged folder, raised as one `RstackBridgeGateError` differi - The generated shim is a build artifact the extension owes hygiene for: never committed, deleted on a native flip, and `writeGeneratedShim`'s no-churn rule keeps the config watcher from seeing phantom edits. - The mode choice is immutable per server process, so anything that changes it — a native config appearing, the root Rstack config vanishing — restarts that folder's server; there is no message path to a live one. - The bridge holds only for the folder root; monorepo subpackages with their own `rstack.config.*` lint through the bridge only when opened as their own workspace folder. +- Yarn PnP is out of scope, extension-wide: resolution walks physical `node_modules` only, matching upstream's resolver ("no PnP or fallback"), so a PnP-only project surfaces the ordinary resolution failure — for a bridged folder, the toolchain gate — with a message naming the layout as the blocker. The resolver's old PnP lookup covered only the find-`@rslint/core` hop; everything downstream (config evaluation, the plugin host, the fmt and test stacks) resolves without PnP hooks, so the hop alone never produced a working folder, and extending it to the generated shim would not help either — the shim's whole import chain (rstack and its dependencies, possibly zip-archived) would need PnP hooks inside the server's evaluation process. Real support is a Yarn editor-SDK-shaped project, the route Prettier's extension takes, not a resolver branch. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 6443a89..ebf91a2 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -37,6 +37,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - The lint × `rstack.config.*` bridge is complete, and every part of it that looks like an arbitrary restriction is forced by the language server. Ownership is decided per **folder**, not per directory — one native config anywhere and the bridge yields entirely, silently — and only a **root** `rstack.config.*` bridges; a subdirectory one does not light lint at all, a documented limitation, not an oversight. The shim is **generated**, never rstack's shipped `dist/rslintConfig.js`, and never interprets the config: `define.lint`'s value is an Rslint flat config, taken through the project's own `rstack/config` export. A mode flip is a **restart** (the controller replays the folder through the coordinator's replacement path), never a message to a live server. Why all of it: `docs/adr/0003-lint-rstack-bridge.md`. - Bridged mode is gated on a **capability**, not a version number: the project's `@rslint/core/config-loader` must report config-discovery protocol >= 2 (the version that carries `configPath`, rslint PR #1630). Do not replace the probe with a guessed release number, and do not "fall back" to automatic discovery for a bridged folder: the server would find no config and report nothing, which reads as a broken extension. - A bridged folder that cannot start reports `version mismatch`, never `crashed` — one `RstackBridgeGateError` for both gates (protocol too old, or `@rslint/core` not resolving at all — the mainstream pnpm shape, since `@rslint/core` reaches such a project only as `rstack`'s transitive dependency), and the coordinator is told it is an expected failure. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`. The rationale is ADR 0003's. +- Yarn PnP is unsupported by decision, extension-wide — do not re-add a partial PnP hop to any resolver. Lint's resolver once carried one (ported from an older upstream, removed when upstream went "no PnP or fallback"); a PnP project now surfaces the ordinary resolution failure, whose message names the layout. Why, and what real support would take: the PnP consequence in `docs/adr/0003-lint-rstack-bridge.md`. - 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. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 9e26615..c764fa0 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -46,7 +46,7 @@ Linting a folder from `define.lint()` in `rstack.config.*` asks more: - `rstack` must publish its config loader (`>=0.4.0`). - `@rslint/core` must be **installed in the project**. `rstack` depends on it, but package managers with an isolated `node_modules` layout (pnpm by default) do not expose transitive dependencies, so add `@rslint/core` to your `devDependencies`. -- `@rslint/core` must be new enough for the editor to pin the language server to a config. That is not a version number the extension can name yet — no released `@rslint/core` has it. +- `@rslint/core` must be new enough to speak config-discovery protocol 2, which is what lets the editor pin the language server to a config — in practice `>= 0.8.0`, the first release that does. The extension probes the protocol, not the version number. - A TypeScript `rstack.config.ts` must be loadable by the VS Code extension host's Node: rstack's config loader relies on native type stripping and has no fallback, so on an older VS Code build use `rstack.config.mjs` (or `.js`). Until all of them hold, such a folder shows `version mismatch` with a message naming the missing piece instead of linting; adding an `rslint.config.*` is the way out today. Folders with an `rslint.config.*` are unaffected. @@ -88,7 +88,7 @@ All settings live under the unified `rstack.*` namespace. There are no `rslint.* | `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.rslint.enable` | `true` | Enable/disable the Rslint integration. | -| `rstack.rslint.binPath` | `local` | `local` (project `node_modules`, incl. Yarn PnP) or `custom`. | +| `rstack.rslint.binPath` | `local` | `local` (project `node_modules`) or `custom`. | | `rstack.rslint.customBinPath` | — | Binary path used when `binPath` is `custom`. | | `rstack.rslint.trace.server` | `off` | LSP trace level (`off` / `messages` / `verbose`). | | `rstack.rstest.enable` | `true` | Enable/disable the Rstest integration. | diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 3c67597..d07de1f 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -164,7 +164,7 @@ "default": "local", "scope": "resource", "markdownEnumDescriptions": [ - "Resolve the Rslint binary from the workspace `node_modules` (Yarn PnP is supported). This extension ships no binary.", + "Resolve the Rslint binary from the workspace `node_modules`. 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" diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 37602f4..fe06f46 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -690,8 +690,7 @@ export class Rslint implements Disposable { this.assertStartCurrent(epoch, signal); this.resolution = resolution; this.logger.info( - `Rslint resolved from the project (${resolution.kind}): ${resolution.coreDir}` + - ` (version ${resolution.coreVersion ?? 'unknown'})`, + `Rslint resolved from the project: ${resolution.coreDir} (version ${resolution.coreVersion ?? 'unknown'})`, ); // Compatibility seam (1): the support matrix. `@rslint/core` older than the diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index 53c15f2..7dec050 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -3,6 +3,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { Uri, workspace, type WorkspaceFolder } from 'vscode'; import { findPackageJsonUncached } from '../../shared/packageResolve'; +import { readPackageVersion } from '../../shared/versionCheck'; import type { Logger } from './logger'; import { fileExists, @@ -21,12 +22,13 @@ import { * from the same `@rslint/core` install, "never binary from A, loader from B". * `assertSingleResolutionRoot` enforces that as an assertion, not a * convention. + * + * Resolution walks physical `node_modules` only — no Yarn PnP, by decision, + * matching upstream's resolver ("no PnP or fallback"); rationale in the + * AGENTS.md gotchas. A PnP project surfaces the ordinary resolution failure, + * whose message names the unsupported layout. */ -export type RslintResolutionKind = 'node-modules' | 'pnp'; - 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 coreDir: string; readonly coreVersion: string | undefined; @@ -48,29 +50,10 @@ export class RslintResolutionError extends Error { } } -interface PnpApi { - resolveRequest(request: string, issuer: string): string | null; -} - // `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`. - } - return undefined; -}; - const isInside = (child: string, parent: string): boolean => { const relative = path.relative(parent, child); return ( @@ -98,39 +81,8 @@ export const assertSingleResolutionRoot = ( } }; -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; +/** Returns the path of the project's `@rslint/core/package.json`. */ +const locateCore = (searchRoot: string, logger: Logger): string => { // 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 @@ -138,94 +90,55 @@ const locateCore = async ( // 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. - } + logger.debug(`Found @rslint/core: ${packageJsonPath}`); + return packageJsonPath; } + // Diagnostic only, never a resolution branch: under Yarn PnP the usual + // remedy (installing @rslint/core as a devDependency) writes no physical + // `node_modules`, so the message must name the layout as the blocker. + const usesPnp = ['.pnp.cjs', '.pnp.js'].some((name) => + fs.existsSync(path.join(searchRoot, name)), + ); 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).`, + usesPnp + ? `Could not resolve @rslint/core from ${searchRoot}: this project uses Yarn Plug'n'Play, which this extension does not support — switch to nodeLinker: node-modules to lint in the editor.` + : `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).`, ); }; const resolveCoreSubpath = ( - location: CoreLocation, + packageJsonPath: string, + coreDir: string, subpath: string, ): string => { const specifier = `@rslint/core/${subpath}`; - if (location.pnpApi) { - const resolved = location.pnpApi.resolveRequest( - specifier, - location.packageJsonPath, - ); - if (!resolved) { - throw new RslintResolutionError( - `Could not resolve ${specifier} through Yarn PnP from ${location.coreDir}`, - ); - } - return resolved; - } 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); + return createRequire(packageJsonPath).resolve(specifier); } catch (error) { try { - return nodeRequire.resolve(specifier, { paths: [location.coreDir] }); + return nodeRequire.resolve(specifier, { paths: [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).`, + `Could not resolve ${specifier} from ${coreDir}. Rslint >= 0.7.2 is required (its package exports ./config-loader and ./eslint-plugin).`, { cause: error }, ); } } }; -const resolveNativeBinary = ( - location: CoreLocation, - logger: Logger, -): string => { +const resolveNativeBinary = (coreDir: string, 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; - } + const binPath = nodeRequire.resolve(request, { paths: [coreDir] }); + logger.debug(`Using Rslint binary from the project: ${binPath}`); + return binPath; } catch { // Candidate not installed; try the next one. } @@ -233,7 +146,7 @@ const resolveNativeBinary = ( 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.`, + )}) from ${coreDir}. The @rslint/native-* package for this platform is not installed.`, ); }; @@ -264,9 +177,9 @@ const resolveUserBinary = async ( }; /** - * 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. + * Binary resolution order: explicit setting → workspace `node_modules`. + * `@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, @@ -282,10 +195,19 @@ export const resolveRslint = async ( ); } - const location = await locateCore(folder, logger); - const configLoaderPath = resolveCoreSubpath(location, 'config-loader'); - const eslintPluginPath = resolveCoreSubpath(location, 'eslint-plugin'); - assertSingleResolutionRoot(location.coreDir, [ + const packageJsonPath = locateCore(folder.uri.fsPath, logger); + const coreDir = path.dirname(packageJsonPath); + const configLoaderPath = resolveCoreSubpath( + packageJsonPath, + coreDir, + 'config-loader', + ); + const eslintPluginPath = resolveCoreSubpath( + packageJsonPath, + coreDir, + 'eslint-plugin', + ); + assertSingleResolutionRoot(coreDir, [ { label: '@rslint/core/config-loader', path: configLoaderPath }, { label: '@rslint/core/eslint-plugin', path: eslintPluginPath }, ]); @@ -293,20 +215,19 @@ export const resolveRslint = async ( const binFromUserSetting = binPathConfig === 'custom'; const binPath = binFromUserSetting ? await resolveUserBinary(folder, logger) - : resolveNativeBinary(location, logger); + : resolveNativeBinary(coreDir, logger); 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.`, + `Rslint binary comes from rstack.rslint.customBinPath (${binPath}) while the config-loader comes from ${coreDir}. The single-resolution-root invariant is waived by this explicit setting.`, ); } return { - kind: location.kind, - coreDir: location.coreDir, - coreVersion: readPackageVersion(location.packageJsonPath), + coreDir, + coreVersion: readPackageVersion(packageJsonPath), binPath, binFromUserSetting, configLoaderPath, diff --git a/packages/vscode/src/stacks/lint/utils.ts b/packages/vscode/src/stacks/lint/utils.ts index b94ca01..a47a13e 100644 --- a/packages/vscode/src/stacks/lint/utils.ts +++ b/packages/vscode/src/stacks/lint/utils.ts @@ -51,7 +51,7 @@ export const getPlatformBinRequests = (): string[] => { /** * `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 + * `node_modules`, 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/test/master.ts b/packages/vscode/src/stacks/test/master.ts index efadfc9..cf132c0 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -257,7 +257,8 @@ export class RstestApi { // `CORE_PACKAGE_JSON` specifier applies. Shared by the worker resolution and // the terminal CLI resolution, which both also report the configured path. private resolveConfiguredPackageJson(): string | undefined { - // TODO: support Yarn PnP + // Yarn PnP is not supported, by decision (see AGENTS.md), replacing + // upstream's "TODO: support Yarn PnP" here. let configuredPackagePath = getConfigValue( 'rstestPackagePath', this.workspace, From 1a2c9e1fa78f8f472714fdc7ed2b02e667789ae9 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 14 Aug 2026 16:43:46 +0800 Subject: [PATCH 05/11] feat(vscode): raise the @rslint/core floor to 0.8.0 Pre-1.0 the floor follows what is actually tested: the E2E fixtures all install 0.8.0 now, and 0.8.0 is the first release speaking config-discovery protocol 2. The resolution error messages derive the range from SUPPORT_MATRIX instead of hardcoding it, so the two can no longer drift. --- packages/vscode/README.md | 2 +- packages/vscode/src/shared/versionCheck.ts | 12 ++++++++---- packages/vscode/src/stacks/lint/resolution.ts | 6 +++--- packages/vscode/tests/versionCheck.test.ts | 6 +++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/vscode/README.md b/packages/vscode/README.md index c764fa0..79023c8 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -36,7 +36,7 @@ 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` | diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index beb3a10..8e9ca49 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -7,9 +7,13 @@ import { readPackageJson } from './packageResolve'; * checked against this matrix; a mismatch surfaces as the `version mismatch` * 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`. + * Floors (verified against npm): + * - `@rslint/core >= 0.8.0` — first release speaking config-discovery + * protocol 2 (the capability the lint × Rstack bridge pins a server + * through), and the release the E2E fixtures install — pre-1.0, the floor + * follows what is actually tested. The launch floor was 0.7.2, the first + * version whose package exports `./config-loader` and `./eslint-plugin`; + * those exports are why the resolution errors quote this range. * - `@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 @@ -25,7 +29,7 @@ import { readPackageJson } from './packageResolve'; * 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', } as const; diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index 7dec050..09d6642 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { Uri, workspace, type WorkspaceFolder } from 'vscode'; import { findPackageJsonUncached } from '../../shared/packageResolve'; -import { readPackageVersion } from '../../shared/versionCheck'; +import { readPackageVersion, SUPPORT_MATRIX } from '../../shared/versionCheck'; import type { Logger } from './logger'; import { fileExists, @@ -103,7 +103,7 @@ const locateCore = (searchRoot: string, logger: Logger): string => { throw new RslintResolutionError( usesPnp ? `Could not resolve @rslint/core from ${searchRoot}: this project uses Yarn Plug'n'Play, which this extension does not support — switch to nodeLinker: node-modules to lint in the editor.` - : `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).`, + : `Could not resolve @rslint/core from ${searchRoot}. This extension ships no Rslint binary — install @rslint/core in the project (this extension requires ${SUPPORT_MATRIX['@rslint/core']}).`, ); }; @@ -124,7 +124,7 @@ const resolveCoreSubpath = ( return nodeRequire.resolve(specifier, { paths: [coreDir] }); } catch { throw new RslintResolutionError( - `Could not resolve ${specifier} from ${coreDir}. Rslint >= 0.7.2 is required (its package exports ./config-loader and ./eslint-plugin).`, + `Could not resolve ${specifier} from ${coreDir}. Rslint ${SUPPORT_MATRIX['@rslint/core']} is required (its package exports ./config-loader and ./eslint-plugin).`, { cause: error }, ); } diff --git a/packages/vscode/tests/versionCheck.test.ts b/packages/vscode/tests/versionCheck.test.ts index bc7bbc6..ee11c97 100644 --- a/packages/vscode/tests/versionCheck.test.ts +++ b/packages/vscode/tests/versionCheck.test.ts @@ -9,9 +9,9 @@ import { } from '../src/shared/versionCheck'; describe('support matrix', () => { - it('pins the launch support floors', () => { + it('pins the 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', }); @@ -20,7 +20,7 @@ describe('support matrix', () => { 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'); From db789cbcb983d376fda029b00a8cd54b5b701362 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 14 Aug 2026 16:57:24 +0800 Subject: [PATCH 06/11] fix(vscode): close four review-found gaps in the bridge lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four align behavior with contracts the bridge already documents: - A dependency event coalesced with a config edit inside the watcher debounce no longer loses shim re-materialization: the bridge keeps its own pending flag, accumulated per event and consumed by the refresh that fires (the debounce keeps only the last event's reason). The flag is lifecycle-scoped alongside pluginDependencyRevision. - The bridged toolchain gate now wraps only the core-not-found failure (new RslintCoreNotFoundError): a resolvable @rslint/core whose later pieces fail (platform binary, exports, custom binary path) keeps its ordinary classification instead of a misleading 'install @rslint/core' version mismatch. - rstack-loader prerequisites (rstack missing, or predating the ./config export) are RstackBridgeGateError now, so they report version mismatch as the bridge documentation promises; an unusable ./config shape stays a crash — the install is not old, 'upgrade' would mislead. - A bridged folder leaving detection entirely (root rstack.config.* deleted, no native start to clean up) removes its generated shim on the prune path; dispose deliberately keeps it. --- docs/adr/0003-lint-rstack-bridge.md | 6 ++--- packages/vscode/AGENTS.md | 4 +-- packages/vscode/src/stacks/lint/Rslint.ts | 25 ++++++++++++++++--- packages/vscode/src/stacks/lint/index.ts | 22 +++++++++++++--- packages/vscode/src/stacks/lint/resolution.ts | 17 ++++++++++++- .../vscode/src/stacks/lint/rstackBridge.ts | 20 +++++++++------ .../tests/stacks/lint/rstackBridge.test.ts | 16 ++++++++++-- 7 files changed, 89 insertions(+), 21 deletions(-) diff --git a/docs/adr/0003-lint-rstack-bridge.md b/docs/adr/0003-lint-rstack-bridge.md index 5033ea8..5e810f7 100644 --- a/docs/adr/0003-lint-rstack-bridge.md +++ b/docs/adr/0003-lint-rstack-bridge.md @@ -6,7 +6,7 @@ A **bridged folder** — no native `rslint.config.*` anywhere in the workspace f `rs lint`'s own answer to "lint from the Rstack config" is a shim rstack ships (`dist/rslintConfig.js`) and injects through Rslint's ordinary explicit-config channel. The editor cannot point the server at that file: it calls `loadRstackConfig()` with no arguments, which probes the **evaluation** cwd — and the server evaluates config modules in the extension host, whose cwd is meaningless. So the extension renders its own shim with two absolute paths baked in: the project's `rstack/config` export (resolved out of the package's own `exports` map, so a layout change in rstack follows automatically) and the folder-root `rstack.config.*`. The body mirrors rstack's shipped shim — take `configs.lint ?? []`, await a function, default-export the result. `define.lint`'s value **is** an Rslint flat config; no translation happens anywhere in the chain, and none may ever be added. -The shim lives inside the project, not in extension storage, so module and plugin resolution from it anchors on the project. `node_modules/.cache/` is the conventional home for tool-generated files, and living under `node_modules` is what makes its deliberately config-like basename safe: detection excludes `node_modules` outright, so the shim can never be mistaken for a native config and flip Ownership against itself. Its lifecycle follows the pin: written before the server starts, rewritten only when its content actually differs (it sits under the config watcher — every rewrite is a config mutation), re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode. +The shim lives inside the project, not in extension storage, so module and plugin resolution from it anchors on the project. `node_modules/.cache/` is the conventional home for tool-generated files, and living under `node_modules` is what makes its deliberately config-like basename safe: detection excludes `node_modules` outright, so the shim can never be mistaken for a native config and flip Ownership against itself. Its lifecycle follows the pin: written before the server starts, rewritten only when its content actually differs (it sits under the config watcher — every rewrite is a config mutation), re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode or leaves detection altogether — a window close deliberately keeps it, since the next start re-materializes in place. ## Why Ownership is per folder, and only a root config bridges @@ -18,7 +18,7 @@ Bridged mode starts only when the project's own `@rslint/core/config-loader` rep ## Why a gated folder reports `version mismatch`, never `crashed` -Two gates refuse a bridged folder, raised as one `RstackBridgeGateError` differing only in message: the **capability** gate above, and the **toolchain** gate — `@rslint/core` does not resolve from the folder at all. The toolchain gap is the _mainstream_ shape, not an edge case: `@rslint/core` reaches a bridged project only as `rstack`'s transitive dependency, and isolated `node_modules` layouts (pnpm's default) deliberately do not expose transitive dependencies, so the typical rstack-cli app — whose only config is `rstack.config.ts` — hits it. Nobody in that folder asked for Rslint by name; `crashed` outranks every other folder in the status aggregation; and the status detail is the only place the fix can be stated (add `@rslint/core` to `devDependencies`, or upgrade it, or write an `rslint.config.*`). Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`, an explicit request for a tool that is missing. +The gates refusing a bridged folder are raised as one `RstackBridgeGateError` differing only in message: the **capability** gate above; the **toolchain** gate — `@rslint/core` does not resolve from the folder at all; and the **rstack-loader** gates — `rstack` itself missing, or too old to export `./config` (`< 0.4.0`). All describe package prerequisites whose remedy is an install or an upgrade — exactly what `version mismatch` means. A failure that is _not_ a prerequisite (a present `./config` export in an unusable shape, a missing platform binary of a resolvable `@rslint/core`) keeps its ordinary classification: its remedy is not "install/upgrade", so the gate message would mislead. The toolchain gap is the _mainstream_ shape, not an edge case: `@rslint/core` reaches a bridged project only as `rstack`'s transitive dependency, and isolated `node_modules` layouts (pnpm's default) deliberately do not expose transitive dependencies, so the typical rstack-cli app — whose only config is `rstack.config.ts` — hits it. Nobody in that folder asked for Rslint by name; `crashed` outranks every other folder in the status aggregation; and the status detail is the only place the fix can be stated (add `@rslint/core` to `devDependencies`, or upgrade it, or write an `rslint.config.*`). Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`, an explicit request for a tool that is missing. ## Considered options @@ -36,7 +36,7 @@ Two gates refuse a bridged folder, raised as one `RstackBridgeGateError` differi - A `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively — `@rslint/core`'s jiti fallback covers the entry config file it loads, not the imports that file makes, and the generated shim's import of the config goes through rstack's loader. The preflight (`describeRstackConfigLoaderPreflight`) turns that into a diagnostic, never a behaviour change; VS Code's release cadence owns the condition. - pnpm users must add `@rslint/core` to `devDependencies` for bridged linting; the README states it and the toolchain gate's status repeats it. -- The generated shim is a build artifact the extension owes hygiene for: never committed, deleted on a native flip, and `writeGeneratedShim`'s no-churn rule keeps the config watcher from seeing phantom edits. +- The generated shim is a build artifact the extension owes hygiene for: never committed, deleted on a native flip and when the folder leaves detection, and `writeGeneratedShim`'s no-churn rule keeps the config watcher from seeing phantom edits. - The mode choice is immutable per server process, so anything that changes it — a native config appearing, the root Rstack config vanishing — restarts that folder's server; there is no message path to a live one. - The bridge holds only for the folder root; monorepo subpackages with their own `rstack.config.*` lint through the bridge only when opened as their own workspace folder. - Yarn PnP is out of scope, extension-wide: resolution walks physical `node_modules` only, matching upstream's resolver ("no PnP or fallback"), so a PnP-only project surfaces the ordinary resolution failure — for a bridged folder, the toolchain gate — with a message naming the layout as the blocker. The resolver's old PnP lookup covered only the find-`@rslint/core` hop; everything downstream (config evaluation, the plugin host, the fmt and test stacks) resolves without PnP hooks, so the hop alone never produced a working folder, and extending it to the generated shim would not help either — the shim's whole import chain (rstack and its dependencies, possibly zip-archived) would need PnP hooks inside the server's evaluation process. Real support is a Yarn editor-SDK-shaped project, the route Prettier's extension takes, not a resolver branch. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index ebf91a2..7d0e70b 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -15,7 +15,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 6. **Node runtime selection** (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. -7. **Rstack config bridge** (lint) — a **bridged folder** (no native `rslint.config.*` anywhere in the folder, an `rstack.config.*` at its root) is linted from the Rstack config: the extension writes a **generated shim** into the project and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`. Native mode stays byte-identical to upstream — the field is absent, so the server keeps doing its own discovery. In `Rslint.ts` every line of it sits between a `--- rstack config bridge ---` / `--- end rstack config bridge ---` marker pair (no single-line markers — the pairing is what an upstream-sync diff greps for); the rule, the shim and the gate live in `stacks/lint/rstackBridge.ts`. The shim's lifecycle follows the pin: written before the server starts, re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode. The full rule and rationale: `docs/adr/0003-lint-rstack-bridge.md`. +7. **Rstack config bridge** (lint) — a **bridged folder** (no native `rslint.config.*` anywhere in the folder, an `rstack.config.*` at its root) is linted from the Rstack config: the extension writes a **generated shim** into the project and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`. Native mode stays byte-identical to upstream — the field is absent, so the server keeps doing its own discovery. In `Rslint.ts` every line of it sits between a `--- rstack config bridge ---` / `--- end rstack config bridge ---` marker pair (no single-line markers — the pairing is what an upstream-sync diff greps for); the rule, the shim and the gate live in `stacks/lint/rstackBridge.ts`. The shim's lifecycle follows the pin: written before the server starts, re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode or leaves detection. The full rule and rationale: `docs/adr/0003-lint-rstack-bridge.md`. ## Rules @@ -36,7 +36,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - The lint × `rstack.config.*` bridge is complete, and every part of it that looks like an arbitrary restriction is forced by the language server. Ownership is decided per **folder**, not per directory — one native config anywhere and the bridge yields entirely, silently — and only a **root** `rstack.config.*` bridges; a subdirectory one does not light lint at all, a documented limitation, not an oversight. The shim is **generated**, never rstack's shipped `dist/rslintConfig.js`, and never interprets the config: `define.lint`'s value is an Rslint flat config, taken through the project's own `rstack/config` export. A mode flip is a **restart** (the controller replays the folder through the coordinator's replacement path), never a message to a live server. Why all of it: `docs/adr/0003-lint-rstack-bridge.md`. - Bridged mode is gated on a **capability**, not a version number: the project's `@rslint/core/config-loader` must report config-discovery protocol >= 2 (the version that carries `configPath`, rslint PR #1630). Do not replace the probe with a guessed release number, and do not "fall back" to automatic discovery for a bridged folder: the server would find no config and report nothing, which reads as a broken extension. -- A bridged folder that cannot start reports `version mismatch`, never `crashed` — one `RstackBridgeGateError` for both gates (protocol too old, or `@rslint/core` not resolving at all — the mainstream pnpm shape, since `@rslint/core` reaches such a project only as `rstack`'s transitive dependency), and the coordinator is told it is an expected failure. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`. The rationale is ADR 0003's. +- A bridged folder blocked by a **package prerequisite** reports `version mismatch`, never `crashed` — one `RstackBridgeGateError` for every gate (protocol too old; `@rslint/core` not resolving at all; `rstack` missing or predating the `./config` export), and the coordinator is told it is an expected failure. A failure that is not a prerequisite keeps its ordinary classification. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`. The rationale, and the full gate/non-gate line, is ADR 0003's. - Yarn PnP is unsupported by decision, extension-wide — do not re-add a partial PnP hop to any resolver. Lint's resolver once carried one (ported from an older upstream, removed when upstream went "no PnP or fallback"); a PnP project now surfaces the ordinary resolution failure, whose message names the layout. Why, and what real support would take: the PnP consequence in `docs/adr/0003-lint-rstack-bridge.md`. - 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`. diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index fe06f46..b2f20b3 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -91,6 +91,7 @@ import { type ConfigLoaderModule, } from './configLoader'; import { + RslintCoreNotFoundError, RslintResolutionError, resolveRslint, type RslintResolution, @@ -515,6 +516,14 @@ export class Rslint implements Disposable { readonly rstackConfigPath: string; } | undefined; + /** + * True when a dependency change arrived since the last config refresh. The + * watcher debounce keeps only the *last* event's reason, so a lockfile event + * followed within the window by a config edit would otherwise lose the one + * signal that forces shim re-materialization. Accumulated per event (like + * `pluginDependencyRevision`), consumed by the refresh that fires. + */ + private bridgeShimRefreshPending = false; // --- end rstack config bridge --- /** Non-fatal notes appended to the folder's status detail. */ private statusNotes: string[] = []; @@ -659,6 +668,7 @@ export class Rslint implements Disposable { const epoch = this.lifecycleEpoch; const pluginLintPool = this.pluginLintPool; this.pluginDependencyRevision = 0; + this.bridgeShimRefreshPending = false; this.statusNotes = []; this.report({ kind: 'starting' }); @@ -1006,7 +1016,9 @@ export class Rslint implements Disposable { try { return await resolveRslint(this.workspaceFolder, this.logger); } catch (error) { - if (mode.kind === 'bridged' && error instanceof RslintResolutionError) { + // Only the not-found shape is the toolchain gap — see + // `RslintCoreNotFoundError`'s doc for the carve-out. + if (mode.kind === 'bridged' && error instanceof RslintCoreNotFoundError) { throw new RstackBridgeGateError( formatBridgeToolchainGap(mode.rstackConfigPath, error.message), { cause: error }, @@ -1175,6 +1187,9 @@ export class Rslint implements Disposable { // identical. Feed a monotonic dependency revision into the staged host // fingerprint so any lockfile mutation forces a worker rebuild. this.pluginDependencyRevision++; + // --- rstack config bridge --- + this.bridgeShimRefreshPending = true; + // --- end rstack config bridge --- } this.logger.debug(`${reason}: ${uri.fsPath}`); clearTimeout(this.configReloadTimer); @@ -1219,8 +1234,11 @@ export class Rslint implements Disposable { // --- rstack config bridge --- // The one thing that can invalidate a bridged folder's pin without // changing it: a reinstall under the shim, whose file the server is about - // to be told to reload. - if (reason === 'dependency-change') { + // to be told to reload. Keyed off the accumulated flag, not `reason`: the + // debounce keeps only the last event's reason, and a config edit landing + // right after a lockfile event must not swallow the re-materialization. + if (this.bridgeShimRefreshPending) { + this.bridgeShimRefreshPending = false; this.refreshGeneratedShim(); } // --- end rstack config bridge --- @@ -1425,6 +1443,7 @@ export class Rslint implements Disposable { } const results = await Promise.allSettled(asynchronousCleanups); this.pluginDependencyRevision = 0; + this.bridgeShimRefreshPending = false; for (const result of results) { if (result.status === 'rejected') { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 97258c8..cc7eab0 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -11,6 +11,7 @@ import { Rslint, type RslintFolderConfigPaths } from './Rslint'; import { decideLintConfigMode, lintConfigModeSignature, + removeGeneratedShim, RstackBridgeGateError, } from './rstackBridge'; import { WorkspaceDocumentRouter } from './WorkspaceDocumentRouter'; @@ -328,10 +329,25 @@ class RslintController implements StackController { this.#folderStates.set(key, { ...previous, configMode }); flipped.push(folder); } - for (const key of [...this.#folderStates.keys()]) { - if (!keys.has(key)) { - this.#folderStates.delete(key); + for (const [key, leaving] of [...this.#folderStates]) { + if (keys.has(key)) { + continue; + } + // A bridged folder leaving detection (its root `rstack.config.*` deleted + // or renamed, no native config appearing) never passes through a native + // start — the only other place the generated shim is deleted — so the + // extension-owned artifact is removed with the folder. Unconditional on + // purpose: the delete is idempotent, and testing the mode here would + // mean parsing the signature this layer otherwise treats as opaque. + // Deliberately not done on dispose: an undisturbed bridged folder keeps + // its shim across window reloads and the next start re-materializes it + // in place. The key is `workspaceRootKey` — the folder URI verbatim. + if (removeGeneratedShim(vscode.Uri.parse(key).fsPath)) { + this.#logger?.info( + `Removed the generated Rslint config shim for ${leaving.name}: the folder is no longer detected`, + ); } + this.#folderStates.delete(key); } this.publishStatus(); const folders = detected.map((entry) => entry.folder); diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index 09d6642..b47a0e1 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -50,6 +50,21 @@ export class RslintResolutionError extends Error { } } +/** + * The specific failure "no `@rslint/core` resolves from this folder at all" — + * the one resolution failure the lint × Rstack bridge reports through its + * toolchain gate ("install @rslint/core"). Every later failure (a missing + * platform binary, a broken exports map, an invalid custom binary path) means + * the package *is* installed and needs its own remedy, so it keeps the plain + * class and its ordinary classification. + */ +export class RslintCoreNotFoundError extends RslintResolutionError { + constructor(message: string) { + super(message); + this.name = 'RslintCoreNotFoundError'; + } +} + // `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); @@ -100,7 +115,7 @@ const locateCore = (searchRoot: string, logger: Logger): string => { const usesPnp = ['.pnp.cjs', '.pnp.js'].some((name) => fs.existsSync(path.join(searchRoot, name)), ); - throw new RslintResolutionError( + throw new RslintCoreNotFoundError( usesPnp ? `Could not resolve @rslint/core from ${searchRoot}: this project uses Yarn Plug'n'Play, which this extension does not support — switch to nodeLinker: node-modules to lint in the editor.` : `Could not resolve @rslint/core from ${searchRoot}. This extension ships no Rslint binary — install @rslint/core in the project (this extension requires ${SUPPORT_MATRIX['@rslint/core']}).`, diff --git a/packages/vscode/src/stacks/lint/rstackBridge.ts b/packages/vscode/src/stacks/lint/rstackBridge.ts index ab0ac17..67e8232 100644 --- a/packages/vscode/src/stacks/lint/rstackBridge.ts +++ b/packages/vscode/src/stacks/lint/rstackBridge.ts @@ -129,10 +129,10 @@ export class RstackBridgeError extends Error { * exists: a bridged folder was lit by an `rstack.config.*`, so its user never * asked for Rslint by name and must not be shown a crash. * - * Two gates raise it, and they differ only in their message — one class, - * because that is the whole granularity anything consumes (`Rslint`'s - * start-failure classification and the coordinator's expected-failure - * predicate both test exactly this type): + * The gates raising it differ only in their message — one class, because that + * is the whole granularity anything consumes (`Rslint`'s start-failure + * classification and the coordinator's expected-failure predicate both test + * exactly this type): * * 1. the **capability** gate — the project's `@rslint/core` speaks a * config-discovery protocol with no `configPath`, so there is no channel to @@ -145,7 +145,13 @@ export class RstackBridgeError extends Error { * mainstream bridged project — an rstack-cli app whose only config is * `rstack.config.ts` — cannot resolve it. Native mode's identical failure * stays a crash: there the user wrote an `rslint.config.*`, an explicit - * request for a tool that is missing. + * request for a tool that is missing; + * 3. the **rstack-loader** gates — `rstack` itself does not resolve from the + * folder, or resolves but predates the `./config` export (`< 0.4.0`). Both + * describe a package-version prerequisite whose remedy is an install or an + * upgrade, exactly what `version mismatch` means. A *present* `./config` + * entry in a shape this extension cannot take stays `RstackBridgeError`: + * the install is not old, so "upgrade" would mislead. */ export class RstackBridgeGateError extends Error { constructor(message: string, options?: { cause?: unknown }) { @@ -309,14 +315,14 @@ const readConfigExportEntry = ( export const resolveRstackConfigLoader = (folderPath: string): string => { const packageJsonPath = findPackageJsonUncached('rstack', folderPath); if (packageJsonPath === undefined) { - throw new RstackBridgeError( + throw new RstackBridgeGateError( `Could not resolve the "rstack" package from ${folderPath}. Linting from rstack.config.* needs the project's own rstack install — run the project's package manager.`, ); } const packageJson = readPackageJson(packageJsonPath); const entry = packageJson ? readConfigExportEntry(packageJson) : undefined; if (entry === undefined) { - throw new RstackBridgeError( + throw new RstackBridgeGateError( `The installed "rstack" (${packageJsonPath}) does not export "./config". Upgrade rstack to a version that publishes the config loader (>= 0.4.0), or add an rslint.config.* file.`, ); } diff --git a/packages/vscode/tests/stacks/lint/rstackBridge.test.ts b/packages/vscode/tests/stacks/lint/rstackBridge.test.ts index a824579..e26f8ad 100644 --- a/packages/vscode/tests/stacks/lint/rstackBridge.test.ts +++ b/packages/vscode/tests/stacks/lint/rstackBridge.test.ts @@ -411,18 +411,26 @@ describe('resolving the project rstack config loader', () => { name: 'rstack', exports: { '.': './dist/index.js' }, }); - expect(() => resolveRstackConfigLoader(root)).toThrow(RstackBridgeError); + // A package-version prerequisite is a gate (`version mismatch`), never a + // crash: the remedy is an upgrade, and the status detail is where it goes. + expect(() => resolveRstackConfigLoader(root)).toThrow( + RstackBridgeGateError, + ); expect(() => resolveRstackConfigLoader(root)).toThrow(/0\.4\.0/); }); it('reports an unrecognised ./config shape without asking for an upgrade', () => { // The entry is there, so the install is not old — telling the user to - // upgrade would send them somewhere that cannot help. + // upgrade would send them somewhere that cannot help. Not a gate either: + // this is a broken install shape, not a version prerequisite. const root = makeProject({ name: 'rstack', exports: { './config': { types: './dist/configExports.d.ts' } }, }); expect(() => resolveRstackConfigLoader(root)).toThrow(RstackBridgeError); + expect(() => resolveRstackConfigLoader(root)).not.toThrow( + RstackBridgeGateError, + ); expect(() => resolveRstackConfigLoader(root)).toThrow( /no target an ESM import can take/, ); @@ -435,6 +443,10 @@ describe('resolving the project rstack config loader', () => { expect(() => resolveRstackConfigLoader(root)).toThrow( /Could not resolve the "rstack" package/, ); + // Missing package = install prerequisite = gate, like the toolchain gap. + expect(() => resolveRstackConfigLoader(root)).toThrow( + RstackBridgeGateError, + ); }); }); From eed2a719b868bbe85a3e7a4cc5f2f8d82e927a76 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 14 Aug 2026 17:12:28 +0800 Subject: [PATCH 07/11] fix(vscode): walk ancestors in the Yarn PnP diagnostic probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe behind the core-not-found message checked only the workspace folder root for .pnp.cjs/.pnp.js, but the package lookup it diagnoses walks ancestors — and in a PnP monorepo the manifest sits at the repository root, not in the subpackage opened as the folder. Such a folder got the generic 'install @rslint/core' remedy, which under PnP creates no physical node_modules and fixes nothing. The probe now walks the same ancestor chain, so the message names the unsupported layout. --- packages/vscode/src/stacks/lint/resolution.ts | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index b47a0e1..c2f2a13 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -96,6 +96,30 @@ export const assertSingleResolutionRoot = ( } }; +/** + * Diagnostic-only probe: does any ancestor of `fromDir` carry a Yarn PnP + * manifest? Walks ancestors exactly like the package lookup it diagnoses — in + * a PnP monorepo the `.pnp.cjs` sits at the repository root, not in the + * subpackage opened as the workspace folder. + */ +const usesYarnPnp = (fromDir: string): boolean => { + let dir = path.resolve(fromDir); + for (;;) { + if ( + ['.pnp.cjs', '.pnp.js'].some((name) => + fs.existsSync(path.join(dir, name)), + ) + ) { + return true; + } + const parent = path.dirname(dir); + if (parent === dir) { + return false; + } + dir = parent; + } +}; + /** Returns the path of the project's `@rslint/core/package.json`. */ const locateCore = (searchRoot: string, logger: Logger): string => { // Uncached on purpose: a failed root is retried after dependency changes @@ -112,11 +136,8 @@ const locateCore = (searchRoot: string, logger: Logger): string => { // Diagnostic only, never a resolution branch: under Yarn PnP the usual // remedy (installing @rslint/core as a devDependency) writes no physical // `node_modules`, so the message must name the layout as the blocker. - const usesPnp = ['.pnp.cjs', '.pnp.js'].some((name) => - fs.existsSync(path.join(searchRoot, name)), - ); throw new RslintCoreNotFoundError( - usesPnp + usesYarnPnp(searchRoot) ? `Could not resolve @rslint/core from ${searchRoot}: this project uses Yarn Plug'n'Play, which this extension does not support — switch to nodeLinker: node-modules to lint in the editor.` : `Could not resolve @rslint/core from ${searchRoot}. This extension ships no Rslint binary — install @rslint/core in the project (this extension requires ${SUPPORT_MATRIX['@rslint/core']}).`, ); From d7072cf84943a7b369190b87d210dab37eb5dabd Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 14 Aug 2026 17:34:34 +0800 Subject: [PATCH 08/11] fix(vscode): heal the generated shim on every config refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim can vanish with no watcher event at all — node_modules events are swallowed by the default files.watcherExclude, and a frozen-lockfile reinstall or a cache wipe rewrites no lockfile — so keying its re-materialization on dependency-change events left the pin pointing at a missing file until a manual restart. Every debounced refresh now re-materializes the shim right before the server is told to reload (no-churn write: the intact case stays a pure read), which also covers the initial refresh a server restart replays; the accumulated bridgeShimRefreshPending flag and its lifecycle resets go away entirely. The shim-rewrite failure note clears itself on the first refresh that succeeds, instead of outliving the condition it describes. Also, an unreadable rstack manifest now throws the ordinary bridge error instead of joining the no-./config prerequisite gate: a manifest that cannot be parsed is a broken install, not an old one, and 'upgrade rstack' would mislead. Docs updated where they contradicted the above (the config watcher does not normally see shim writes; the no-churn rule is what prevents a refresh loop, not watcher ordering). --- docs/adr/0003-lint-rstack-bridge.md | 4 +- packages/vscode/AGENTS.md | 2 +- packages/vscode/src/stacks/lint/Rslint.ts | 82 ++++++++++--------- .../vscode/src/stacks/lint/rstackBridge.ts | 16 +++- .../tests/stacks/lint/rstackBridge.test.ts | 19 +++++ 5 files changed, 79 insertions(+), 44 deletions(-) diff --git a/docs/adr/0003-lint-rstack-bridge.md b/docs/adr/0003-lint-rstack-bridge.md index 5e810f7..f4d1146 100644 --- a/docs/adr/0003-lint-rstack-bridge.md +++ b/docs/adr/0003-lint-rstack-bridge.md @@ -6,7 +6,7 @@ A **bridged folder** — no native `rslint.config.*` anywhere in the workspace f `rs lint`'s own answer to "lint from the Rstack config" is a shim rstack ships (`dist/rslintConfig.js`) and injects through Rslint's ordinary explicit-config channel. The editor cannot point the server at that file: it calls `loadRstackConfig()` with no arguments, which probes the **evaluation** cwd — and the server evaluates config modules in the extension host, whose cwd is meaningless. So the extension renders its own shim with two absolute paths baked in: the project's `rstack/config` export (resolved out of the package's own `exports` map, so a layout change in rstack follows automatically) and the folder-root `rstack.config.*`. The body mirrors rstack's shipped shim — take `configs.lint ?? []`, await a function, default-export the result. `define.lint`'s value **is** an Rslint flat config; no translation happens anywhere in the chain, and none may ever be added. -The shim lives inside the project, not in extension storage, so module and plugin resolution from it anchors on the project. `node_modules/.cache/` is the conventional home for tool-generated files, and living under `node_modules` is what makes its deliberately config-like basename safe: detection excludes `node_modules` outright, so the shim can never be mistaken for a native config and flip Ownership against itself. Its lifecycle follows the pin: written before the server starts, rewritten only when its content actually differs (it sits under the config watcher — every rewrite is a config mutation), re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode or leaves detection altogether — a window close deliberately keeps it, since the next start re-materializes in place. +The shim lives inside the project, not in extension storage, so module and plugin resolution from it anchors on the project. `node_modules/.cache/` is the conventional home for tool-generated files, and living under `node_modules` is what makes its deliberately config-like basename safe: detection excludes `node_modules` outright, so the shim can never be mistaken for a native config and flip Ownership against itself. Its lifecycle follows the pin: written before the server starts, rewritten only when its content actually differs (the config watcher normally never sees shim writes — the default `files.watcherExclude` swallows `node_modules` events — but that is a user setting, and the no-churn rule is what guarantees a write cannot feed a refresh loop), re-materialized under the _same_ path on every config refresh (a reinstall or a cache wipe can delete it or dangle the store path baked into it, such deletions produce no watcher event either, and the pin cannot move — the no-churn write keeps the intact case a pure read), deleted when the folder starts in native mode or leaves detection altogether — a window close deliberately keeps it, since the next start re-materializes in place. ## Why Ownership is per folder, and only a root config bridges @@ -18,7 +18,7 @@ Bridged mode starts only when the project's own `@rslint/core/config-loader` rep ## Why a gated folder reports `version mismatch`, never `crashed` -The gates refusing a bridged folder are raised as one `RstackBridgeGateError` differing only in message: the **capability** gate above; the **toolchain** gate — `@rslint/core` does not resolve from the folder at all; and the **rstack-loader** gates — `rstack` itself missing, or too old to export `./config` (`< 0.4.0`). All describe package prerequisites whose remedy is an install or an upgrade — exactly what `version mismatch` means. A failure that is _not_ a prerequisite (a present `./config` export in an unusable shape, a missing platform binary of a resolvable `@rslint/core`) keeps its ordinary classification: its remedy is not "install/upgrade", so the gate message would mislead. The toolchain gap is the _mainstream_ shape, not an edge case: `@rslint/core` reaches a bridged project only as `rstack`'s transitive dependency, and isolated `node_modules` layouts (pnpm's default) deliberately do not expose transitive dependencies, so the typical rstack-cli app — whose only config is `rstack.config.ts` — hits it. Nobody in that folder asked for Rslint by name; `crashed` outranks every other folder in the status aggregation; and the status detail is the only place the fix can be stated (add `@rslint/core` to `devDependencies`, or upgrade it, or write an `rslint.config.*`). Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`, an explicit request for a tool that is missing. +The gates refusing a bridged folder are raised as one `RstackBridgeGateError` differing only in message: the **capability** gate above; the **toolchain** gate — `@rslint/core` does not resolve from the folder at all; and the **rstack-loader** gates — `rstack` itself missing, or too old to export `./config` (`< 0.4.0`). All describe package prerequisites whose remedy is an install or an upgrade — exactly what `version mismatch` means. A failure that is _not_ a prerequisite (a present `./config` export in an unusable shape, an unreadable `rstack` manifest, a missing platform binary of a resolvable `@rslint/core`) keeps its ordinary classification: its remedy is not "install/upgrade", so the gate message would mislead. The toolchain gap is the _mainstream_ shape, not an edge case: `@rslint/core` reaches a bridged project only as `rstack`'s transitive dependency, and isolated `node_modules` layouts (pnpm's default) deliberately do not expose transitive dependencies, so the typical rstack-cli app — whose only config is `rstack.config.ts` — hits it. Nobody in that folder asked for Rslint by name; `crashed` outranks every other folder in the status aggregation; and the status detail is the only place the fix can be stated (add `@rslint/core` to `devDependencies`, or upgrade it, or write an `rslint.config.*`). Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`, an explicit request for a tool that is missing. ## Considered options diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 7d0e70b..4fb43fc 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -15,7 +15,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 6. **Node runtime selection** (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. -7. **Rstack config bridge** (lint) — a **bridged folder** (no native `rslint.config.*` anywhere in the folder, an `rstack.config.*` at its root) is linted from the Rstack config: the extension writes a **generated shim** into the project and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`. Native mode stays byte-identical to upstream — the field is absent, so the server keeps doing its own discovery. In `Rslint.ts` every line of it sits between a `--- rstack config bridge ---` / `--- end rstack config bridge ---` marker pair (no single-line markers — the pairing is what an upstream-sync diff greps for); the rule, the shim and the gate live in `stacks/lint/rstackBridge.ts`. The shim's lifecycle follows the pin: written before the server starts, re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode or leaves detection. The full rule and rationale: `docs/adr/0003-lint-rstack-bridge.md`. +7. **Rstack config bridge** (lint) — a **bridged folder** (no native `rslint.config.*` anywhere in the folder, an `rstack.config.*` at its root) is linted from the Rstack config: the extension writes a **generated shim** into the project and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`. Native mode stays byte-identical to upstream — the field is absent, so the server keeps doing its own discovery. In `Rslint.ts` every line of it sits between a `--- rstack config bridge ---` / `--- end rstack config bridge ---` marker pair (no single-line markers — the pairing is what an upstream-sync diff greps for); the rule, the shim and the gate live in `stacks/lint/rstackBridge.ts`. The shim's lifecycle follows the pin: written before the server starts, re-materialized under the _same_ path on every config refresh (a reinstall or a cache wipe can delete it or dangle the store path baked into it, not every such event reaches the watcher, and the pin cannot move), deleted when the folder starts in native mode or leaves detection. The full rule and rationale: `docs/adr/0003-lint-rstack-bridge.md`. ## Rules diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index b2f20b3..a1db105 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -167,6 +167,14 @@ export const BRIDGED_CONFIG_REFRESH_WATCH_GLOB = `**/{${[ ...RSTACK_CONFIG_PROBE_ORDER, ...LOCKFILE_NAMES, ].join(',')}}`; + +/** + * One constant so the success path of `refreshGeneratedShim` can remove + * exactly the note its failure path added. No restart instruction: every + * config refresh retries on its own. + */ +const SHIM_REFRESH_FAILURE_NOTE = + 'the generated Rslint config shim could not be rewritten; fix the install and it heals on the next config refresh'; // --- end rstack config bridge --- /** @@ -508,22 +516,14 @@ export class Rslint implements Disposable { /** The generated shim the server is pinned to. */ readonly configPath: string; /** - * The Rstack config the shim was generated from, kept for the *only* - * thing that may rewrite the shim after the start: a dependency change, - * which can delete it (it lives under `node_modules`) or dangle the - * store path baked into it. + * The Rstack config the shim was generated from, kept so every config + * refresh can re-materialize the shim in place — a reinstall or a + * cache wipe can delete it (it lives under `node_modules`) or dangle + * the store path baked into it. */ readonly rstackConfigPath: string; } | undefined; - /** - * True when a dependency change arrived since the last config refresh. The - * watcher debounce keeps only the *last* event's reason, so a lockfile event - * followed within the window by a config edit would otherwise lose the one - * signal that forces shim re-materialization. Accumulated per event (like - * `pluginDependencyRevision`), consumed by the refresh that fires. - */ - private bridgeShimRefreshPending = false; // --- end rstack config bridge --- /** Non-fatal notes appended to the folder's status detail. */ private statusNotes: string[] = []; @@ -611,6 +611,17 @@ export class Rslint implements Disposable { } } + private removeStatusNote(note: string): void { + const index = this.statusNotes.indexOf(note); + if (index === -1) { + return; + } + this.statusNotes.splice(index, 1); + if (this.isRunning()) { + this.report({ kind: 'running', detail: this.runningDetail() }); + } + } + public async start(signal: AbortSignal): Promise { if (this.startPromise) { await this.startPromise; @@ -668,7 +679,6 @@ export class Rslint implements Disposable { const epoch = this.lifecycleEpoch; const pluginLintPool = this.pluginLintPool; this.pluginDependencyRevision = 0; - this.bridgeShimRefreshPending = false; this.statusNotes = []; this.report({ kind: 'starting' }); @@ -1048,12 +1058,14 @@ export class Rslint implements Disposable { /** * Re-materializes the generated shim under its existing path. * - * Called on a dependency change and nowhere else. A reinstall can delete the - * shim outright (it lives under `node_modules`) or leave the loader path - * baked into it dangling, since that path is realpath'd into a - * version-pinned store — and the `configPath` the server was pinned to is - * immutable for its process lifetime, so the file has to come back at the - * same path rather than the pin moving to a new one. + * Called on every debounced config refresh, right before the server is told + * to reload. A reinstall or a cache wipe can delete the shim outright (it + * lives under `node_modules`) or leave the loader path baked into it + * dangling, since that path is realpath'd into a version-pinned store — and + * the `configPath` the server was pinned to is immutable for its process + * lifetime, so the file has to come back at the same path rather than the + * pin moving to a new one. The no-churn write keeps the common case (the + * shim is intact and nothing moved) a pure read. */ private refreshGeneratedShim(): void { const bridge = this.bridge; @@ -1064,19 +1076,18 @@ export class Rslint implements Disposable { const shim = this.materializeShim(bridge.rstackConfigPath); if (shim.written) { this.logger.info( - `Re-materialized the generated Rslint config shim after a dependency change: ${shim.path}`, + `Re-materialized the generated Rslint config shim: ${shim.path}`, ); } + // Every refresh retries, so a past failure is cleared the moment one + // succeeds — the note must not outlive the condition it describes. + this.removeStatusNote(SHIM_REFRESH_FAILURE_NOTE); } catch (error) { - // The pin cannot move, so this is as far as recovery goes: say what - // happened and what clears it. this.logger.error( 'Failed to re-materialize the generated Rslint config shim', error, ); - this.addStatusNote( - 'the generated Rslint config shim could not be rewritten after a dependency change; run Rstack: Restart Rslint', - ); + this.addStatusNote(SHIM_REFRESH_FAILURE_NOTE); } } @@ -1187,9 +1198,6 @@ export class Rslint implements Disposable { // identical. Feed a monotonic dependency revision into the staged host // fingerprint so any lockfile mutation forces a worker rebuild. this.pluginDependencyRevision++; - // --- rstack config bridge --- - this.bridgeShimRefreshPending = true; - // --- end rstack config bridge --- } this.logger.debug(`${reason}: ${uri.fsPath}`); clearTimeout(this.configReloadTimer); @@ -1232,15 +1240,14 @@ export class Rslint implements Disposable { return; } // --- rstack config bridge --- - // The one thing that can invalidate a bridged folder's pin without - // changing it: a reinstall under the shim, whose file the server is about - // to be told to reload. Keyed off the accumulated flag, not `reason`: the - // debounce keeps only the last event's reason, and a config edit landing - // right after a lockfile event must not swallow the re-materialization. - if (this.bridgeShimRefreshPending) { - this.bridgeShimRefreshPending = false; - this.refreshGeneratedShim(); - } + // A reinstall or a cache wipe can invalidate a bridged folder's pin + // without changing it — delete the shim outright or dangle the loader + // path baked into it — and not every such event reaches this watcher + // (`node_modules` events are excluded, and a frozen-lockfile reinstall + // rewrites no lockfile). So *every* refresh re-materializes the shim the + // server is about to reload, not just dependency-change ones; the + // no-churn write makes that a no-op whenever nothing actually moved. + this.refreshGeneratedShim(); // --- end rstack config bridge --- const request: ConfigRefreshRequest = { protocolVersion: configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION, @@ -1443,7 +1450,6 @@ export class Rslint implements Disposable { } const results = await Promise.allSettled(asynchronousCleanups); this.pluginDependencyRevision = 0; - this.bridgeShimRefreshPending = false; for (const result of results) { if (result.status === 'rejected') { diff --git a/packages/vscode/src/stacks/lint/rstackBridge.ts b/packages/vscode/src/stacks/lint/rstackBridge.ts index 67e8232..e66e9be 100644 --- a/packages/vscode/src/stacks/lint/rstackBridge.ts +++ b/packages/vscode/src/stacks/lint/rstackBridge.ts @@ -95,8 +95,11 @@ const GENERATED_SHIM_RELATIVE_DIR = path.join( * every `node_modules` path outright, so the generated shim can never be * mistaken for a native config — which would flip Ownership to native and * restart the folder into the mode that deletes the shim's reason to exist. The - * config-refresh watcher is separately protected by being written before it is - * installed, and by only being rewritten when its content actually changes. + * config-refresh watcher normally never sees the shim either — VS Code's + * default `files.watcherExclude` swallows `node_modules` events — but that is + * a user setting, so the no-churn write rule below is what actually guarantees + * a shim write cannot feed a refresh loop (and it keeps the intact case a pure + * read on every refresh). */ const GENERATED_SHIM_BASENAME = 'rslint.config.mjs'; @@ -320,7 +323,14 @@ export const resolveRstackConfigLoader = (folderPath: string): string => { ); } const packageJson = readPackageJson(packageJsonPath); - const entry = packageJson ? readConfigExportEntry(packageJson) : undefined; + // An unreadable manifest is a broken install, not a package prerequisite — + // "upgrade rstack" would mislead, so it is not a gate. + if (packageJson === undefined) { + throw new RstackBridgeError( + `Could not read the installed "rstack" manifest at ${packageJsonPath}. The install looks broken — run the project's package manager, or add an rslint.config.* file.`, + ); + } + const entry = readConfigExportEntry(packageJson); if (entry === undefined) { throw new RstackBridgeGateError( `The installed "rstack" (${packageJsonPath}) does not export "./config". Upgrade rstack to a version that publishes the config loader (>= 0.4.0), or add an rslint.config.* file.`, diff --git a/packages/vscode/tests/stacks/lint/rstackBridge.test.ts b/packages/vscode/tests/stacks/lint/rstackBridge.test.ts index e26f8ad..aaa980d 100644 --- a/packages/vscode/tests/stacks/lint/rstackBridge.test.ts +++ b/packages/vscode/tests/stacks/lint/rstackBridge.test.ts @@ -437,6 +437,25 @@ describe('resolving the project rstack config loader', () => { expect(() => resolveRstackConfigLoader(root)).not.toThrow(/0\.4\.0/); }); + it('reports an unreadable manifest as a broken install, not a gate', () => { + // A manifest that cannot be parsed means a broken install, not an old + // one — "upgrade rstack" would mislead, so it must not join the + // no-./config prerequisite in the gate. + const root = makeProject({ name: 'rstack' }); + fs.writeFileSync( + path.join(root, 'node_modules', 'rstack', 'package.json'), + 'not json', + ); + expect(() => resolveRstackConfigLoader(root)).toThrow(RstackBridgeError); + expect(() => resolveRstackConfigLoader(root)).not.toThrow( + RstackBridgeGateError, + ); + expect(() => resolveRstackConfigLoader(root)).toThrow( + /Could not read the installed "rstack" manifest/, + ); + expect(() => resolveRstackConfigLoader(root)).not.toThrow(/0\.4\.0/); + }); + it('reports a missing rstack install instead of guessing a path', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstack-empty-')); roots.push(root); From fce8a2fc93dbb91e73f7871d24d315ea6fa271f3 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 14 Aug 2026 17:48:32 +0800 Subject: [PATCH 09/11] fix(vscode): match Node's conditional-export semantics in the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pickImportTarget walked a fixed preference list (import, node, module, default) over a conditional ./config export, but Node walks the condition map in declaration order and takes the first enabled key — { default: A, import: B } resolves to A, and module is a bundler-only condition Node never enables (both verified empirically against Node). The probe now iterates the entry's own keys in order, matching only the conditions Node enables for the shim's ESM import ({node, import, default}), so the loader baked into the generated shim is the file an import of rstack/config would actually execute. Two unit tests pin the declaration-order and module-skipping rules. Also stop promising a version-mismatch status for the host-runtime type-stripping condition: the README listed it beside the three package prerequisites under one 'shows version mismatch' sentence, but the preflight only warns and a config that fails to load stays a crash by design. The README now separates the editor-owned condition, and ADR 0003's consequence states the classification. --- docs/adr/0003-lint-rstack-bridge.md | 2 +- packages/vscode/README.md | 3 +- .../vscode/src/stacks/lint/rstackBridge.ts | 31 ++++++------ .../tests/stacks/lint/rstackBridge.test.ts | 50 +++++++++++++++++-- 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/docs/adr/0003-lint-rstack-bridge.md b/docs/adr/0003-lint-rstack-bridge.md index f4d1146..ebb3289 100644 --- a/docs/adr/0003-lint-rstack-bridge.md +++ b/docs/adr/0003-lint-rstack-bridge.md @@ -34,7 +34,7 @@ The gates refusing a bridged folder are raised as one `RstackBridgeGateError` di ## Consequences -- A `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively — `@rslint/core`'s jiti fallback covers the entry config file it loads, not the imports that file makes, and the generated shim's import of the config goes through rstack's loader. The preflight (`describeRstackConfigLoaderPreflight`) turns that into a diagnostic, never a behaviour change; VS Code's release cadence owns the condition. +- A `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively — `@rslint/core`'s jiti fallback covers the entry config file it loads, not the imports that file makes, and the generated shim's import of the config goes through rstack's loader. The preflight (`describeRstackConfigLoaderPreflight`) turns that into a diagnostic, never a behaviour change; VS Code's release cadence owns the condition. A config that then fails to load is not a package prerequisite, so it keeps its ordinary classification — a crash, not a `version mismatch`. - pnpm users must add `@rslint/core` to `devDependencies` for bridged linting; the README states it and the toolchain gate's status repeats it. - The generated shim is a build artifact the extension owes hygiene for: never committed, deleted on a native flip and when the folder leaves detection, and `writeGeneratedShim`'s no-churn rule keeps the config watcher from seeing phantom edits. - The mode choice is immutable per server process, so anything that changes it — a native config appearing, the root Rstack config vanishing — restarts that folder's server; there is no message path to a live one. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 79023c8..1ee05b6 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -47,10 +47,11 @@ Linting a folder from `define.lint()` in `rstack.config.*` asks more: - `rstack` must publish its config loader (`>=0.4.0`). - `@rslint/core` must be **installed in the project**. `rstack` depends on it, but package managers with an isolated `node_modules` layout (pnpm by default) do not expose transitive dependencies, so add `@rslint/core` to your `devDependencies`. - `@rslint/core` must be new enough to speak config-discovery protocol 2, which is what lets the editor pin the language server to a config — in practice `>= 0.8.0`, the first release that does. The extension probes the protocol, not the version number. -- A TypeScript `rstack.config.ts` must be loadable by the VS Code extension host's Node: rstack's config loader relies on native type stripping and has no fallback, so on an older VS Code build use `rstack.config.mjs` (or `.js`). Until all of them hold, such a folder shows `version mismatch` with a message naming the missing piece instead of linting; adding an `rslint.config.*` is the way out today. Folders with an `rslint.config.*` are unaffected. +One more condition belongs to the editor, not to a package: a TypeScript `rstack.config.ts` must be loadable by the VS Code extension host's Node — rstack's config loader relies on native type stripping and has no fallback. On an affected build the extension warns in the status detail, and a config that then fails to load reports as a crash rather than a `version mismatch`. Use `rstack.config.mjs` (or `.js`) there. + ## Auto-fix on save (Rslint) To automatically fix lint issues when saving, add this to your VS Code settings (`.vscode/settings.json`): diff --git a/packages/vscode/src/stacks/lint/rstackBridge.ts b/packages/vscode/src/stacks/lint/rstackBridge.ts index e66e9be..997bf99 100644 --- a/packages/vscode/src/stacks/lint/rstackBridge.ts +++ b/packages/vscode/src/stacks/lint/rstackBridge.ts @@ -260,22 +260,24 @@ const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); /** - * The export conditions an ESM `import` of the generated shim matches, most - * specific first. `require` is deliberately absent: the shim is `.mjs` and - * imports the target by `file:` URL, so taking a `require` branch would pin it - * to a CommonJS build the moment `rstack` splits the entry. + * The conditions Node itself enables for an ESM `import` in the extension + * host: `node` and `import`, with `default` matching always. `require` is + * deliberately absent: the shim is `.mjs` and imports the target by `file:` + * URL, so a `require` branch would pin it to a CommonJS build the moment + * `rstack` splits the entry. So are bundler-only conditions like `module`, + * which Node never enables. */ -const IMPORT_EXPORT_CONDITIONS = [ - 'import', - 'node', - 'module', - 'default', -] as const; +const NODE_IMPORT_CONDITIONS = new Set(['node', 'import', 'default']); /** * The target an ESM importer would take out of one `exports` entry: a string - * is the target, an object is a condition map (recursed into, conditions in - * the order above), an array is a fallback list (first entry that yields one). + * is the target, an object is a condition map, an array is a fallback list + * (first entry that yields one). + * + * A condition map is walked in *declaration order*, taking the first enabled + * key — Node's semantics, not a fixed preference list. `{ default: A, + * import: B }` resolves to A, and the shim must agree with the `import` the + * server will actually execute (empirically verified against Node). * * Not `createRequire(...).resolve('rstack/config')`: Node's require-resolution * matches the `require` conditions, while the generated shim is ESM. @@ -290,8 +292,9 @@ const pickImportTarget = (entry: unknown): string | undefined => { return undefined; } if (!isRecord(entry)) return undefined; - for (const condition of IMPORT_EXPORT_CONDITIONS) { - const target = pickImportTarget(entry[condition]); + for (const [condition, value] of Object.entries(entry)) { + if (!NODE_IMPORT_CONDITIONS.has(condition)) continue; + const target = pickImportTarget(value); if (target !== undefined) return target; } return undefined; diff --git a/packages/vscode/tests/stacks/lint/rstackBridge.test.ts b/packages/vscode/tests/stacks/lint/rstackBridge.test.ts index aaa980d..5789ee8 100644 --- a/packages/vscode/tests/stacks/lint/rstackBridge.test.ts +++ b/packages/vscode/tests/stacks/lint/rstackBridge.test.ts @@ -361,9 +361,9 @@ describe('resolving the project rstack config loader', () => { }); it('takes the ESM branch of a split entry, never the CommonJS default', () => { - // The shim is `.mjs` and imports the target by `file:` URL, so `import` - // beats `default` — a fixed preference list ending in `default` would grab - // the CommonJS build the moment `rstack` splits the entry. + // The shim is `.mjs` and imports the target by `file:` URL, so the + // `require` branch must never win; `import` beats `default` here because + // it is declared first, exactly as Node would resolve it. const root = makeProject({ name: 'rstack', exports: { @@ -437,6 +437,50 @@ describe('resolving the project rstack config loader', () => { expect(() => resolveRstackConfigLoader(root)).not.toThrow(/0\.4\.0/); }); + it('walks a condition map in declaration order, as Node does', () => { + // Node takes the first *enabled* key in declaration order, so a `default` + // declared before `import` wins even though an import build exists — a + // fixed preference list would disagree with the `import` the server + // actually executes. + const root = makeProject({ + name: 'rstack', + exports: { + './config': { + default: './dist/configExports.cjs', + import: './dist/configExports.js', + }, + }, + }); + fs.writeFileSync( + path.join(root, 'node_modules/rstack/dist/configExports.cjs'), + 'module.exports = {};', + ); + expect(resolveRstackConfigLoader(root)).toBe( + fs.realpathSync( + path.join(root, 'node_modules/rstack/dist/configExports.cjs'), + ), + ); + }); + + it('skips conditions Node never enables, like the bundler-only module', () => { + // `module` is a bundler condition; Node resolving the shim's `import` + // skips it even when declared first. + const root = makeProject({ + name: 'rstack', + exports: { + './config': { + module: './dist/configExports.module.js', + default: './dist/configExports.js', + }, + }, + }); + expect(resolveRstackConfigLoader(root)).toBe( + fs.realpathSync( + path.join(root, 'node_modules/rstack/dist/configExports.js'), + ), + ); + }); + it('reports an unreadable manifest as a broken install, not a gate', () => { // A manifest that cannot be parsed means a broken install, not an old // one — "upgrade rstack" would mislead, so it must not join the From 8cd5c3911d5d03869e1ab5a6764fbac482ed22f6 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Sat, 15 Aug 2026 15:49:59 +0800 Subject: [PATCH 10/11] fix(vscode): degrade a bridged folder when the gate closes mid-life MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A config refresh that finds a package prerequisite gone — rstack removed, or downgraded below the ./config export — used to shrink into a nonfatal status note, leaving the folder 'running' on a stale or missing shim until a manual restart. The shim-refresh failure path now reports the same classification the start path gives that gate: 'version mismatch' with the written-out fix, logged as a warning rather than an error since it is an expected, user-fixable state. Recovery is stateless: every successful re-materialization re-asserts the running status (the status bar drops same-state repeats), so the first refresh after the prerequisite returns restores the folder without a restart. Non-gate failures keep the retriable status note. The bridge-only removeStatusNote helper is gone — the success path filters the note inline, inside the marker block. ADR 0003 and the AGENTS.md gate gotcha record the mid-life classification. --- docs/adr/0003-lint-rstack-bridge.md | 2 +- packages/vscode/AGENTS.md | 2 +- packages/vscode/src/stacks/lint/Rslint.ts | 33 +++++++++++++---------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/adr/0003-lint-rstack-bridge.md b/docs/adr/0003-lint-rstack-bridge.md index ebb3289..73639f2 100644 --- a/docs/adr/0003-lint-rstack-bridge.md +++ b/docs/adr/0003-lint-rstack-bridge.md @@ -18,7 +18,7 @@ Bridged mode starts only when the project's own `@rslint/core/config-loader` rep ## Why a gated folder reports `version mismatch`, never `crashed` -The gates refusing a bridged folder are raised as one `RstackBridgeGateError` differing only in message: the **capability** gate above; the **toolchain** gate — `@rslint/core` does not resolve from the folder at all; and the **rstack-loader** gates — `rstack` itself missing, or too old to export `./config` (`< 0.4.0`). All describe package prerequisites whose remedy is an install or an upgrade — exactly what `version mismatch` means. A failure that is _not_ a prerequisite (a present `./config` export in an unusable shape, an unreadable `rstack` manifest, a missing platform binary of a resolvable `@rslint/core`) keeps its ordinary classification: its remedy is not "install/upgrade", so the gate message would mislead. The toolchain gap is the _mainstream_ shape, not an edge case: `@rslint/core` reaches a bridged project only as `rstack`'s transitive dependency, and isolated `node_modules` layouts (pnpm's default) deliberately do not expose transitive dependencies, so the typical rstack-cli app — whose only config is `rstack.config.ts` — hits it. Nobody in that folder asked for Rslint by name; `crashed` outranks every other folder in the status aggregation; and the status detail is the only place the fix can be stated (add `@rslint/core` to `devDependencies`, or upgrade it, or write an `rslint.config.*`). Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`, an explicit request for a tool that is missing. +The gates refusing a bridged folder are raised as one `RstackBridgeGateError` differing only in message: the **capability** gate above; the **toolchain** gate — `@rslint/core` does not resolve from the folder at all; and the **rstack-loader** gates — `rstack` itself missing, or too old to export `./config` (`< 0.4.0`). All describe package prerequisites whose remedy is an install or an upgrade — exactly what `version mismatch` means. A failure that is _not_ a prerequisite (a present `./config` export in an unusable shape, an unreadable `rstack` manifest, a missing platform binary of a resolvable `@rslint/core`) keeps its ordinary classification: its remedy is not "install/upgrade", so the gate message would mislead. The toolchain gap is the _mainstream_ shape, not an edge case: `@rslint/core` reaches a bridged project only as `rstack`'s transitive dependency, and isolated `node_modules` layouts (pnpm's default) deliberately do not expose transitive dependencies, so the typical rstack-cli app — whose only config is `rstack.config.ts` — hits it. Nobody in that folder asked for Rslint by name; `crashed` outranks every other folder in the status aggregation; and the status detail is the only place the fix can be stated (add `@rslint/core` to `devDependencies`, or upgrade it, or write an `rslint.config.*`). Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`, an explicit request for a tool that is missing. The classification also holds mid-life: a config refresh that finds a prerequisite vanished (`rstack` removed or downgraded under a pinned shim) degrades the folder to `version mismatch`, and the first refresh that re-materializes the shim restores `running`. ## Considered options diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 4fb43fc..ba5a7a8 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -36,7 +36,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - The lint × `rstack.config.*` bridge is complete, and every part of it that looks like an arbitrary restriction is forced by the language server. Ownership is decided per **folder**, not per directory — one native config anywhere and the bridge yields entirely, silently — and only a **root** `rstack.config.*` bridges; a subdirectory one does not light lint at all, a documented limitation, not an oversight. The shim is **generated**, never rstack's shipped `dist/rslintConfig.js`, and never interprets the config: `define.lint`'s value is an Rslint flat config, taken through the project's own `rstack/config` export. A mode flip is a **restart** (the controller replays the folder through the coordinator's replacement path), never a message to a live server. Why all of it: `docs/adr/0003-lint-rstack-bridge.md`. - Bridged mode is gated on a **capability**, not a version number: the project's `@rslint/core/config-loader` must report config-discovery protocol >= 2 (the version that carries `configPath`, rslint PR #1630). Do not replace the probe with a guessed release number, and do not "fall back" to automatic discovery for a bridged folder: the server would find no config and report nothing, which reads as a broken extension. -- A bridged folder blocked by a **package prerequisite** reports `version mismatch`, never `crashed` — one `RstackBridgeGateError` for every gate (protocol too old; `@rslint/core` not resolving at all; `rstack` missing or predating the `./config` export), and the coordinator is told it is an expected failure. A failure that is not a prerequisite keeps its ordinary classification. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`. The rationale, and the full gate/non-gate line, is ADR 0003's. +- A bridged folder blocked by a **package prerequisite** reports `version mismatch`, never `crashed` — one `RstackBridgeGateError` for every gate (protocol too old; `@rslint/core` not resolving at all; `rstack` missing or predating the `./config` export), and the coordinator is told it is an expected failure — at start; a config refresh that finds a prerequisite gone degrades the folder to `version mismatch` mid-life (reported directly, no coordinator), and the first refresh that re-materializes the shim restores `running`. A failure that is not a prerequisite keeps its ordinary classification. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`. The rationale, and the full gate/non-gate line, is ADR 0003's. - Yarn PnP is unsupported by decision, extension-wide — do not re-add a partial PnP hop to any resolver. Lint's resolver once carried one (ported from an older upstream, removed when upstream went "no PnP or fallback"); a PnP project now surfaces the ordinary resolution failure, whose message names the layout. Why, and what real support would take: the PnP consequence in `docs/adr/0003-lint-rstack-bridge.md`. - 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`. diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index a1db105..93cfa88 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -611,17 +611,6 @@ export class Rslint implements Disposable { } } - private removeStatusNote(note: string): void { - const index = this.statusNotes.indexOf(note); - if (index === -1) { - return; - } - this.statusNotes.splice(index, 1); - if (this.isRunning()) { - this.report({ kind: 'running', detail: this.runningDetail() }); - } - } - public async start(signal: AbortSignal): Promise { if (this.startPromise) { await this.startPromise; @@ -1079,10 +1068,26 @@ export class Rslint implements Disposable { `Re-materialized the generated Rslint config shim: ${shim.path}`, ); } - // Every refresh retries, so a past failure is cleared the moment one - // succeeds — the note must not outlive the condition it describes. - this.removeStatusNote(SHIM_REFRESH_FAILURE_NOTE); + // Success is where recovery becomes observable: drop the failure note + // and re-assert the running status — nothing else re-reports a + // still-running folder a gate degrade replaced with `version mismatch`. + this.statusNotes = this.statusNotes.filter( + (note) => note !== SHIM_REFRESH_FAILURE_NOTE, + ); + if (this.isRunning()) { + this.report({ kind: 'running', detail: this.runningDetail() }); + } } catch (error) { + if (error instanceof RstackBridgeGateError) { + // A prerequisite that vanished mid-life is the start path's gate, not + // a failure of ours: `version mismatch` with the written-out fix + // (ADR 0003), never a silently `running` folder on a stale shim. + this.logger.warn( + `The rstack bridge gate closed mid-life: ${error.message}`, + ); + this.report({ kind: 'version-mismatch', detail: error.message }); + return; + } this.logger.error( 'Failed to re-materialize the generated Rslint config shim', error, From 65fa1cb0e7d947476b1ff78d3b85de9bd46e8ddc Mon Sep 17 00:00:00 2001 From: fi3ework Date: Sat, 15 Aug 2026 16:00:46 +0800 Subject: [PATCH 11/11] fix(vscode): never re-materialize the shim for a vanished source config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting the only root rstack.config.* fires two independent listeners: the shell's detection reconcile, whose prune path removes the generated shim, and the runtime's own config watcher, whose debounced refresh unconditionally re-materializes it. If the refresh callback ran after the prune but before the departing runtime finished closing, the shim came back and outlived the folder's detection as an orphan. refreshGeneratedShim now declines when the pinned source config no longer exists on disk: a vanished source means the folder is leaving detection and cleanup owns the file. A rename or atomic save merely skips one refresh — the existing shim stays put and the next refresh that finds the config back re-materializes as usual. --- docs/adr/0003-lint-rstack-bridge.md | 2 +- packages/vscode/src/stacks/lint/Rslint.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/adr/0003-lint-rstack-bridge.md b/docs/adr/0003-lint-rstack-bridge.md index 73639f2..454cf73 100644 --- a/docs/adr/0003-lint-rstack-bridge.md +++ b/docs/adr/0003-lint-rstack-bridge.md @@ -6,7 +6,7 @@ A **bridged folder** — no native `rslint.config.*` anywhere in the workspace f `rs lint`'s own answer to "lint from the Rstack config" is a shim rstack ships (`dist/rslintConfig.js`) and injects through Rslint's ordinary explicit-config channel. The editor cannot point the server at that file: it calls `loadRstackConfig()` with no arguments, which probes the **evaluation** cwd — and the server evaluates config modules in the extension host, whose cwd is meaningless. So the extension renders its own shim with two absolute paths baked in: the project's `rstack/config` export (resolved out of the package's own `exports` map, so a layout change in rstack follows automatically) and the folder-root `rstack.config.*`. The body mirrors rstack's shipped shim — take `configs.lint ?? []`, await a function, default-export the result. `define.lint`'s value **is** an Rslint flat config; no translation happens anywhere in the chain, and none may ever be added. -The shim lives inside the project, not in extension storage, so module and plugin resolution from it anchors on the project. `node_modules/.cache/` is the conventional home for tool-generated files, and living under `node_modules` is what makes its deliberately config-like basename safe: detection excludes `node_modules` outright, so the shim can never be mistaken for a native config and flip Ownership against itself. Its lifecycle follows the pin: written before the server starts, rewritten only when its content actually differs (the config watcher normally never sees shim writes — the default `files.watcherExclude` swallows `node_modules` events — but that is a user setting, and the no-churn rule is what guarantees a write cannot feed a refresh loop), re-materialized under the _same_ path on every config refresh (a reinstall or a cache wipe can delete it or dangle the store path baked into it, such deletions produce no watcher event either, and the pin cannot move — the no-churn write keeps the intact case a pure read), deleted when the folder starts in native mode or leaves detection altogether — a window close deliberately keeps it, since the next start re-materializes in place. +The shim lives inside the project, not in extension storage, so module and plugin resolution from it anchors on the project. `node_modules/.cache/` is the conventional home for tool-generated files, and living under `node_modules` is what makes its deliberately config-like basename safe: detection excludes `node_modules` outright, so the shim can never be mistaken for a native config and flip Ownership against itself. Its lifecycle follows the pin: written before the server starts, rewritten only when its content actually differs (the config watcher normally never sees shim writes — the default `files.watcherExclude` swallows `node_modules` events — but that is a user setting, and the no-churn rule is what guarantees a write cannot feed a refresh loop), re-materialized under the _same_ path on every config refresh so long as its source `rstack.config.*` still exists (a reinstall or a cache wipe can delete the shim or dangle the store path baked into it, such deletions produce no watcher event either, and the pin cannot move — the no-churn write keeps the intact case a pure read; a vanished source means the folder is leaving detection, whose cleanup a rewrite would race), deleted when the folder starts in native mode or leaves detection altogether — a window close deliberately keeps it, since the next start re-materializes in place. ## Why Ownership is per folder, and only a root config bridges diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 93cfa88..0f0d5e9 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -1061,6 +1061,13 @@ export class Rslint implements Disposable { if (!bridge) { return; } + // A pin whose source is gone must not be re-materialized: that deletion is + // what takes the folder out of detection, and a rewrite here would race + // the reconcile removing the shim (ADR 0003). A rename or atomic save + // merely skips one refresh — the shim stays, the next refresh restores it. + if (!fs.existsSync(bridge.rstackConfigPath)) { + return; + } try { const shim = this.materializeShim(bridge.rstackConfigPath); if (shim.written) {