From 911989537c83828bf0ba938716dbb0c5ee96f1a2 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Sat, 10 Oct 2026 11:54:18 +0800 Subject: [PATCH] fix(vscode): keep Rslint healthy when rstack.config has no define.lint() rstack's lint shim (dist/rslintConfig.js) calls process.exit(1) when the Rstack config defines no lint configuration. The editor's lint worker evaluates that shim in-process, so a formatting-only rstack.config.* killed the worker mid-transaction and took the Go server and the language client transport down with it: "No lint configuration found" followed by connection-disposed and server-restart errors on every TypeScript file. The bridged worker now builds its ConfigModuleHost with a loadFresh wrapped by withProcessExitAsThrow: during module evaluation, process.exit throws an error tagged RSTACK_CONFIG_PROCESS_EXIT, which lands as a failed load result. ConfigTransactionAdapter classifies that code as a new `unconfigured` verdict on rstack/rslintConfigDependency, beside `missing` and `plugin`. The editor reports `running` with the detail `no define.lint() in rstack.config.ts`, logs one info line per episode, and neither polls, crashes nor warns. Adding define.lint() later is picked up by the same worker through the bridged config watcher, without a restart. Native runtimes are untouched. Adds the rstack-fmt-only E2E fixture and suite, unit coverage for the wrapper, the verdict and the editor state, and records the verdict and the lint-only shim behaviour in packages/vscode/AGENTS.md. Closes #93 --- packages/vscode/AGENTS.md | 3 +- .../e2e/fixtures/rstack-fmt-only/.nvmrc | 1 + .../e2e/fixtures/rstack-fmt-only/package.json | 13 ++ .../rstack-fmt-only/pnpm-workspace.yaml | 10 ++ .../fixtures/rstack-fmt-only/rstack.config.ts | 8 + .../e2e/fixtures/rstack-fmt-only/src/index.ts | 6 + packages/vscode/e2e/lint/runTest.ts | 7 + .../fmtOnlyConfig.test.ts | 154 ++++++++++++++++++ .../e2e/lint/suite-fmt-only-config/index.ts | 3 + .../suite-jsconfig/config-transaction.test.ts | 1 + packages/vscode/e2e/run.mjs | 3 +- packages/vscode/e2e/setupFixtures.mjs | 1 + packages/vscode/src/stacks/lint/Rslint.ts | 34 +++- packages/vscode/src/stacks/lint/status.ts | 19 ++- .../lint/worker/ConfigTransactionAdapter.ts | 47 ++++++ .../lint/worker/configDependencyProtocol.ts | 8 + .../vscode/src/stacks/lint/worker/core.ts | 14 +- .../vscode/src/stacks/lint/worker/index.ts | 19 ++- .../vscode/tests/stacks/lint/start.test.ts | 95 ++++++++++- .../vscode/tests/stacks/lint/status.test.ts | 6 + .../vscode/tests/stacks/lint/worker.test.ts | 135 ++++++++++++--- 21 files changed, 549 insertions(+), 38 deletions(-) create mode 100644 packages/vscode/e2e/fixtures/rstack-fmt-only/.nvmrc create mode 100644 packages/vscode/e2e/fixtures/rstack-fmt-only/package.json create mode 100644 packages/vscode/e2e/fixtures/rstack-fmt-only/pnpm-workspace.yaml create mode 100644 packages/vscode/e2e/fixtures/rstack-fmt-only/rstack.config.ts create mode 100644 packages/vscode/e2e/fixtures/rstack-fmt-only/src/index.ts create mode 100644 packages/vscode/e2e/lint/suite-fmt-only-config/fmtOnlyConfig.test.ts create mode 100644 packages/vscode/e2e/lint/suite-fmt-only-config/index.ts diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 53f7eda..1236507 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -17,7 +17,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. Upstream's plugin-host failure toast becomes a status verdict (adaptation 7). Rslint LSP tracing shares the **Rstack: Rslint** Output channel to preserve the four-channel cap; see `stacks/lint/index.ts`. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 6. **Node runtime selection** (lint, test, fmt) — the Node a project-loading child process runs on is a **User Node runtime** chosen by the extension against one uniform floor, never assumed from PATH; the recovery path is the user's own shell, and the dividing line is the **load bound** (terms in GLOSSARY.md; the full rule and rationale in `docs/adr/0001-node-runtime-selection.md`). All three callers — the lint worker, the rstest worker and the `rs fmt --lsp` server — take the decision from the one shared module (`shared/nodeResolution.ts`) and share one escape hatch, the resource-scoped `rstack.nodeExecutable` (`shared/nodeExecutableSetting.ts`); each appends its own consequence to the shared preflight message. -7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — GLOSSARY.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged runtime passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because every supported config protocol locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. Plugin-host startup failures use a separate, unclassified `plugin` verdict on `rstack/rslintConfigDependency`, report `disabled` (a plugin could not be loaded), and recover through the dependency poll. +7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — GLOSSARY.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged runtime passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because every supported config protocol locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. Plugin-host startup failures use a separate, unclassified `plugin` verdict on `rstack/rslintConfigDependency`, report `disabled` (a plugin could not be loaded), and recover through the dependency poll. A bridged runtime whose Rstack config has no `define.lint()` gets a third verdict, `unconfigured` (#93; why it exists: the lint-shim gotcha below). It reports `running` with the detail `no define.lint() in rstack.config.*` and one `info` line per episode — healthy with nothing to lint, like `idle`: not `disabled` (no poll), not `crashed`, no warning. Adding `define.lint()` recovers in the same worker through the bridged `rstack.config.*` watcher, without a restart. 8. **Self-documenting Rslint diagnostics** — client-side providers parse Inline directives into per-rule hover, DocumentLink and underline-decoration affordances (the hover renders `Rslint(rule-id)`, the shape VS Code gives the published diagnostics), and the router enriches today's `[rule-id] message` diagnostics with a derived Rule docs link. No rule metadata or network lookup is bundled (ADR 0004). The hover provider yields whenever the owning language client's resolved capabilities advertise `hoverProvider`; an optional `Rslint.onClosed` hook identity-safely prunes the controller's capability mirror; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively. 9. **Color env parity with the CLI** (test) — upstream hard-codes `FORCE_COLOR: '1'` into the worker's spawn env; ours mirrors the CLI's `getForceColorEnv` (rstest `packages/core/src/utils/logger.ts`) instead (`stacks/test/shared/colorEnv.ts`): the master injects `FORCE_COLOR=1` into the composed spawn env only when neither `FORCE_COLOR` nor `NO_COLOR` is already set (marking the injection with `RSTACK_FORCE_COLOR_INJECTED`), and the worker retracts the marked injection right after config load if the config set `NO_COLOR` — the CLI's own decision point. Otherwise a project whose config sets `process.env.NO_COLOR` (rstack-cli does) hits Node's "'NO_COLOR' env is ignored" warning in every pool process. A user-set `FORCE_COLOR` beside a config-set `NO_COLOR` still warns, exactly as the bare CLI does. @@ -53,6 +53,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. - The lint worker is deliberately vscode-free so it can move upstream whole. It takes explicit `--core` / `--config` native paths, writes logs only to stderr because stdout is LSP, and owns the Go child plus config/plugin lifecycles. Config edits use `rslint/configRefresh` with the same pinned path; a document whose ownership flips native ↔ bridged moves to another runtime, because the supported config protocols lock that choice for the process lifetime; documents whose ownership did not change keep their runtime. +- **Lint is the only tool whose Rstack shim refuses an unconfigured config**, so "the three tools are treated uniformly" is knowingly broken for it. rstack's `rslintConfig.js` exits the process without `define.lint()` (deliberately, for `rs lint`; rstack-cli #490/#493), while `rstestConfig.js` returns `{}` without `define.test()`. Detection cannot tell the cases apart — a static probe for `define.lint` is forbidden (above) and wrong under shared config layers (rstack-cli #546) — so a formatting-only root config still lights lint, and only the shim, inside the worker, decides (the `unconfigured` verdict, adaptation 7). The bridged worker therefore builds its `ConfigModuleHost` with a `loadFresh` wrapped by `withProcessExitAsThrow`, so the exit becomes a tagged failed load result instead of a dead worker, Go, and transport. That is a workaround for rstack 0.8.2 as published: once the shim throws or exports a marker instead of exiting, classify that and drop the wrapper. The shim's own `No lint configuration found…` line (rslog, stderr) and Go's `Skipped config …` lines still reach the Rslint Output channel beside our `info` line. Known gap: after a catalog with `define.lint()` has committed, removing it makes Go reject the refresh and keep linting with the last-good catalog while the status says `no define.lint()`; `rs lint` would refuse. - The test × `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Bridged projects resolve `@rstest/core` from the resolved rstack package directory, mirroring lint, so rstack's dependency remains visible under isolated installs. Never re-implement rstack config semantics in the extension. - Rstest's upstream VS Code extension deep-imports `quoteFilter` from core to mark exact file filters. The published package does not export that helper, so our copy lives in `stacks/test/vendored/coreInternals.ts` beside the other core internals; keep it byte-identical when syncing filter behavior. - The fmt stack is an LSP client: one `rs fmt --lsp` server per detected workspace folder, spawned at the **folder root** even when a deeper `rstack.config.*` exists. Deepest-config-wins was removed deliberately — `rs fmt` loads one config from its cwd with no upward walk, so anchoring deeper made the editor disagree with `rs fmt` in a terminal; a subproject that needs its own fmt config becomes its own workspace folder. The stack registers **no** `DocumentFormattingEditProvider`: the client registers the provider from the server's `documentFormattingProvider` capability, and adding one by hand would double-register. A config create/change/delete **restarts** the owning folder's server (the server caches its config for its process lifetime and has no config-change message), which is also why the stack watches `RSTACK_CONFIG_GLOB` itself instead of relying on detection — a detection signature records which config files exist, not their contents. A detection pass keeps healthy servers and restarts failed ones in place (`isFailedFmtState`) — lockfile events notify even when the folder set is unchanged, precisely so a completed install or upgrade is retried without a manual restart. There is no stdin fallback below `SUPPORT_MATRIX.rstack`; that is a version gate, not an omission. **Nested workspace folders are a documented limitation, by decision**: when a folder and its subdirectory are both workspace folders and both detect fmt, the parent's per-folder selector also matches the nested folder's files, and which server VS Code hands the request to is not defined — the supported shape is subprojects as _sibling_ workspace folders (or only the subproject opened), not parent-plus-child. Routing (lint's `WorkspaceDocumentRouter` shape) was considered and deferred. Why all of it: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`. diff --git a/packages/vscode/e2e/fixtures/rstack-fmt-only/.nvmrc b/packages/vscode/e2e/fixtures/rstack-fmt-only/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-fmt-only/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/packages/vscode/e2e/fixtures/rstack-fmt-only/package.json b/packages/vscode/e2e/fixtures/rstack-fmt-only/package.json new file mode 100644 index 0000000..e5f1685 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-fmt-only/package.json @@ -0,0 +1,13 @@ +{ + "name": "rstack-editor-fixture-rstack-fmt-only", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: a formatting-only `rstack.config.ts` (#93).", + "dependencies": { + "rstack": "0.8.2" + }, + "devDependencies": { + "jiti": "^2.7.0" + } +} diff --git a/packages/vscode/e2e/fixtures/rstack-fmt-only/pnpm-workspace.yaml b/packages/vscode/e2e/fixtures/rstack-fmt-only/pnpm-workspace.yaml new file mode 100644 index 0000000..0330b39 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-fmt-only/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +# E2E fixture install config; rationale in packages/vscode/e2e/setupFixtures.mjs. +minimumReleaseAgeExclude: + - rstack + - '@rslint/*' + - '@rstest/core' + - '@rsbuild/core' + - '@rslib/core' + - '@rstackjs/*' + - rsbuild-plugin-dts + - '@rspack/*' diff --git a/packages/vscode/e2e/fixtures/rstack-fmt-only/rstack.config.ts b/packages/vscode/e2e/fixtures/rstack-fmt-only/rstack.config.ts new file mode 100644 index 0000000..be3d388 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-fmt-only/rstack.config.ts @@ -0,0 +1,8 @@ +// Rstack configuration guide: https://rstack.rs/config +// +// Formatting only: no `define.lint()`. rstack's lint shim exits the process +// when it finds no lint configuration; the editor-shipped lint worker must +// survive that and report a healthy runtime with nothing to lint (#93). +import { define } from 'rstack'; + +define.fmt({ singleQuote: true }); diff --git a/packages/vscode/e2e/fixtures/rstack-fmt-only/src/index.ts b/packages/vscode/e2e/fixtures/rstack-fmt-only/src/index.ts new file mode 100644 index 0000000..552e7f3 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-fmt-only/src/index.ts @@ -0,0 +1,6 @@ +// A lintable issue that must stay silent until `define.lint()` enables +// `no-debugger` in `rstack.config.ts`. +export function trace(value: unknown): unknown { + debugger; + return value; +} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index 53cb416..7a32c9c 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -326,6 +326,13 @@ async function main(): Promise { workspace: sharedFixture('rstack'), tests: suiteDir('suite-bridge'), }, + { + // A root `rstack.config.ts` with only `define.fmt()`: rstack's lint + // shim refuses it, and the runtime must stay healthy (#93). + name: 'Rstack fmt-only config tests', + workspace: sharedFixture('rstack-fmt-only'), + tests: suiteDir('suite-fmt-only-config'), + }, { // A root `rstack.config.ts` and a nested `rslint.config.ts` in one // folder: one bridged and one native runtime side by side (ADR 0006). diff --git a/packages/vscode/e2e/lint/suite-fmt-only-config/fmtOnlyConfig.test.ts b/packages/vscode/e2e/lint/suite-fmt-only-config/fmtOnlyConfig.test.ts new file mode 100644 index 0000000..d38a2ca --- /dev/null +++ b/packages/vscode/e2e/lint/suite-fmt-only-config/fmtOnlyConfig.test.ts @@ -0,0 +1,154 @@ +import * as assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import type { StackState } from '../../../src/types'; +import { + diagnosticRuleIdIncludes, + getRslintDiagnostics, + waitForRslintDiagnostics, +} from '../utils/diagnostics'; +import { extensionExports } from '../utils/extension'; + +// rstack's lint shim calls `process.exit(1)` when the Rstack config has no +// `define.lint()` (#93). The root config still lights the lint stack, so the +// bridged runtime must survive the shim's refusal and report a healthy +// runtime with nothing to lint, then pick up `define.lint()` through its +// config watcher in the same worker. + +function lintExports(): { + getFolderStates(): ReadonlyMap; + getRuntimeStates(): ReadonlyMap; +} { + const exports = extensionExports().getStackExports('rslint'); + assert.ok(exports, 'lint stack exports are unavailable'); + return exports as ReturnType; +} + +function lintStates(): StackState[] { + const exports = lintExports(); + return [ + ...exports.getFolderStates().values(), + ...exports.getRuntimeStates().values(), + ]; +} + +function isUnconfigured(state: StackState): boolean { + return ( + state.kind === 'running' && + state.detail !== undefined && + state.detail.includes('define.lint()') + ); +} + +function hasNoDebugger(diagnostics: readonly vscode.Diagnostic[]): boolean { + return diagnostics.some((diagnostic) => + diagnosticRuleIdIncludes(diagnostic, 'no-debugger'), + ); +} + +/** Polls `condition`, failing as soon as any lint state is `crashed`. */ +async function waitForLintStates( + description: string, + condition: (states: StackState[]) => boolean, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const states = lintStates(); + const crashed = states.find((state) => state.kind === 'crashed'); + assert.equal( + crashed, + undefined, + `Rslint became crashed while waiting for ${description}: ${crashed?.kind === 'crashed' ? crashed.detail : ''}`, + ); + if (states.length > 0 && condition(states)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${description}`); +} + +function workspaceRoot(): string { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) throw new Error('VS Code test workspace is unavailable'); + return folder.uri.fsPath; +} + +function configSource(markerPath: string, lint: boolean): string { + const lintBlock = lint + ? ` +define.lint([ + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]); +` + : ''; + return `import { writeFileSync } from 'node:fs'; +import { define } from 'rstack'; + +// The fmt server and the Rstest worker evaluate this file too; only the lint +// worker records itself. The name is the worker bundle's (\`rslib.config.mts\`). +if (process.argv[1]?.endsWith('lint-worker.js')) { + writeFileSync(${JSON.stringify(markerPath)}, String(process.pid)); +} + +define.fmt({ singleQuote: true }); +${lintBlock}`; +} + +suite('Rstack fmt-only config', function () { + this.timeout(180_000); + + const root = workspaceRoot(); + const configPath = path.join(root, 'rstack.config.ts'); + const markerPath = path.join(root, '.lint-worker-pid'); + const originalConfig = fs.readFileSync(configPath, 'utf8'); + + teardown(() => { + fs.writeFileSync(configPath, originalConfig, 'utf8'); + fs.rmSync(markerPath, { force: true }); + }); + + test('a formatting-only config keeps a healthy runtime and recovers on define.lint()', async () => { + // Still formatting-only; the lint runtime starts on `didOpen`, so the + // first shim evaluation records the lint worker's pid. + fs.writeFileSync(configPath, configSource(markerPath, false), 'utf8'); + const document = await vscode.workspace.openTextDocument( + path.join(root, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + + // Bare `running` appears before the initial config refresh settles; + // only the detail proves the shim's refusal was classified. + await waitForLintStates('the unconfigured running detail', (states) => + states.every(isUnconfigured), + ); + assert.ok(fs.existsSync(markerPath), 'the lint worker never ran the shim'); + const workerPid = fs.readFileSync(markerPath, 'utf8'); + assert.deepStrictEqual(getRslintDiagnostics(document), []); + + fs.writeFileSync(configPath, configSource(markerPath, true), 'utf8'); + const diagnostics = await waitForRslintDiagnostics(document, hasNoDebugger); + assert.ok(hasNoDebugger(diagnostics)); + await waitForLintStates('the plain running state', (states) => + states.every( + (state) => state.kind === 'running' && !isUnconfigured(state), + ), + ); + // Any worker restart in between would have changed the pid. + assert.strictEqual( + fs.readFileSync(markerPath, 'utf8'), + workerPid, + 'define.lint() must be picked up by the same lint worker, without a restart', + ); + // A healthy state: logged at info, so no warning was recorded. + assert.deepStrictEqual( + extensionExports().getRecordedWarnings('rslint'), + [], + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-fmt-only-config/index.ts b/packages/vscode/e2e/lint/suite-fmt-only-config/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-fmt-only-config/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts index b63752d..4962be2 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts @@ -259,6 +259,7 @@ suite('LSP config discovery transactions', () => { typeof LspConfigTransactionAdapter >[4] = { report: () => assert.fail('unexpected missing dependency'), + reportUnconfigured: () => assert.fail('unexpected unconfigured verdict'), reportError: () => assert.fail('unexpected config error'), }; diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index 2adf0e1..781eda1 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -46,13 +46,14 @@ const SLICES = [ { // The ported Rslint suites plus the Rstack lint bridge; the latter uses the // shared `rstack` fixture, and per-document ownership installs its own. `RSTACK_LINT_E2E_SUITES=` filters - // which suites run. + // which suites run. `rstack-fmt-only`: #93. name: 'lint', fixtures: [ 'lint', 'lint-dependency-recovery', 'lint-document-ownership', 'rstack', + 'rstack-fmt-only', ], entry: 'tests-dist/e2e/lint/runTest.js', compile: true, diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index d3cb711..05a6845 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -43,6 +43,7 @@ export const FIXTURES = { rslint: path.join(FIXTURES_DIR, 'rslint'), rstest: path.join(FIXTURES_DIR, 'rstest'), rstack: path.join(FIXTURES_DIR, 'rstack'), + 'rstack-fmt-only': path.join(FIXTURES_DIR, 'rstack-fmt-only'), 'rstest-ownership': path.join(FIXTURES_DIR, 'rstest-ownership'), 'fmt-missing-config-dependency': path.join( FIXTURES_DIR, diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 03b4935..1f14443 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -322,6 +322,8 @@ export class Rslint implements Disposable { private readonly configDependencyEpisode = new NotInstalledEpisode(); private configDependencyRetryPending = false; private configRefreshFailed = false; + /** Holds the status detail while the bridged shim finds no `define.lint()` (#93). */ + private readonly unconfigured = new MessageLatch(); private readonly configError = new MessageLatch(); private startPromise: Promise | undefined; private startOperation: Promise | undefined; @@ -352,12 +354,33 @@ export class Rslint implements Disposable { private reportRunning(): void { if (this.configRefreshFailed || this.hasConfigDependencyFailure()) return; - this.report(runningRslintStatus(this.advisory)); + this.report(runningRslintStatus(this.advisory, this.unconfigured.current)); } private handleConfigDependencyStatus( notification: ConfigDependencyStatusNotification, ): void { + if (notification.kind === 'unconfigured') { + // Healthy, nothing to lint: no poll, no warning, no crash. The bridged + // config watcher re-runs the shim once `define.lint()` appears. + this.configError.clear(); + this.configRefreshFailed = false; + this.configDependencyEpisode.clear(); + const configPath = + this.bridgeConfigPath === undefined + ? 'rstack.config.*' + : displayPath(this.workspaceFolder.uri.fsPath, this.bridgeConfigPath); + const detail = `no define.lint() in ${configPath}`; + if (this.unconfigured.changed(detail)) { + this.logger.info( + `Rslint has nothing to lint: ${detail}. Add define.lint(...) to enable it.`, + ); + } + this.reportRunning(); + return; + } + const wasUnconfigured = this.unconfigured.current !== undefined; + this.unconfigured.clear(); if (notification.kind === 'plugin') { const reason = `ESLint plugins failed to load: ${notification.cause}`; this.configRefreshFailed = true; @@ -382,7 +405,9 @@ export class Rslint implements Disposable { this.configRefreshFailed = false; if (notification.kind === 'ok') { const wasMissing = this.configDependencyEpisode.clear(); - if ((wasMissing || wasFailed) && this.isRunning()) this.reportRunning(); + if ((wasMissing || wasFailed || wasUnconfigured) && this.isRunning()) { + this.reportRunning(); + } return; } const failure = notification.failure; @@ -437,6 +462,7 @@ export class Rslint implements Disposable { this.lifecycleEpoch++; const epoch = this.lifecycleEpoch; this.advisory = undefined; + this.unconfigured.clear(); this.report({ kind: 'starting' }); const folderRoot = this.workspaceFolder.uri.fsPath; @@ -706,9 +732,11 @@ export class Rslint implements Disposable { // error. Keep the live runtime for config edits without duplicate logs // or a generic startup failure replacing its precise status. // Source-change races must still reach the existing startup retry. + // An unconfigured verdict is a healthy state, so Go rejecting that + // refresh (it keeps a last-good catalog) is not reported either. if ( isConfigSourceChangeDuringTransaction(error) || - !this.configRefreshFailed + (!this.configRefreshFailed && this.unconfigured.current === undefined) ) { this.configRefreshFailed = wasFailed; throw error; diff --git a/packages/vscode/src/stacks/lint/status.ts b/packages/vscode/src/stacks/lint/status.ts index cb11602..db847a2 100644 --- a/packages/vscode/src/stacks/lint/status.ts +++ b/packages/vscode/src/stacks/lint/status.ts @@ -57,10 +57,23 @@ export const attributeToCore = ( return { kind: state.kind, detail: `${state.detail} (${coreDirectory})` }; }; -export const runningRslintStatus = (advisory?: string): StackState => - advisory === undefined +/** + * An advisory wins over `detail`, which is the unconfigured bridge's + * `no define.lint()` note: healthy with nothing to lint, so `running` plus a + * detail like `idle`, not `disabled` — that would keep the dependency poll + * re-evaluating an unchanged config (#93). + */ +export const runningRslintStatus = ( + advisory?: string, + detail?: string, +): StackState => { + if (advisory !== undefined) { + return { kind: 'version-mismatch', detail: advisory }; + } + return detail === undefined ? { kind: 'running' } - : { kind: 'version-mismatch', detail: advisory }; + : { kind: 'running', detail }; +}; /** A detected folder with no Lint runtime: `running` plus a detail, never a new kind (AGENTS.md, lint gotcha). */ const RSLINT_IDLE_DETAIL = 'idle'; diff --git a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index 58c0705..a7e9dc3 100644 --- a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -9,12 +9,53 @@ import type { } from '@rslint/core/config-loader'; import { missingDependencyCause } from '../../../shared/missingDependency'; import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; +import { CONFIG_PROCESS_EXIT_CODE } from './configDependencyProtocol'; interface ConfigDependencyObserver { report(failure: ConfigDependencyFailure): void; + reportUnconfigured(): void; reportError(message: string): void; } +/** + * Wraps a bridged worker's fresh config loader. rstack's lint shim, evaluated + * in this process, calls `process.exit(1)` when the Rstack config has no + * `define.lint()` (#93), which would kill the worker, the Go child and the + * editor transport mid-transaction. While a load runs, `exit` throws an error + * tagged `CONFIG_PROCESS_EXIT_CODE` instead, so the evaluation fails like any + * other config error and lands as a `failed` result in `ConfigModuleHost`. + * Depth-counted because a canceled load's module evaluation can outlive Go's + * request and overlap the next refresh. + */ +export function withProcessExitAsThrow( + load: (configPath: string) => Promise, + seam: Pick = process, +): (configPath: string) => Promise { + let depth = 0; + let originalExit: NodeJS.Process['exit'] | undefined; + return async (configPath) => { + if (depth++ === 0) { + originalExit = seam.exit; + seam.exit = (code) => { + throw Object.assign( + new Error( + `config load called process.exit(${code === undefined ? '' : String(code)})`, + ), + { code: CONFIG_PROCESS_EXIT_CODE }, + ); + }; + } + try { + return await load(configPath); + } finally { + if (--depth === 0 && originalExit !== undefined) { + seam.exit = originalExit; + originalExit = undefined; + } + } + }; +} + interface ConfigActivationWireResponse { transactionId: string; eslintPluginEntries: ConfigModuleEslintPluginEntry[]; @@ -123,6 +164,12 @@ export class LspConfigTransactionAdapter { results: response.results.map((result, index) => { if (result.status !== 'failed') return result; const candidate = request.candidates[index]; + // Only `withProcessExitAsThrow` produces this code, and only a + // bridged worker installs it: the shim refused (no `define.lint()`). + if (result.error.code === CONFIG_PROCESS_EXIT_CODE) { + this.configDependencyObserver.reportUnconfigured(); + return result; + } const cause = missingDependencyCause( result.error.code, result.error.message, diff --git a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts index 4ae4399..3f355c6 100644 --- a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts +++ b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts @@ -8,8 +8,16 @@ export type ConfigDependencyStatusNotification = | { readonly kind: 'ok' } | { readonly kind: 'missing'; readonly failure: ConfigDependencyFailure } | { readonly kind: 'plugin'; readonly cause: string } + | { readonly kind: 'unconfigured' } | { readonly kind: 'error'; readonly message: string }; +/** + * `code` of the error `withProcessExitAsThrow` throws in place of exiting the + * worker; `ConfigModuleHost` keeps a string `code` on the failed load result, + * so the verdict survives to the adapter. + */ +export const CONFIG_PROCESS_EXIT_CODE = 'RSTACK_CONFIG_PROCESS_EXIT'; + /** Shared with the editor's startup retry; this module stays vscode-free. */ export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { if (!isRecord(error)) return false; diff --git a/packages/vscode/src/stacks/lint/worker/core.ts b/packages/vscode/src/stacks/lint/worker/core.ts index 80c69b1..e7d3cf7 100644 --- a/packages/vscode/src/stacks/lint/worker/core.ts +++ b/packages/vscode/src/stacks/lint/worker/core.ts @@ -14,10 +14,13 @@ type ConfigModuleHostConstructor = new ( options?: ConfigModuleHostOptions, ) => ConfigModuleHost; +type ConfigModuleLoader = NonNullable; + interface ConfigLoaderModule { readonly ConfigModuleHost: ConfigModuleHostConstructor; readonly CONFIG_DISCOVERY_PROTOCOL_VERSION: number; readonly resolveRslintBinary: () => unknown; + readonly loadConfigFileFresh: ConfigModuleLoader; } interface PluginHostModule { @@ -33,7 +36,9 @@ export interface CoreInstallation { readonly version: string; readonly binaryPath: string; readonly protocolVersion: number; - createConfigModuleHost(): ConfigModuleHost; + /** The host's default fresh loader, for wrapping through `loadFresh`. */ + readonly loadConfigFileFresh: ConfigModuleLoader; + createConfigModuleHost(options?: ConfigModuleHostOptions): ConfigModuleHost; createPluginLintHost: typeof createPluginLintHost; } @@ -46,7 +51,8 @@ function isConfigLoaderModule(value: unknown): value is ConfigLoaderModule { isRecord(value) && typeof value.ConfigModuleHost === 'function' && Number.isInteger(value.CONFIG_DISCOVERY_PROTOCOL_VERSION) && - typeof value.resolveRslintBinary === 'function' + typeof value.resolveRslintBinary === 'function' && + typeof value.loadConfigFileFresh === 'function' ); } @@ -146,7 +152,9 @@ export async function loadCoreInstallation( version: packageJson.version, binaryPath, protocolVersion: configLoaderModule.CONFIG_DISCOVERY_PROTOCOL_VERSION, - createConfigModuleHost: () => new configLoaderModule.ConfigModuleHost(), + loadConfigFileFresh: configLoaderModule.loadConfigFileFresh, + createConfigModuleHost: (hostOptions) => + new configLoaderModule.ConfigModuleHost(hostOptions), createPluginLintHost: async (...args) => { const factory = await getPluginFactory(); return factory(...args); diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index 4cbc988..313fcb7 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -11,6 +11,7 @@ import { } from 'vscode-jsonrpc/node'; import { LspConfigTransactionAdapter, + withProcessExitAsThrow, type ConfigTransactionControlRequest, } from './ConfigTransactionAdapter'; import { PluginLintPool } from './PluginLintPool'; @@ -228,8 +229,16 @@ export async function runLintWorker( ); let configDependencyFailure: ConfigDependencyFailure | undefined; let configError: string | undefined; + let unconfigured = false; const adapter = new LspConfigTransactionAdapter( - installation.createConfigModuleHost(), + installation.createConfigModuleHost( + // `--config` means bridged: only rstack's shim exits on a refusal. + options.configPath === undefined + ? undefined + : { + loadFresh: withProcessExitAsThrow(installation.loadConfigFileFresh), + }, + ), pluginLintPool, (activation) => fingerprinter.compute(activation), installation.protocolVersion, @@ -237,6 +246,9 @@ export async function runLintWorker( report: (failure) => { configDependencyFailure ??= failure; }, + reportUnconfigured: () => { + unconfigured = true; + }, reportError: (message) => { configError ??= message; }, @@ -258,10 +270,15 @@ export async function runLintWorker( takeConfigStatus: () => { const failure = configDependencyFailure; const message = configError; + const refused = unconfigured; configDependencyFailure = undefined; configError = undefined; + unconfigured = false; if (message !== undefined) return { kind: 'error', message }; if (failure !== undefined) return { kind: 'missing', failure }; + // Before `plugin`: a host failure left by an earlier `define.lint()` + // must not mask the healthy unconfigured state. + if (refused) return { kind: 'unconfigured' }; const cause = pluginLintPool.hostFailure; if (cause !== undefined) return { kind: 'plugin', cause }; return { kind: 'ok' }; diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index 1070194..b39e443 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -9,7 +9,13 @@ import type { ResolvedCoreRuntime } from '../../../src/stacks/lint/CoreResolver' import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; let refreshOutcome: - 'missing' | 'broken' | 'fixed' | 'changed' | 'changed-once' = 'missing'; + | 'missing' + | 'broken' + | 'fixed' + | 'changed' + | 'changed-once' + | 'unconfigured' + | 'unconfigured-rejected' = 'missing'; let pendingRefresh: Promise | undefined; let refreshCalls = 0; let reconciles = 0; @@ -108,6 +114,17 @@ rs.mock('vscode-languageclient/node', () => ({ ); return request('rslint/configRefresh', { reason: 'initial' }); } + if ( + refreshOutcome === 'unconfigured' || + refreshOutcome === 'unconfigured-rejected' + ) { + this.notification?.({ kind: 'unconfigured' }); + // Go rejects instead of committing when a last-good catalog exists. + if (refreshOutcome === 'unconfigured-rejected') { + throw new Error('config refresh failed at last-good boundary'); + } + return; + } if (refreshOutcome !== 'missing') { this.notification?.( refreshOutcome === 'broken' @@ -233,17 +250,23 @@ it('updates a surviving bridge runtime attribution before the next config failur expect(warnings[0]).not.toContain('rstack.config.js'); }); -function createRuntime() { +function createRuntime( + installation: Record = { + mode: 'native', + packageDirectory: '/project/core', + }, +) { const states: StackState[] = []; const warnings: string[] = []; const errors: unknown[] = []; + const infos: string[] = []; const runtime = new Rslint({ rootKey: '/project/core', workspaceFolder: { name: 'project', uri: { fsPath: '/project' } }, - installation: { mode: 'native', packageDirectory: '/project/core' }, + installation, router: { createMiddleware: () => ({}) }, logger: { - info() {}, + info: (message: string) => infos.push(message), debug() {}, warn: (message: string) => warnings.push(message), error: (...args: unknown[]) => errors.push(args), @@ -251,7 +274,7 @@ function createRuntime() { reportStatus: (state: StackState) => states.push(state), } as unknown as RslintOptions); - return { runtime, states, warnings, errors }; + return { runtime, states, warnings, errors, infos }; } it('keeps dependency retries single-flight until a hung refresh settles', async () => { @@ -342,3 +365,65 @@ it('reports one startup crash when the config source retry is exhausted', async expect(states.filter((state) => state.kind === 'crashed')).toHaveLength(1); expect(errors).toHaveLength(1); }); + +function refreshConfig(runtime: Rslint, reason: string): Promise { + return ( + runtime as unknown as { + requestConfigRefresh(reason: string): Promise; + } + ).requestConfigRefresh(reason); +} + +it('keeps a bridged runtime running with a detail when the shim finds no define.lint()', async () => { + refreshOutcome = 'unconfigured'; + const { runtime, states, warnings, errors, infos } = createRuntime({ + mode: 'bridged', + packageDirectory: '/project/core', + shimPath: '/project/node_modules/rstack/dist/rslintConfig.js', + }); + runtime.setBridgeConfigPath('/project/rstack.config.ts'); + const unconfigured = { + kind: 'running', + detail: 'no define.lint() in rstack.config.ts', + }; + + await runtime.start(new AbortController().signal); + expect(states.at(-1)).toEqual(unconfigured); + expect(warnings).toEqual([]); + expect(errors).toEqual([]); + const notices = () => + infos.filter((message) => message.includes('nothing to lint')); + expect(notices()).toEqual([ + 'Rslint has nothing to lint: no define.lint() in rstack.config.ts. Add define.lint(...) to enable it.', + ]); + // Healthy: the dependency poll has nothing to retry. + expect(runtime.retryConfigDependency()).toBeUndefined(); + + // An unchanged episode logs once; Go rejecting with a last-good catalog is + // not reported as a failure either. + await refreshConfig(runtime, 'config-change'); + refreshOutcome = 'unconfigured-rejected'; + await refreshConfig(runtime, 'config-change'); + expect(states.at(-1)).toEqual(unconfigured); + expect(notices()).toHaveLength(1); + expect(errors).toEqual([]); + + refreshOutcome = 'fixed'; + await refreshConfig(runtime, 'config-change'); + expect(states.at(-1)).toEqual({ kind: 'running' }); + + refreshOutcome = 'unconfigured'; + await refreshConfig(runtime, 'config-change'); + expect(states.at(-1)).toEqual(unconfigured); + expect(notices()).toHaveLength(2); + expect(states.some((state) => state.kind === 'crashed')).toBe(false); + expect(warnings).toEqual([]); + + // A real config error replaces the detail. + refreshOutcome = 'broken'; + await refreshConfig(runtime, 'config-change'); + expect(states.at(-1)).toEqual({ kind: 'crashed', detail: 'Invalid config' }); + expect(errors).toEqual([ + ['Failed to refresh config discovery: Invalid config'], + ]); +}); diff --git a/packages/vscode/tests/stacks/lint/status.test.ts b/packages/vscode/tests/stacks/lint/status.test.ts index 785543b..da9b19e 100644 --- a/packages/vscode/tests/stacks/lint/status.test.ts +++ b/packages/vscode/tests/stacks/lint/status.test.ts @@ -88,6 +88,12 @@ describe('Rslint status classification', () => { kind: 'version-mismatch', detail: 'Node 22.17 is below the floor', }); + expect( + runningRslintStatus('Node 22.17 is below the floor', 'idle'), + ).toEqual({ + kind: 'version-mismatch', + detail: 'Node 22.17 is below the floor', + }); }); }); diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index 917dde1..0f2f153 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -3,11 +3,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; -import { describe, expect, it } from '@rstest/core'; -import type { - ConfigModuleActivationPlan, - LoadConfigsRequest, - LoadConfigsResponse, +import { describe, expect, it, rs } from '@rstest/core'; +import { + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ConfigModuleHost, + type ConfigModuleActivationPlan, + type LoadConfigsRequest, + type LoadConfigsResponse, } from '@rslint/core/config-loader'; import { createMessageConnection, NullLogger } from 'vscode-jsonrpc/node'; import { @@ -15,9 +17,13 @@ import { parseWorkerArgs, stampConfigRefresh, } from '../../../src/stacks/lint/worker/cli'; -import { LspConfigTransactionAdapter } from '../../../src/stacks/lint/worker/ConfigTransactionAdapter'; +import { + LspConfigTransactionAdapter, + withProcessExitAsThrow, +} from '../../../src/stacks/lint/worker/ConfigTransactionAdapter'; import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + CONFIG_PROCESS_EXIT_CODE, type ConfigDependencyStatusNotification, } from '../../../src/stacks/lint/worker/configDependencyProtocol'; import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; @@ -84,6 +90,12 @@ process.stdin.on('data', (chunk) => { }); `; +const pluginLintPool = { + prepare: async () => true, + commit: async () => true, + abort: async () => undefined, +}; + describe('lint worker CLI', () => { it('accepts only absolute core and optional config paths', () => { const coreDir = path.resolve('/project/node_modules/@rslint/core'); @@ -264,6 +276,9 @@ describe('lint worker config dependency classification', () => { report: (failure: NonNullable) => { missing = failure; }, + reportUnconfigured: () => { + throw new Error('unexpected unconfigured verdict'); + }, reportError: (message: string) => { configError ??= message; }, @@ -296,11 +311,7 @@ describe('lint worker config dependency classification', () => { }, deleteSession: () => true, }, - { - prepare: async () => true, - commit: async () => true, - abort: async () => {}, - }, + pluginLintPool, () => 'fingerprint', 3, observer, @@ -379,11 +390,6 @@ describe('lint worker config dependency classification', () => { }, deleteSession: () => true, }; - const pluginLintPool = { - prepare: async () => true, - commit: async () => true, - abort: async () => undefined, - }; const failures: Array<{ configPath: string; cause: string }> = []; const adapter = new LspConfigTransactionAdapter( host, @@ -392,6 +398,9 @@ describe('lint worker config dependency classification', () => { 3, { report: (failure) => failures.push(failure), + reportUnconfigured: () => { + throw new Error('unexpected unconfigured verdict'); + }, reportError: () => { throw new Error('unexpected config error'); }, @@ -463,15 +472,14 @@ describe('lint worker config dependency classification', () => { }, deleteSession: () => true, }, - { - prepare: async () => true, - commit: async () => true, - abort: async () => undefined, - }, + pluginLintPool, () => 'fingerprint', 3, { report: (failure) => failures.push(failure), + reportUnconfigured: () => { + throw new Error('unexpected unconfigured verdict'); + }, reportError: (message) => expect(message).toBe("Cannot find package './relative.js'"), }, @@ -494,3 +502,88 @@ describe('lint worker config dependency classification', () => { expect(failures).toEqual([]); }); }); + +describe('lint worker bridged shim refusal (#93)', () => { + const shimPath = path.resolve( + '/project/node_modules/rstack/dist/rslintConfig.js', + ); + const loadRequest = (transactionId: string): LoadConfigsRequest => ({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId, + loadMode: 'fresh', + candidates: [ + { + id: 'shim', + configPath: shimPath, + configDirectory: path.dirname(shimPath), + }, + ], + }); + const realExit = (code?: number | string | null): never => { + throw new Error(`the real exit ran with ${String(code)}`); + }; + + it('turns process.exit during a bridged load into an unconfigured verdict', async () => { + const seam = { exit: realExit }; + let unconfigured = 0; + // The real ConfigModuleHost: proves its error payload keeps the tag. + const host = new ConfigModuleHost({ + readSource: async () => new TextEncoder().encode('export default []'), + loadFresh: withProcessExitAsThrow(async () => seam.exit(1), seam), + }); + const adapter = new LspConfigTransactionAdapter( + host, + pluginLintPool, + () => 'fingerprint', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + report: () => { + throw new Error('unexpected missing dependency'); + }, + reportUnconfigured: () => { + unconfigured++; + }, + reportError: () => { + throw new Error('unexpected config error'); + }, + }, + ); + + const response = await adapter.loadConfigs(loadRequest('refused')); + + expect(response.results).toEqual([ + { + id: 'shim', + status: 'failed', + error: { + code: CONFIG_PROCESS_EXIT_CODE, + message: 'config load called process.exit(1)', + }, + }, + ]); + expect(unconfigured).toBe(1); + expect(seam.exit).toBe(realExit); + adapter.dispose(); + }); + + it('keeps the interception until the last overlapping load settles', async () => { + const seam = { exit: realExit }; + const gates = new Map>(); + const load = withProcessExitAsThrow(async (configPath) => { + const gate = Promise.withResolvers(); + gates.set(configPath, gate); + await gate.promise; + }, seam); + + const first = load('/first'); + const second = load('/second'); + await rs.waitUntil(() => gates.size === 2); + expect(seam.exit).not.toBe(realExit); + gates.get('/first')?.resolve(); + await first; + expect(() => seam.exit(1)).toThrow('config load called process.exit(1)'); + gates.get('/second')?.resolve(); + await second; + expect(seam.exit).toBe(realExit); + }); +});