diff --git a/CONTEXT.md b/CONTEXT.md index 9cd4efc..5a49c25 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,10 +25,12 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **Shim** — the module rstack-cli ships per tool that loads the Rstack config and exposes that tool's section through the tool's ordinary explicit-config channel. The extension points upstream machinery at the shim rather than re-implementing Rstack config semantics. - **Bridged project** — a test project the extension synthesizes for a directory whose test signal is a Rstack config, wired to the shim. _Avoid_: virtual project, rstack project. - **Config root** — the directory a tool's config is loaded from, which is also the directory the tool's process stands in. For the fmt server the editor anchors it at the workspace folder root, so it loads the config a terminal opened on that folder would, and a subproject that needs its own config becomes its own workspace folder. The test stack does not share this anchor: a project's cwd is set per project (for native configs, upstream's config-file-directory rule). _Avoid_: config directory, project root. -- **Ownership** — the editor-side rule choosing one config source for a tool's unit of work when both a native config and a Rstack config are present: the atomic tool's native config wins and the bridge yields. The unit is the tool's own — a project for test (one per config directory), a workspace folder for lint (one server per folder, one config choice per server process). This rule exists only in the editor; upstream CLIs never face the choice, since each reads only its own config. +- **Ownership** — the editor-side rule choosing one config source for a tool's unit of work when both a native config and a Rstack config are present: the atomic tool's native config wins and the bridge yields. The unit is the tool's own — a project for test (one per config directory), a workspace folder for lint (one config choice per folder, locked for each lint runtime's lifetime). This rule exists only in the editor; upstream CLIs never face the choice, since each reads only its own config. ## lint +- **Rslint core** — one `@rslint/core` package directory, identified by its real path (two copies of the same version are two cores; a symlink to one copy is that copy). Everything a lint runtime runs — the Go binary, config host, protocol version, plugin host — derives from one Rslint core. _Avoid_: core (bare), installation, binary. +- **Lint runtime** — the lint machinery serving one Rslint core inside one workspace folder: one lint worker, the Go process it spawns, and one language client. Unrelated to the Node runtimes above. _Avoid_: server, instance, coordinator. - **Lint worker** — the process the extension ships and runs for one lint server: it hosts Rslint's JS side (config evaluation, plugin rules) on a User Node runtime with its cwd at the workspace folder root, and fronts the Go `rslint --lsp` process it spawns, so the editor sees one language server. _Avoid_: lint host, lint proxy, lint server (that is what the worker presents, not what it is). - **Bridged folder** — a workspace folder whose lint runs against the Rstack config: no native `rslint.config.*` anywhere in the folder, a `rstack.config.*` at its root, and the lint worker pinned to rstack's shipped shim for its whole lifetime. _Avoid_: bridged workspace, rstack folder. - **Native folder** — a workspace folder whose lint runs against its own `rslint.config.*`, exactly as the standalone Rslint extension would. diff --git a/docs/adr/0003-lint-through-editor-worker.md b/docs/adr/0003-lint-through-editor-worker.md index 968baa0..ebede74 100644 --- a/docs/adr/0003-lint-through-editor-worker.md +++ b/docs/adr/0003-lint-through-editor-worker.md @@ -23,8 +23,8 @@ Rslint's language server is two halves: the Go process (`rslint --lsp`) lints na ## Consequences - **Lint gains a Node floor it never had.** A native folder that lints on VS Code's Node today reports `version mismatch` and starts nothing when no User Node runtime clears `^22.18.0 || >=23.6.0`. Accepted deliberately: one worker, one path, one floor (ADR 0001 already rejected per-project floors), and this is the debt that ADR named. -- **Resolution follows one chain, mirroring `rs lint`.** For a bridged folder: `rstack` from the folder root → `@rslint/core` from rstack's directory (the transitive copy `rs lint` itself imports; a pnpm project declaring only `rstack` cannot resolve `@rslint/core` from its root) → the Go binary through that core's `resolveRslintBinary()`. For a native folder the chain starts at `@rslint/core` from the folder root. The extension walks the chain as far as the **core directory** — `fs.stat`, `package.json` reads and semver comparisons, no project code loaded, so inside the load bound and legitimately on the VS Code Node runtime, exactly as fmt resolves the `rs` bin — and gates there; the worker receives `--core [--config ]` and takes the last hop itself, calling that core's `resolveRslintBinary()` on the User Node runtime, since it is a JS export of the project's package. Floors follow the "latest release only" rule: `@rslint/core >= 0.8.0` (protocol 1 support removed) and `rstack >= 0.6.1` toolchain-wide — `rstack` 0.5.2 still depends on `@rslint/core ~0.7.3`, and one answer to "which rstack does the extension support" is worth more than keeping 0.5.x users' tests running. -- **One override, and it names a core, not a binary.** `rstack.rslint.binPath` / `customBinPath` are removed (with the `rslint.customBinPath` migration mapping) in favour of `rstack.rslint.corePath` — the setting upstream introduced in rslint #1617: a path to an `@rslint/core` package directory, resource-scoped, from which the binary, config host, protocol version and plugin host all derive. In a bridged folder it overrides the rstack → `@rslint/core` hop only; the shim stays rstack's. A binary chosen independently of its core cannot be supported: the two must speak the same protocol. The rest of #1617 — per-document core resolution, one runtime per physical installation — is a separate sync, tracked in issue #13; the worker takes explicit `--core` / `--config` paths precisely so that change does not touch it. +- **Resolution follows one chain, mirroring `rs lint`.** For a bridged folder: `rstack` from the folder root → `@rslint/core` from rstack's directory (the transitive copy `rs lint` itself imports; a pnpm project declaring only `rstack` cannot resolve `@rslint/core` from its root) → the Go binary through that core's `resolveRslintBinary()`. For a native folder the chain starts at `@rslint/core` from the **document's own directory** (the #1617 sync; before it, from the folder root). The extension walks the chain as far as the **core directory** — `fs.stat`, `package.json` reads and semver comparisons, no project code loaded, so inside the load bound and legitimately on the VS Code Node runtime, exactly as fmt resolves the `rs` bin — and gates there; the worker receives `--core [--config ]` and takes the last hop itself, calling that core's `resolveRslintBinary()` on the User Node runtime, since it is a JS export of the project's package. Floors follow the "latest release only" rule: `@rslint/core >= 0.8.0` (protocol 1 support removed) and `rstack >= 0.6.1` toolchain-wide — `rstack` 0.5.2 still depends on `@rslint/core ~0.7.3`, and one answer to "which rstack does the extension support" is worth more than keeping 0.5.x users' tests running. +- **One override, and it names a core, not a binary.** `rstack.rslint.binPath` / `customBinPath` are removed in favour of `rstack.rslint.corePath` — the setting upstream introduced in rslint #1617: a path to an `@rslint/core` package directory, resource-scoped, from which the binary, config host, protocol version and plugin host all derive. In a bridged folder it overrides the rstack → `@rslint/core` hop only; the shim stays rstack's. A binary chosen independently of its core cannot be supported: the two must speak the same protocol. The rest of #1617 — per-document core resolution, one runtime per physical installation — has since been synced (issue #13): a **Lint runtime** is now one Rslint core inside one workspace folder, resolved per open document and refcounted by it, so a folder runs as many workers as its files have distinct cores (a bridged folder always exactly one, rstack's) and none at all while nothing is open. The worker never noticed: it still takes explicit `--core` / `--config` paths, which is precisely why that change did not touch it. - **Ownership is per folder, native wins.** One server holds one config choice for its lifetime (protocol 2 locks `configPath` per process), and explicit and automatic modes cannot mix, so a folder is bridged only when no `rslint.config.*` exists anywhere in it and a `rstack.config.*` sits at its root; a subdirectory `rstack.config.*` lights nothing (`rs lint` in a terminal reads its cwd only — the same reason ADR 0002 rejected deepest-config-wins for fmt). Detection lights a bridged folder on the file's presence and never reads it: a `rstack.config.*` without `define.lint()` runs an empty config, as `rs lint` does. - **Config changes refresh, mode changes restart.** Rslint has a live refresh (`rslint/configRefresh` with the same `configPath`), unlike `rs fmt --lsp`, so the extension keeps its watcher-driven refresh — extended, for a bridged folder, with the root `rstack.config.*` — and the worker re-stamps `protocolVersion` and its `configPath` on every refresh (the extension does not know either). Only a native ↔ bridged flip, or a dependency change the refresh cannot absorb, restarts the server. This is the "diverge only when the tool forces it" rule: rslint can refresh, fmt cannot. - **Failure states mirror fmt.** Bridged folder: no `rstack` → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Native folder missing `@rslint/core` stays `crashed` — the user asked for Rslint by name. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index f37a97a..78ffd95 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -6,6 +6,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase. - The copies diverge from upstream in exactly seven ways (the "adaptations" below). When syncing upstream, preserve them. An eighth divergence is either a bug or must be added to this list. +- **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. ## The seven adaptations @@ -15,7 +16,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 6. **Node runtime selection** (lint, test, fmt) — the Node a project-loading child process runs on is a **User Node runtime** chosen by the extension against one uniform floor, never assumed from PATH; the recovery path is the user's own shell, and the dividing line is the **load bound** (terms in CONTEXT.md; the full rule and rationale in `docs/adr/0001-node-runtime-selection.md`). All three callers — the lint worker, the rstest worker and the `rs fmt --lsp` server — take the decision from the one shared module (`shared/nodeResolution.ts`) and share one escape hatch, the resource-scoped `rstack.nodeExecutable` (`shared/nodeExecutableSetting.ts`); each appends its own consequence to the shared preflight message. -7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per folder runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. A bridged folder passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Why: `docs/adr/0003-lint-through-editor-worker.md`. +7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — CONTEXT.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged folder passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because protocol 2 locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. ## Rules @@ -36,6 +37,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. +- **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "no `rstack`", not the kill switch). Triggers: the shell's detection pass (which already covers lockfiles) plus one lint-owned watcher on `node_modules/@rslint/core/package.json` — upstream's glob minus the lockfiles detection owns. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. - The lint worker is deliberately vscode-free so it can move upstream whole. It takes explicit `--core` / `--config` native paths, writes logs only to stderr because stdout is LSP, and owns the Go child plus config/plugin lifecycles. Config edits use `rslint/configRefresh` with the same pinned path; a native ↔ bridged ownership change replaces the whole folder runtime because protocol 2 locks that choice for the process lifetime. - The test × `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Never re-implement rstack config semantics in the extension. - The fmt stack is an LSP client: one `rs fmt --lsp` server per detected workspace folder, spawned at the **folder root** even when a deeper `rstack.config.*` exists. Deepest-config-wins was removed deliberately — `rs fmt` loads one config from its cwd with no upward walk, so anchoring deeper made the editor disagree with `rs fmt` in a terminal; a subproject that needs its own fmt config becomes its own workspace folder. The stack registers **no** `DocumentFormattingEditProvider`: the client registers the provider from the server's `documentFormattingProvider` capability, and adding one by hand would double-register. A config create/change/delete **restarts** the owning folder's server (the server caches its config for its process lifetime and has no config-change message), which is also why the stack watches `RSTACK_CONFIG_GLOB` itself instead of relying on detection — a detection signature records which config files exist, not their contents. A detection pass keeps healthy servers and restarts failed ones in place (`isFailedFmtState`) — lockfile events notify even when the folder set is unchanged, precisely so a completed install or upgrade is retried without a manual restart. There is no stdin fallback below `SUPPORT_MATRIX.rstack`; that is a version gate, not an omission. **Nested workspace folders are a documented limitation, by decision**: when a folder and its subdirectory are both workspace folders and both detect fmt, the parent's per-folder selector also matches the nested folder's files, and which server VS Code hands the request to is not defined — the supported shape is subprojects as _sibling_ workspace folders (or only the subproject opened), not parent-plus-child. Routing (lint's `WorkspaceDocumentRouter` shape) was considered and deferred. Why all of it: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`. diff --git a/packages/vscode/e2e/lint/runSuite.ts b/packages/vscode/e2e/lint/runSuite.ts index 253b112..3a40cb1 100644 --- a/packages/vscode/e2e/lint/runSuite.ts +++ b/packages/vscode/e2e/lint/runSuite.ts @@ -10,6 +10,13 @@ * for the lint stack to register through the extension's public exports * channel (`whenStackActive('rslint')`) instead. The no-config suite asserts * the opposite state and opts out via `createRun({ expectLintStack: false })`. + * - Since rslint #1617 that signal means "the controller registered its + * detected folders and scheduled the open documents", not "a server is + * running": a **Lint runtime** exists only while a document uses it, so a + * freshly registered stack usually holds zero runtimes (the folder reports + * `running: idle`) and that counts as active. A suite that needs a live + * server opens a document and awaits its diagnostics — which is what the + * ported suites already do. * - `fast-glob` is replaced by a small recursive walk (one dependency less). */ import fs from 'node:fs'; diff --git a/packages/vscode/e2e/lint/suite-jsconfig/core-resolver.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/core-resolver.test.ts new file mode 100644 index 0000000..21cb30c --- /dev/null +++ b/packages/vscode/e2e/lint/suite-jsconfig/core-resolver.test.ts @@ -0,0 +1,353 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-jsconfig/core-resolver.test.ts` +// (rslint #1617, commit 39536fd6). +// +// Adaptations from upstream (see packages/vscode/AGENTS.md, adaptation 7): +// - Upstream's resolver *loads* the selected `@rslint/core` in the extension +// host (`config-loader`, `eslint-plugin`, `resolveRslintBinary()`). Here the +// host must never load project code (ADR 0003): it walks the package chain +// with plain `fs` calls and version-gates the manifest, and the Lint worker +// takes the last hop. So a fixture core is just a `package.json` — there is +// no `createLoadableCore`, no binary, and no `installation.binaryPath`. +// - `resolveCorePackageDirectory(dir, corePath?)` has no exported twin here; +// the equivalent host-side walk is `resolveRslint()` from `resolution.ts`, +// wrapped below as `resolveCoreDirectory`. +// - `CoreNotFoundError` was not ported: our chain reports every failure as +// `RslintResolutionError` with a code (`missing-core` here), which is what +// `status.ts` maps to a folder state. +// - `CoreResolver.resolve` takes the folder's detection decision as a third +// argument (`{mode, corePath}`); upstream has no bridged mode. +// - Upstream caches loaded installations and its tests assert object identity +// for a shared one; this host loads nothing and caches nothing, so "the same +// installation" is the same runtime key and a structurally equal record. +// - Upstream's "rejects a core package whose binary is missing" has no +// host-side analogue (the binary is the worker's business). It is replaced +// by the rejection this host actually performs: the SUPPORT_MATRIX version +// floor. Same shape, same count, the check that exists here. +import * as assert from 'node:assert'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + Uri, + workspace, + type TextDocument, + type WorkspaceFolder, +} from 'vscode'; +import { CoreResolver } from '../../../src/stacks/lint/CoreResolver'; +import { + RslintResolutionError, + resolveRslint, +} from '../../../src/stacks/lint/resolution'; + +// require.resolve can retain a Windows 8.3 alias (RUNNER~1) while fs.realpath +// returns its long spelling. Compare the physical identity used by production. +async function canonicalPath(filePath: string): Promise { + const realPath = path.normalize(await fs.realpath(filePath)); + return process.platform === 'win32' ? realPath.toLowerCase() : realPath; +} + +async function assertSamePhysicalPath( + actual: string, + expected: string, +): Promise { + assert.strictEqual( + await canonicalPath(actual), + await canonicalPath(expected), + ); +} + +suite('local core resolver', () => { + let temporaryDirectory: string; + + setup(async () => { + temporaryDirectory = await fs.mkdtemp( + path.join(os.tmpdir(), 'rslint-core-resolver-'), + ); + }); + + teardown(async function () { + this.timeout(10_000); + // Keep transient Windows EBUSY handling bounded and surface the final + // failure instead of turning cleanup into a best-effort operation. + await fs.rm(temporaryDirectory, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); + }); + + /** + * The host-side half of upstream's `resolveCorePackageDirectory`: the + * `@rslint/core` directory a document's own directory resolves, with the + * `rstack.rslint.corePath` override applied relative to the folder root. + */ + function resolveCoreDirectory( + documentDirectory: string, + corePath?: string, + ): string { + return resolveRslint({ + folderRoot: temporaryDirectory, + mode: 'native', + corePath, + documentDirectory, + }).coreDir; + } + + function temporaryWorkspaceFolder(): WorkspaceFolder { + return { + uri: Uri.file(temporaryDirectory), + name: 'temporary', + index: 0, + }; + } + + async function createSource( + relativePath: string, + ): Promise> { + const source = path.join(temporaryDirectory, relativePath); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, 'const value = 1;\n'); + // Core resolution consumes only uri. A real VS Code TextDocument adds an + // unrelated editor/file-watcher lifecycle and made Windows cleanup racy. + return { uri: Uri.file(source) }; + } + + /** + * Upstream's `createPackageDirectory` — a manifest is all the host reads. + * Versions must clear the SUPPORT_MATRIX floor (`@rslint/core >= 0.8.0`) + * wherever the test expects resolution to succeed. + */ + async function createCore( + packageDirectory: string, + version: string, + ): Promise { + await fs.mkdir(packageDirectory, { recursive: true }); + await fs.writeFile( + path.join(packageDirectory, 'package.json'), + JSON.stringify({ name: '@rslint/core', version }), + ); + return packageDirectory; + } + + async function createInstalledCore( + root: string, + version = '1.0.0', + ): Promise { + return createCore( + path.join(root, 'node_modules', '@rslint', 'core'), + version, + ); + } + + test('selects the nearest node_modules installation', async () => { + const rootCore = await createInstalledCore(temporaryDirectory); + const nestedRoot = path.join(temporaryDirectory, 'packages', 'app'); + const nestedCore = await createInstalledCore(nestedRoot); + const sourceDirectory = path.join(nestedRoot, 'src'); + await fs.mkdir(sourceDirectory, { recursive: true }); + + await assertSamePhysicalPath( + resolveCoreDirectory(sourceDirectory), + nestedCore, + ); + assert.notStrictEqual(nestedCore, rootCore); + }); + + test('uses an explicit core package directory verbatim', async () => { + // Deviation: upstream returns the configured path without touching the + // filesystem (it loads the package afterwards). This host reads the + // manifest to gate the version, so the directory must hold a real + // `@rslint/core` — the assertion is still "exactly this directory". + const configured = path.join(temporaryDirectory, 'vendor', 'rslint-core'); + await createCore(configured, '1.0.0'); + + await assertSamePhysicalPath( + resolveCoreDirectory(temporaryDirectory, configured), + configured, + ); + }); + + test('loads a root installation for nested monorepo documents', async () => { + const packageDirectory = await createInstalledCore( + temporaryDirectory, + '1.2.3', + ); + const first = await createSource('packages/first/src/index.ts'); + const second = await createSource('packages/second/src/index.ts'); + + const resolver = new CoreResolver(); + const [firstResolution, secondResolution] = await Promise.all([ + resolver.resolve(first, temporaryWorkspaceFolder(), { mode: 'native' }), + resolver.resolve(second, temporaryWorkspaceFolder(), { mode: 'native' }), + ]); + + // Shared installation: upstream asserts object identity out of its module + // cache; here (no cache, see the header) it is the runtime key plus a + // structurally equal installation. + assert.strictEqual(firstResolution.key, secondResolution.key); + assert.deepStrictEqual( + firstResolution.installation, + secondResolution.installation, + ); + assert.strictEqual(firstResolution.installation.version, '1.2.3'); + await assertSamePhysicalPath( + firstResolution.installation.packageDirectory, + packageDirectory, + ); + }); + + test('selects independent nested installations for different documents', async () => { + await Promise.all([ + createInstalledCore( + path.join(temporaryDirectory, 'packages', 'first'), + '1.0.0', + ), + createInstalledCore( + path.join(temporaryDirectory, 'packages', 'second'), + '2.0.0', + ), + ]); + const first = await createSource('packages/first/src/index.ts'); + const second = await createSource('packages/second/src/index.ts'); + + const resolver = new CoreResolver(); + const [firstResolution, secondResolution] = await Promise.all([ + resolver.resolve(first, temporaryWorkspaceFolder(), { mode: 'native' }), + resolver.resolve(second, temporaryWorkspaceFolder(), { mode: 'native' }), + ]); + + assert.notStrictEqual(firstResolution.key, secondResolution.key); + assert.strictEqual(firstResolution.installation.version, '1.0.0'); + assert.strictEqual(secondResolution.installation.version, '2.0.0'); + }); + + test('does not merge separate package copies by version text alone', async () => { + await Promise.all([ + createInstalledCore( + path.join(temporaryDirectory, 'packages', 'first'), + '1.0.0', + ), + createInstalledCore( + path.join(temporaryDirectory, 'packages', 'second'), + '1.0.0', + ), + ]); + const first = await createSource('packages/first/src/index.ts'); + const second = await createSource('packages/second/src/index.ts'); + + const resolver = new CoreResolver(); + const [firstResolution, secondResolution] = await Promise.all([ + resolver.resolve(first, temporaryWorkspaceFolder(), { mode: 'native' }), + resolver.resolve(second, temporaryWorkspaceFolder(), { mode: 'native' }), + ]); + + assert.notStrictEqual(firstResolution.key, secondResolution.key); + assert.notStrictEqual( + firstResolution.installation, + secondResolution.installation, + ); + }); + + test('loads an exact relative corePath outside node_modules', async () => { + const packageDirectory = await createCore( + path.join(temporaryDirectory, 'vendor', 'rslint-core'), + '3.0.0', + ); + const document = await createSource('src/index.ts'); + + const resolved = await new CoreResolver().resolve( + document, + temporaryWorkspaceFolder(), + { mode: 'native', corePath: 'vendor/rslint-core' }, + ); + + await assertSamePhysicalPath( + resolved.installation.packageDirectory, + packageDirectory, + ); + assert.strictEqual(resolved.installation.version, '3.0.0'); + }); + + test('rejects a core package below the supported version floor', async () => { + // Upstream's binary existence check, restated on the gate this host owns: + // the resolver refuses an unusable core before any runtime is created, and + // names the directory it came from (a folder can run several cores). + // The status detail names the core directory the resolver walked to. Its + // spelling is the resolver's (on Windows possibly an 8.3 alias), so the + // named path is compared as a physical identity, not as text. + const packageDirectory = await createInstalledCore( + temporaryDirectory, + '0.7.3', + ); + const document = await createSource('src/index.ts'); + + let failure: unknown; + try { + await new CoreResolver().resolve(document, temporaryWorkspaceFolder(), { + mode: 'native', + }); + } catch (error) { + failure = error; + } + assert.ok(failure instanceof Error, 'expected the resolver to reject'); + assert.ok(failure.message.includes('0.7.3'), failure.message); + const named = /\(([^()]+)\)$/.exec(failure.message)?.[1]; + assert.ok(named, `expected a core directory in: ${failure.message}`); + await assertSamePhysicalPath(named, packageDirectory); + }); + + test('does not execute a Yarn PnP resolver as a fallback', async () => { + await fs.writeFile( + path.join(temporaryDirectory, '.pnp.cjs'), + 'throw new Error("PnP resolver must not execute");\n', + ); + + assert.throws( + () => resolveCoreDirectory(temporaryDirectory), + (error: unknown) => + error instanceof RslintResolutionError && error.code === 'missing-core', + ); + }); + + test('reuses symlinks that point at one physical installation', async () => { + const folder = workspace.workspaceFolders?.[0]; + assert.ok(folder, 'test requires a workspace folder'); + const actualCore = path.dirname( + require.resolve('@rslint/core/package.json'), + ); + const documents: Array> = []; + for (const name of ['a', 'b']) { + const project = path.join(temporaryDirectory, name); + const packageScope = path.join(project, 'node_modules', '@rslint'); + await fs.mkdir(packageScope, { recursive: true }); + await fs.symlink( + actualCore, + path.join(packageScope, 'core'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + const source = path.join(project, 'index.ts'); + await fs.writeFile(source, `const ${name} = 1;\n`); + documents.push({ uri: Uri.file(source) }); + } + + const resolver = new CoreResolver(); + const first = await resolver.resolve(documents[0], folder, { + mode: 'native', + }); + const second = await resolver.resolve(documents[1], folder, { + mode: 'native', + }); + assert.strictEqual(first.key, second.key); + // Structural, not reference, equality — see the header on the cache. + assert.deepStrictEqual(first.installation, second.installation); + assert.strictEqual((first.installation.version ?? '').length > 0, true); + // Upstream asserts an absolute `binaryPath`; the binary belongs to the + // worker here, so the host's absolute path is the core directory. + assert.strictEqual( + path.isAbsolute(first.installation.packageDirectory), + true, + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts new file mode 100644 index 0000000..ca071fa --- /dev/null +++ b/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts @@ -0,0 +1,626 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-jsconfig/runtime-manager.test.ts` +// (rslint #1617, commit 39536fd6). It replaces this suite's former +// `workspace-coordinator.test.ts`: the coordinator it exercised no longer +// exists, and the lifecycle it covered is now the RuntimeManager's. +// +// Adaptations from upstream (see packages/vscode/AGENTS.md, adaptation 7): +// - `RuntimeManager`'s fifth constructor parameter is an options object. It +// carries upstream's `documentIsOpen` plus `folderMode`, the detection gate: +// a folder detection never lit up for Rslint has no mode, and its documents +// are never linted. The fake harness reports every folder as `native`, which +// is upstream's only mode. +// - `CoreResolver.resolve` takes that mode (and `rstack.rslint.corePath`) as a +// third argument; the fake resolver ignores it, exactly as upstream's does. +// - `CoreInstallation` carries paths, not module factories (the host never +// loads `@rslint/core` — ADR 0003), so the fake installation has no +// `protocolVersion`/binary and adds `mode`. +// - Assertion semantics are upstream's, unchanged. +import * as assert from 'node:assert'; +import { + Uri, + workspace, + type TextDocument, + type WorkspaceFolder, +} from 'vscode'; +import type { + CoreInstallation, + ResolvedCoreRuntime, +} from '../../../src/stacks/lint/CoreResolver'; +import { + RuntimeManager, + type ManagedRslintRuntime, + type RuntimeCoreResolver, + type RuntimeManagerLogger, +} from '../../../src/stacks/lint/RuntimeManager'; +import { WorkspaceDocumentRouter } from '../../../src/stacks/lint/WorkspaceDocumentRouter'; + +class FakeResolver implements RuntimeCoreResolver { + readonly keys = new Map(); + readonly failures = new Set(); + readonly gates = new Map>(); + readonly started: string[] = []; + clearCalls = 0; + + clear(): void { + this.clearCalls++; + } + + async resolve( + document: TextDocument, + workspaceFolder: WorkspaceFolder, + ): Promise { + const identity = this.keys.get(document.uri.toString()) ?? 'shared-core'; + this.started.push(identity); + await this.gates.get(identity)?.promise; + if (this.failures.has(identity)) { + throw new Error(`resolution failed for ${identity}`); + } + // The fake runtime never spawns a worker, so no real core directory is + // needed — only a stable physical identity. + const installation: CoreInstallation = { + identity, + packageDirectory: `/core/${identity}`, + version: identity, + mode: 'native', + }; + return { + key: `${workspaceFolder.uri.toString()}\0${identity}`, + workspaceFolder, + installation, + }; + } +} + +class FakeRuntime implements ManagedRslintRuntime { + readonly opened: string[] = []; + readonly closedDocuments: string[] = []; + startCalls = 0; + closeCalls = 0; + + constructor( + readonly rootKey: string, + readonly workspaceFolder: WorkspaceFolder, + readonly identity: string, + private readonly behavior: RuntimeBehavior, + ) {} + + async start(signal: AbortSignal): Promise { + this.startCalls++; + if (this.behavior.failStart) { + throw new Error(`start failed for ${this.rootKey}`); + } + if (this.behavior.pendingStart) { + await new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }); + } + } + + async close(): Promise { + this.closeCalls++; + if (this.behavior.failClose) { + throw new Error(`close failed for ${this.rootKey}`); + } + } + + async sendDocumentOpen(document: TextDocument): Promise { + if (this.behavior.failOpen) { + throw new Error(`didOpen failed for ${this.rootKey}`); + } + this.opened.push(document.uri.toString()); + } + + async sendDocumentClose(document: TextDocument): Promise { + this.closedDocuments.push(document.uri.toString()); + } + + clearDocumentDiagnostics(): void {} +} + +const silentLogger: RuntimeManagerLogger = { + debug() {}, + info() {}, + error() {}, +}; + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +interface RuntimeBehavior { + readonly failFactory?: boolean; + readonly failStart?: boolean; + readonly pendingStart?: boolean; + readonly failOpen?: boolean; + readonly failClose?: boolean; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function eventually( + predicate: () => boolean, + message: string, +): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(message); +} + +async function reconcileAll( + manager: RuntimeManager, + documents: readonly TextDocument[], +): Promise { + await Promise.all(documents.map((document) => manager.reconcile(document))); +} + +// runSuite activates the real extension before Mocha. Detached documents keep +// this fake manager's lifecycle from starting and stopping actual LSP clients. +function detachedDocument(uri: Uri, languageId = 'typescript'): TextDocument { + return { uri, languageId } as TextDocument; +} + +suite('local-core runtime manager', () => { + let documentRoot: Uri; + let first: TextDocument; + let second: TextDocument; + let openDocuments: Set; + let generation = 0; + + setup(() => { + const folder = workspace.workspaceFolders?.[0]; + assert.ok(folder, 'test requires a workspace folder'); + // Deviation: upstream also parks the folder in a suite-level + // `workspaceFolder` binding it never reads. This repo's + // `@typescript-eslint/no-unused-vars` rejects that, so only the local + // `folder` survives; the fake resolver receives the folder the manager + // passes it, exactly as upstream. + documentRoot = Uri.joinPath( + folder.uri, + `.runtime-manager-${String(generation++)}`, + ); + first = detachedDocument(Uri.joinPath(documentRoot, 'first.ts')); + second = detachedDocument(Uri.joinPath(documentRoot, 'second.ts')); + openDocuments = new Set([first, second]); + }); + + function harness( + behaviorFor: (identity: string) => RuntimeBehavior = () => ({}), + ) { + const resolver = new FakeResolver(); + const router = new WorkspaceDocumentRouter(); + const runtimes: FakeRuntime[] = []; + const manager = new RuntimeManager( + router, + resolver, + (resolved) => { + const identity = resolved.installation.identity; + const behavior = behaviorFor(identity); + if (behavior.failFactory) { + throw new Error(`factory failed for ${identity}`); + } + const runtime = new FakeRuntime( + resolved.key, + resolved.workspaceFolder, + identity, + behavior, + ); + runtimes.push(runtime); + return runtime; + }, + silentLogger, + { + // Detection is the gate; every fixture folder here is a native + // Rslint folder, which is upstream's only mode. + folderMode: () => 'native', + documentIsOpen: (document) => openDocuments.has(document), + }, + ); + return { manager, resolver, router, runtimes }; + } + + function closeDocument( + manager: RuntimeManager, + document: TextDocument, + ): void { + openDocuments.delete(document); + manager.documentClosed(document); + } + + test('reuses one physical core installation for multiple documents', async () => { + const { manager, router, runtimes } = harness(); + await reconcileAll(manager, [first, second]); + + assert.strictEqual(runtimes.length, 1); + assert.strictEqual(router.getServerOpenOwner(first), runtimes[0].rootKey); + assert.strictEqual(router.getServerOpenOwner(second), runtimes[0].rootKey); + assert.deepStrictEqual( + new Set(runtimes[0].opened), + new Set([first.uri.toString(), second.uri.toString()]), + ); + await manager.close(); + assert.strictEqual(runtimes[0].closeCalls, 1); + }); + + test('does not resolve or retain runtimes for unsupported documents', async () => { + const notesUri = Uri.joinPath(documentRoot, 'notes.md'); + const notes = detachedDocument(notesUri, 'markdown'); + openDocuments.add(notes); + const { manager, resolver, runtimes } = harness(); + + await manager.reconcile(notes); + + assert.deepStrictEqual(resolver.started, []); + assert.deepStrictEqual(runtimes, []); + await manager.close(); + }); + + test('keeps distinct core installations isolated', async () => { + const { manager, resolver, router, runtimes } = harness(); + resolver.keys.set(first.uri.toString(), 'core-a'); + resolver.keys.set(second.uri.toString(), 'core-b'); + await reconcileAll(manager, [first, second]); + + assert.strictEqual(runtimes.length, 2); + assert.notStrictEqual( + router.getServerOpenOwner(first), + router.getServerOpenOwner(second), + ); + await manager.close(); + }); + + test('keeps the last-good owner when a replacement fails to start', async () => { + const { manager, resolver, router, runtimes } = harness((identity) => ({ + failStart: identity === 'broken-core', + })); + resolver.keys.set(first.uri.toString(), 'working-core'); + await reconcileAll(manager, [first]); + const working = runtimes[0]; + + resolver.keys.set(first.uri.toString(), 'broken-core'); + await manager.reconcile(first); + + assert.strictEqual(router.getServerOpenOwner(first), working.rootKey); + assert.strictEqual(working.closeCalls, 0); + assert.strictEqual(runtimes.length, 2); + assert.strictEqual(runtimes[1].closeCalls, 1); + await manager.close(); + }); + + test('isolates an initial start failure from a document using another core', async () => { + const { manager, resolver, router, runtimes } = harness((identity) => ({ + failStart: identity === 'broken-core', + })); + resolver.keys.set(first.uri.toString(), 'broken-core'); + resolver.keys.set(second.uri.toString(), 'healthy-core'); + + await reconcileAll(manager, [first, second]); + + const broken = runtimes.find( + (runtime) => runtime.identity === 'broken-core', + ); + const healthy = runtimes.find( + (runtime) => runtime.identity === 'healthy-core', + ); + assert.ok(broken); + assert.ok(healthy); + assert.strictEqual(router.getServerOpenOwner(first), undefined); + assert.strictEqual(router.getServerOpenOwner(second), healthy.rootKey); + assert.strictEqual(broken.closeCalls, 1); + assert.strictEqual(healthy.closeCalls, 0); + await manager.close(); + assert.strictEqual(healthy.closeCalls, 1); + }); + + test('keeps the last-good owner when replacement resolution fails', async () => { + const { manager, resolver, router, runtimes } = harness(); + resolver.keys.set(first.uri.toString(), 'working-core'); + await reconcileAll(manager, [first]); + + resolver.keys.set(first.uri.toString(), 'missing-core'); + resolver.failures.add('missing-core'); + await manager.reconcile(first); + + assert.strictEqual(router.getServerOpenOwner(first), runtimes[0].rootKey); + assert.strictEqual(runtimes[0].closeCalls, 0); + await manager.close(); + }); + + test('restores the last-good owner when replacement didOpen fails', async () => { + const { manager, resolver, router, runtimes } = harness((identity) => ({ + failOpen: identity === 'broken-core', + })); + resolver.keys.set(first.uri.toString(), 'working-core'); + await reconcileAll(manager, [first]); + + resolver.keys.set(first.uri.toString(), 'broken-core'); + await manager.reconcile(first); + + assert.strictEqual(router.getServerOpenOwner(first), runtimes[0].rootKey); + assert.strictEqual(runtimes[0].closeCalls, 0); + assert.strictEqual(runtimes[1].closeCalls, 1); + await manager.close(); + }); + + test('switches one shared document without restarting the remaining owner', async () => { + const { manager, resolver, router, runtimes } = harness(); + await reconcileAll(manager, [first, second]); + const shared = runtimes[0]; + + resolver.keys.set(first.uri.toString(), 'nested-core'); + await manager.reconcile(first); + + assert.strictEqual(shared.closeCalls, 0); + assert.strictEqual(router.getServerOpenOwner(second), shared.rootKey); + assert.strictEqual(router.getServerOpenOwner(first), runtimes[1].rootKey); + + resolver.keys.set(second.uri.toString(), 'nested-core'); + await manager.reconcile(second); + await eventually( + () => shared.closeCalls === 1, + 'the unreferenced shared runtime should close', + ); + assert.strictEqual(runtimes.length, 2); + assert.strictEqual( + router.getServerOpenOwner(first), + router.getServerOpenOwner(second), + ); + await manager.close(); + }); + + test('releases a runtime only after its last document closes', async () => { + const { manager, router, runtimes } = harness(); + await reconcileAll(manager, [first, second]); + + closeDocument(manager, first); + await eventually( + () => router.getServerOpenOwner(first) === undefined, + 'the first document should detach', + ); + assert.strictEqual(runtimes[0].closeCalls, 0); + + closeDocument(manager, second); + await eventually( + () => runtimes[0].closeCalls === 1, + 'the last document should release the runtime', + ); + await manager.close(); + }); + + test('discards a stale resolution before it starts a runtime', async () => { + const { manager, resolver, router, runtimes } = harness(); + resolver.keys.set(first.uri.toString(), 'slow-core'); + const gate = deferred(); + resolver.gates.set('slow-core', gate); + + const slowReconcile = manager.reconcile(first); + await eventually( + () => resolver.started.includes('slow-core'), + 'the slow resolution should begin', + ); + resolver.keys.set(first.uri.toString(), 'current-core'); + const currentReconcile = manager.reconcile(first); + gate.resolve(); + await Promise.all([slowReconcile, currentReconcile]); + + assert.deepStrictEqual( + runtimes.map((runtime) => runtime.identity), + ['current-core'], + ); + assert.strictEqual(router.getServerOpenOwner(first), runtimes[0].rootKey); + await manager.close(); + }); + + test('discards a resolution after the document leaves the workspace snapshot', async () => { + const { manager, resolver, router, runtimes } = harness(); + const gate = deferred(); + resolver.gates.set('shared-core', gate); + + const reconciling = manager.reconcile(first); + await eventually( + () => resolver.started.includes('shared-core'), + 'core resolution should begin', + ); + openDocuments.delete(first); + gate.resolve(); + await reconciling; + + assert.deepStrictEqual(runtimes, []); + assert.strictEqual(router.getServerOpenOwner(first), undefined); + await manager.close(); + }); + + test('does not undo a committed switch when old-runtime shutdown fails', async () => { + const { manager, resolver, router, runtimes } = harness((identity) => ({ + failClose: identity === 'old-core', + })); + resolver.keys.set(first.uri.toString(), 'old-core'); + await reconcileAll(manager, [first]); + + resolver.keys.set(first.uri.toString(), 'new-core'); + await manager.reconcile(first); + await eventually( + () => runtimes[0].closeCalls === 1, + 'the old runtime should attempt shutdown', + ); + + assert.strictEqual(router.getServerOpenOwner(first), runtimes[1].rootKey); + assert.strictEqual(runtimes[1].closeCalls, 0); + await assert.rejects(manager.close(), /failed to close runtime manager/); + assert.strictEqual(runtimes[1].closeCalls, 1); + }); + + test('does not start a same-key replacement after shutdown fails', async () => { + const { manager, resolver, router, runtimes } = harness((identity) => ({ + failClose: identity === 'quarantined-core', + })); + resolver.keys.set(first.uri.toString(), 'quarantined-core'); + await manager.reconcile(first); + + resolver.keys.set(first.uri.toString(), 'healthy-core'); + await manager.reconcile(first); + await eventually( + () => runtimes[0].closeCalls === 1, + 'the superseded runtime should attempt shutdown', + ); + + resolver.keys.set(first.uri.toString(), 'quarantined-core'); + await manager.reconcile(first); + + assert.strictEqual(runtimes.length, 3); + assert.strictEqual(runtimes[2].identity, 'quarantined-core'); + assert.strictEqual( + runtimes[2].startCalls, + 0, + 'a failed shutdown must remain a barrier for the same runtime key', + ); + assert.strictEqual(router.getServerOpenOwner(first), runtimes[1].rootKey); + await assert.rejects(manager.close(), /failed to close runtime manager/); + assert.strictEqual(runtimes[1].closeCalls, 1); + }); + + test('isolates a factory failure from a document using another core', async () => { + const { manager, resolver, router, runtimes } = harness((identity) => ({ + failFactory: identity === 'broken-core', + })); + resolver.keys.set(first.uri.toString(), 'broken-core'); + resolver.keys.set(second.uri.toString(), 'healthy-core'); + + await reconcileAll(manager, [first, second]); + + assert.deepStrictEqual( + runtimes.map((runtime) => runtime.identity), + ['healthy-core'], + ); + assert.strictEqual(router.getServerOpenOwner(first), undefined); + assert.strictEqual(router.getServerOpenOwner(second), runtimes[0].rootKey); + await manager.close(); + }); + + test('recovers after an initially missing core becomes available', async () => { + const { manager, resolver, router, runtimes } = harness(); + resolver.keys.set(first.uri.toString(), 'installed-later'); + resolver.failures.add('installed-later'); + + await manager.reconcile(first); + assert.strictEqual(router.getServerOpenOwner(first), undefined); + assert.strictEqual(runtimes.length, 0); + + resolver.failures.delete('installed-later'); + manager.clearResolutionCache(); + await manager.reconcile(first); + + assert.strictEqual(resolver.clearCalls, 1); + assert.strictEqual(runtimes.length, 1); + assert.strictEqual(router.getServerOpenOwner(first), runtimes[0].rootKey); + await manager.close(); + }); + + test('lets a healthy core become ready while another initial core is pending', async () => { + const { manager, resolver, router, runtimes } = harness((identity) => ({ + pendingStart: identity === 'pending-core', + })); + resolver.keys.set(first.uri.toString(), 'pending-core'); + resolver.keys.set(second.uri.toString(), 'healthy-core'); + + manager.initialize([first, second]); + await eventually( + () => router.getServerOpenOwner(second) !== undefined, + 'the healthy runtime should not wait for the pending runtime', + ); + + assert.strictEqual(router.getServerOpenOwner(first), undefined); + assert.deepStrictEqual( + new Set(runtimes.map((runtime) => runtime.identity)), + new Set(['pending-core', 'healthy-core']), + ); + await manager.close(); + }); + + test('aborts a pending start during terminal shutdown', async () => { + const { manager, resolver, runtimes } = harness((identity) => ({ + pendingStart: identity === 'pending-core', + })); + resolver.keys.set(first.uri.toString(), 'pending-core'); + + const reconciling = manager.reconcile(first); + await eventually( + () => runtimes.length === 1, + 'the pending runtime should be created', + ); + await manager.close(); + await reconciling; + + assert.strictEqual(runtimes[0].closeCalls, 1); + }); + + test('aborts a pending start when its only document closes', async () => { + const { manager, resolver, runtimes } = harness((identity) => ({ + pendingStart: identity === 'pending-core', + })); + resolver.keys.set(first.uri.toString(), 'pending-core'); + + const reconciling = manager.reconcile(first); + await eventually( + () => runtimes.length === 1, + 'the pending runtime should be created', + ); + closeDocument(manager, first); + await reconciling; + await eventually( + () => runtimes[0].closeCalls === 1, + 'closing the document should cancel its pending runtime', + ); + + await manager.close(); + }); + + test('clears resolver state without replacing an unchanged runtime', async () => { + const { manager, resolver, runtimes } = harness(); + await reconcileAll(manager, [first]); + manager.clearResolutionCache(); + await manager.reconcile(first); + + assert.strictEqual(resolver.clearCalls, 1); + assert.strictEqual(runtimes.length, 1); + await manager.close(); + }); + + test('closes every active runtime when one terminal close fails', async () => { + const { manager, resolver, runtimes } = harness((identity) => ({ + failClose: identity === 'broken-core', + })); + resolver.keys.set(first.uri.toString(), 'broken-core'); + resolver.keys.set(second.uri.toString(), 'healthy-core'); + await reconcileAll(manager, [first, second]); + + await assert.rejects(manager.close(), /failed to close runtime manager/); + + assert.strictEqual(runtimes.length, 2); + assert.deepStrictEqual( + new Map( + runtimes.map((runtime) => [runtime.identity, runtime.closeCalls]), + ), + new Map([ + ['broken-core', 1], + ['healthy-core', 1], + ]), + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-jsconfig/workspace-coordinator.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/workspace-coordinator.test.ts deleted file mode 100644 index 1219738..0000000 --- a/packages/vscode/e2e/lint/suite-jsconfig/workspace-coordinator.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -// Ported from web-infra-dev/rslint -// `packages/vscode-extension/__tests__/suite-jsconfig/workspace-coordinator.test.ts` -// (origin/main). Only the import path changed: the copied extension sources -// live under `src/stacks/lint/` in this repo. -import * as assert from 'node:assert'; -import { - Uri, - type TextDocument, - type WorkspaceFolder, - type WorkspaceFoldersChangeEvent, -} from 'vscode'; -import { - WorkspaceRslintCoordinator, - workspaceRootKey, - type WorkspaceCoordinatorLogger, - type WorkspaceRootRouter, - type WorkspaceRuntime, -} from '../../../src/stacks/lint/WorkspaceRslintCoordinator'; - -type StartMode = 'ready' | 'fail' | 'pending' | 'factory-fail'; - -class FakeRuntime implements WorkspaceRuntime { - readonly opened: string[] = []; - readonly closedDocuments: string[] = []; - closeCalls = 0; - failClose = false; - - constructor( - readonly workspaceFolder: WorkspaceFolder, - readonly rootKey: string, - private readonly startMode: StartMode, - ) {} - - async start(signal: AbortSignal): Promise { - if (this.startMode === 'ready') return; - if (this.startMode === 'fail') throw new Error(`failed ${this.rootKey}`); - await new Promise((resolve, reject) => { - const onAbort = () => reject(signal.reason); - signal.addEventListener('abort', onAbort, { once: true }); - void resolve; - }); - } - - async close(): Promise { - this.closeCalls++; - if (this.failClose) throw new Error(`close failed ${this.rootKey}`); - } - - async sendDocumentOpen(document: TextDocument): Promise { - this.opened.push(document.uri.toString()); - } - - async sendDocumentClose(document: TextDocument): Promise { - this.closedDocuments.push(document.uri.toString()); - } - - clearDocumentDiagnostics(): void {} -} - -class FakeRouter implements WorkspaceRootRouter { - readonly active = new Map(); - - async activate(runtime: WorkspaceRuntime): Promise { - this.active.set(runtime.rootKey, runtime); - } - - async deactivate(rootKey: string): Promise { - this.active.delete(rootKey); - } - - async closeAll(): Promise { - this.active.clear(); - } -} - -const silentLogger: WorkspaceCoordinatorLogger = { - debug() {}, - info() {}, - warn() {}, - error() {}, -}; - -function folder(fsPath: string, name = 'app', index = 0): WorkspaceFolder { - return { uri: Uri.file(fsPath), name, index }; -} - -function changeEvent( - added: readonly WorkspaceFolder[], - removed: readonly WorkspaceFolder[], -): WorkspaceFoldersChangeEvent { - return { added, removed }; -} - -async function eventually( - predicate: () => boolean, - message: string, -): Promise { - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 5)); - } - assert.fail(message); -} - -function coordinatorHarness( - modeFor: (rootKey: string) => StartMode = () => 'ready', -) { - const router = new FakeRouter(); - const runtimes: FakeRuntime[] = []; - const coordinator = new WorkspaceRslintCoordinator( - router, - (workspaceFolder, rootKey) => { - const mode = modeFor(rootKey); - if (mode === 'factory-fail') { - throw new Error(`factory failed ${rootKey}`); - } - const runtime = new FakeRuntime(workspaceFolder, rootKey, mode); - runtimes.push(runtime); - return runtime; - }, - silentLogger, - ); - return { coordinator, router, runtimes }; -} - -suite('workspace runtime coordinator', () => { - test('uses URI identity for same-name roots', async () => { - const first = folder('/workspace/first/app', 'app', 0); - const second = folder('/workspace/second/app', 'app', 1); - const { coordinator, router, runtimes } = coordinatorHarness(); - - await coordinator.initialize([first, second]); - await eventually( - () => router.active.size === 2, - 'both same-name roots should become active', - ); - - assert.notStrictEqual(workspaceRootKey(first), workspaceRootKey(second)); - assert.strictEqual(runtimes.length, 2); - await coordinator.close(); - }); - - test('isolates initial root failures', async () => { - const broken = folder('/workspace/broken', 'broken', 0); - const healthy = folder('/workspace/healthy', 'healthy', 1); - const { coordinator, router } = coordinatorHarness((key) => - key === workspaceRootKey(broken) ? 'fail' : 'ready', - ); - - await coordinator.initialize([broken, healthy]); - await eventually( - () => router.active.has(workspaceRootKey(healthy)), - 'healthy root should remain active', - ); - assert.strictEqual(router.active.has(workspaceRootKey(broken)), false); - await coordinator.close(); - }); - - test('isolates runtime factory failures', async () => { - const broken = folder('/workspace/broken', 'broken', 0); - const healthy = folder('/workspace/healthy', 'healthy', 1); - const { coordinator, router } = coordinatorHarness((key) => - key === workspaceRootKey(broken) ? 'factory-fail' : 'ready', - ); - - await coordinator.initialize([broken, healthy]); - await eventually( - () => router.active.has(workspaceRootKey(healthy)), - 'healthy root should survive a sibling factory failure', - ); - await coordinator.close(); - }); - - test('does not let a pending root block another root or removal', async () => { - const pending = folder('/workspace/pending', 'pending', 0); - const healthy = folder('/workspace/healthy', 'healthy', 1); - const { coordinator, router, runtimes } = coordinatorHarness((key) => - key === workspaceRootKey(pending) ? 'pending' : 'ready', - ); - - await coordinator.initialize([pending, healthy]); - coordinator.handleWorkspaceFoldersChanged(changeEvent([], [pending]), [ - healthy, - ]); - await eventually( - () => - runtimes.find( - (runtime) => runtime.rootKey === workspaceRootKey(pending), - )?.closeCalls === 1, - 'removed pending root should close', - ); - assert.strictEqual(router.active.has(workspaceRootKey(healthy)), true); - await coordinator.close(); - }); - - test('follows a topology replacement while activation is still pending', async () => { - const pending = folder('/workspace/pending', 'pending', 0); - const replacement = folder('/workspace/replacement', 'replacement', 0); - const { coordinator, router } = coordinatorHarness((key) => - key === workspaceRootKey(pending) ? 'pending' : 'ready', - ); - - const initializing = coordinator.initialize([pending]); - coordinator.handleWorkspaceFoldersChanged( - changeEvent([replacement], [pending]), - [replacement], - ); - await initializing; - - assert.strictEqual(router.active.has(workspaceRootKey(pending)), false); - assert.strictEqual(router.active.has(workspaceRootKey(replacement)), true); - await coordinator.close(); - }); - - test('lets an added healthy root unblock a pending initial root', async () => { - const pending = folder('/workspace/pending', 'pending', 0); - const healthy = folder('/workspace/healthy', 'healthy', 1); - const { coordinator, router } = coordinatorHarness((key) => - key === workspaceRootKey(pending) ? 'pending' : 'ready', - ); - - const initializing = coordinator.initialize([pending]); - coordinator.handleWorkspaceFoldersChanged(changeEvent([healthy], []), [ - pending, - healthy, - ]); - await initializing; - - assert.strictEqual(router.active.has(workspaceRootKey(healthy)), true); - await coordinator.close(); - }); - - test('replaces a renamed root even when its URI is unchanged', async () => { - const original = folder('/workspace/app', 'old-name', 0); - const renamed = folder('/workspace/app', 'new-name', 0); - const { coordinator, router, runtimes } = coordinatorHarness(); - await coordinator.initialize([original]); - - coordinator.handleWorkspaceFoldersChanged( - changeEvent([renamed], [original]), - [renamed], - ); - await eventually( - () => runtimes.length === 2 && runtimes[0].closeCalls === 1, - 'rename should replace and close the old runtime', - ); - assert.strictEqual( - router.active.get(workspaceRootKey(renamed))?.workspaceFolder.name, - 'new-name', - ); - await coordinator.close(); - }); - - test('quarantines a close-failed runtime instead of overlapping its replacement', async () => { - const original = folder('/workspace/app', 'old-name', 0); - const renamed = folder('/workspace/app', 'new-name', 0); - const { coordinator, router, runtimes } = coordinatorHarness(); - await coordinator.initialize([original]); - runtimes[0].failClose = true; - - coordinator.handleWorkspaceFoldersChanged( - changeEvent([renamed], [original]), - [renamed], - ); - await eventually( - () => runtimes[0].closeCalls === 1, - 'replacement should attempt to close the old runtime', - ); - - assert.strictEqual(runtimes.length, 1, 'replacement must not overlap'); - assert.strictEqual(router.active.has(workspaceRootKey(original)), false); - await assert.rejects( - coordinator.close(), - /failed to close workspace coordinator/, - ); - assert.strictEqual( - runtimes[0].closeCalls, - 2, - 'terminal close should retry', - ); - }); - - test('does not restart a root when only its positional index changes', async () => { - const original = folder('/workspace/app', 'app', 0); - const shifted = folder('/workspace/app', 'app', 1); - const inserted = folder('/workspace/inserted', 'inserted', 0); - const { coordinator, router, runtimes } = coordinatorHarness(); - await coordinator.initialize([original]); - - coordinator.handleWorkspaceFoldersChanged(changeEvent([inserted], []), [ - inserted, - shifted, - ]); - await eventually( - () => router.active.size === 2, - 'new root should become active', - ); - assert.strictEqual( - runtimes.filter( - (runtime) => runtime.rootKey === workspaceRootKey(original), - ).length, - 1, - ); - await coordinator.close(); - }); - - test('rejects activation when every root fails', async () => { - const first = folder('/workspace/first', 'first', 0); - const second = folder('/workspace/second', 'second', 1); - const { coordinator } = coordinatorHarness(() => 'fail'); - - await assert.rejects( - coordinator.initialize([first, second]), - /All Rslint workspace roots failed/, - ); - await coordinator.close(); - }); - - test('closes every root and reports terminal close failures', async () => { - const first = folder('/workspace/first', 'first', 0); - const second = folder('/workspace/second', 'second', 1); - const { coordinator, router, runtimes } = coordinatorHarness(); - await coordinator.initialize([first, second]); - await eventually( - () => router.active.size === 2, - 'both roots should become active', - ); - runtimes[0].failClose = true; - - await assert.rejects( - coordinator.close(), - /failed to close workspace coordinator/, - ); - assert.deepStrictEqual( - runtimes.map((runtime) => runtime.closeCalls), - [1, 1], - ); - }); -}); diff --git a/packages/vscode/e2e/lint/suite-jsconfig/workspace-router.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/workspace-router.test.ts index 2de2e05..812f248 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/workspace-router.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/workspace-router.test.ts @@ -1,7 +1,7 @@ // Ported from web-infra-dev/rslint // `packages/vscode-extension/__tests__/suite-jsconfig/workspace-router.test.ts` -// (origin/main). Only the import path changed: the copied extension sources -// live under `src/stacks/lint/` in this repo. +// (origin/main, re-synced for rslint #1617). Only the import path changed: the +// copied extension sources live under `src/stacks/lint/` in this repo. import * as assert from 'node:assert'; import { commands, @@ -80,7 +80,7 @@ suite('workspace document router', () => { await workspace.fs.delete(testDirectory, { recursive: true }); }); - test('hands an open document to the longest active root', async () => { + test('hands an open document to its explicitly selected runtime', async () => { const router = new WorkspaceDocumentRouter(); const parent = new FakeRoutingRuntime( parentFolder.uri.toString(), @@ -91,11 +91,13 @@ suite('workspace document router', () => { childFolder, ); + await router.assign(document, parent.rootKey); await router.activate(parent); assert.strictEqual(router.getServerOpenOwner(document), parent.rootKey); parent.events.length = 0; await router.activate(child); + await router.assign(document, child.rootKey); assert.strictEqual(router.getServerOpenOwner(document), child.rootKey); assert.deepStrictEqual( parent.events.filter((event) => event.includes(document.uri.toString())), @@ -109,6 +111,7 @@ suite('workspace document router', () => { child.events.length = 0; parent.events.length = 0; + await router.assign(document, parent.rootKey); await router.deactivate(child.rootKey); assert.strictEqual(router.getServerOpenOwner(document), parent.rootKey); assert.deepStrictEqual( @@ -133,6 +136,7 @@ suite('workspace document router', () => { childFolder.uri.toString(), childFolder, ); + await router.assign(document, child.rootKey); await router.activate(parent); await router.activate(child); @@ -169,6 +173,7 @@ suite('workspace document router', () => { parentFolder.uri.toString(), parentFolder, ); + await router.assign(document, parent.rootKey); await router.activate(parent); let forwarded = 0; @@ -191,6 +196,7 @@ suite('workspace document router', () => { parentFolder.uri.toString(), parentFolder, ); + await router.assign(document, parent.rootKey); await router.activate(parent); parent.events.length = 0; @@ -214,6 +220,7 @@ suite('workspace document router', () => { parentFolder.uri.toString(), parentFolder, ); + await router.assign(document, parent.rootKey); await router.activate(parent); parent.events.length = 0; @@ -243,11 +250,13 @@ suite('workspace document router', () => { childFolder.uri.toString(), childFolder, ); + await router.assign(document, parent.rootKey); await router.activate(parent); await router.resetServerSession(parent); parent.events.length = 0; await router.activate(child); + await router.assign(document, child.rootKey); assert.strictEqual(router.getServerOpenOwner(document), child.rootKey); assert.strictEqual( @@ -271,6 +280,7 @@ suite('workspace document router', () => { parentFolder.uri.toString(), parentFolder, ); + await router.assign(staleDocument, parent.rootKey); await router.activate(parent); const didOpen = router.createMiddleware(parent).didOpen; assert.ok(didOpen); @@ -320,6 +330,7 @@ suite('workspace document router', () => { parentFolder.uri.toString(), parentFolder, ); + await router.assign(staleDocument, parent.rootKey); await router.activate(parent); const firstDidOpen = router.createMiddleware(parent).didOpen; assert.ok(firstDidOpen); @@ -367,6 +378,7 @@ suite('workspace document router', () => { ); const oldMiddleware = router.createMiddleware(oldRuntime); const replacementMiddleware = router.createMiddleware(replacement); + await router.assign(document, oldRuntime.rootKey); await router.activate(oldRuntime); await router.deactivate(oldRuntime.rootKey); await router.activate(replacement); @@ -395,6 +407,7 @@ suite('workspace document router', () => { childFolder.uri.toString(), childFolder, ); + await router.assign(document, parent.rootKey); await router.activate(parent); let release!: () => void; @@ -416,6 +429,7 @@ suite('workspace document router', () => { ); await router.activate(child); + await router.assign(document, child.rootKey); release(); assert.strictEqual(await Promise.resolve(action), undefined); await router.closeAll(); @@ -432,10 +446,12 @@ suite('workspace document router', () => { childFolder, ); child.failOpen = true; + await router.assign(document, parent.rootKey); await router.activate(parent); + await router.activate(child); await assert.rejects( - router.activate(child), + router.assign(document, child.rootKey), /failed to activate document owner/, ); assert.strictEqual(router.getServerOpenOwner(document), parent.rootKey); diff --git a/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts b/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts index 8b7bca7..e75cbb5 100644 --- a/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts +++ b/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts @@ -1,11 +1,23 @@ -// Ported verbatim from web-infra-dev/rslint -// `packages/vscode-extension/__tests__/suite-monorepo/monorepo.test.ts` (origin/main). +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-monorepo/monorepo.test.ts` +// (origin/main). Verbatim apart from the nested-physical-core fixture setup, +// whose deviation is documented on `suiteSetup` below. import * as assert from 'assert'; import * as vscode from 'vscode'; import path from 'node:path'; import fs from 'node:fs'; import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; import { revertTextDocument } from '../utils/documents'; +import { CoreResolver } from '../../../src/stacks/lint/CoreResolver'; +import type { StackState } from '../../../src/types'; +import { extensionExports } from '../utils/extension'; + +function getRuntimeStates(): ReadonlyMap { + const exports = extensionExports().getStackExports('rslint') as + { getRuntimeStates?: () => ReadonlyMap } | undefined; + assert.ok(exports?.getRuntimeStates, 'lint stack exports are unavailable'); + return exports.getRuntimeStates(); +} suite('rslint monorepo multi-config support', function () { this.timeout(120000); @@ -28,8 +40,139 @@ suite('rslint monorepo multi-config support', function () { }); } + /** + * Give `packages/foo` its own **physical** `@rslint/core` copy, so the + * monorepo exercises two Lint runtimes in one workspace folder (rslint + * #1617). Upstream's fixture does the same by copying `package.json` + `dist` + * out of the workspace-resolved core and symlinking that core's own + * `node_modules` next to the copy. + * + * Adaptation: this repo installs published npm packages into one shared + * fixture root (`e2e/lint/fixtures/package.json`), and with pnpm's isolated + * store the core's dependencies (`picomatch`, `jiti`, the platform + * `@rslint/native-*`) live in the `node_modules` **two levels above** the + * package directory, not inside it. Those entries are linked individually — + * `@rslint/core` itself is skipped, because a link to the original would + * shadow the copy the Lint worker is meant to load. + */ + suiteSetup(async function () { + this.timeout(120000); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + // Resolve the core the *workspace* resolves, not the one this extension + // package happens to carry as a devDependency. + const sourceCore = path.dirname( + require.resolve('@rslint/core/package.json', { + paths: [getWorkspaceRoot()], + }), + ); + const nestedCore = path.join( + getWorkspaceRoot(), + 'packages/foo/node_modules/@rslint/core', + ); + await fs.promises.mkdir(nestedCore, { recursive: true }); + await Promise.all([ + fs.promises.copyFile( + path.join(sourceCore, 'package.json'), + path.join(nestedCore, 'package.json'), + ), + fs.promises.cp( + path.join(sourceCore, 'dist'), + path.join(nestedCore, 'dist'), + { recursive: true, force: false, errorOnExist: true }, + ), + ]); + + const dependencyRoot = path.resolve(sourceCore, '..', '..'); + const nestedModules = path.join(nestedCore, 'node_modules'); + await fs.promises.mkdir(nestedModules, { recursive: true }); + for (const entry of await fs.promises.readdir(dependencyRoot, { + withFileTypes: true, + })) { + if (entry.name.startsWith('.')) continue; + if (!entry.name.startsWith('@')) { + await fs.promises.symlink( + path.join(dependencyRoot, entry.name), + path.join(nestedModules, entry.name), + linkType, + ); + continue; + } + const scopeSource = path.join(dependencyRoot, entry.name); + const scopeTarget = path.join(nestedModules, entry.name); + await fs.promises.mkdir(scopeTarget, { recursive: true }); + for (const scoped of await fs.promises.readdir(scopeSource)) { + if (entry.name === '@rslint' && scoped === 'core') continue; + await fs.promises.symlink( + path.join(scopeSource, scoped), + path.join(scopeTarget, scoped), + linkType, + ); + } + } + }); + // ======== Basic multi-config resolution ======== + test('root and nested physical core copies run concurrently', async () => { + const rootDoc = await openFile('src/index.ts'); + const fooDoc = await openFile('packages/foo/src/index.ts'); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder); + + // The resolver takes the folder's detection decision; this fixture is a + // plain native Rslint folder (`rslint.config.js` at the root). + const resolver = new CoreResolver(); + const [rootCore, fooCore] = await Promise.all([ + resolver.resolve(rootDoc, workspaceFolder, { mode: 'native' }), + resolver.resolve(fooDoc, workspaceFolder, { mode: 'native' }), + ]); + assert.notStrictEqual( + rootCore.installation.identity, + fooCore.installation.identity, + ); + assert.strictEqual( + rootCore.installation.version, + fooCore.installation.version, + ); + + await vscode.window.showTextDocument(rootDoc, { preview: false }); + await vscode.window.showTextDocument(fooDoc, { preview: false }); + const [rootDiagnostics, fooDiagnostics] = await Promise.all([ + waitForDiagnostics(rootDoc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-explicit-any'), + ), + ), + waitForDiagnostics(fooDoc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-unsafe-member-access'), + ), + ), + ]); + + assert.ok( + rootDiagnostics.some((diagnostic) => + diagnostic.message.includes('no-explicit-any'), + ), + ); + assert.ok( + fooDiagnostics.some((diagnostic) => + diagnostic.message.includes('no-unsafe-member-access'), + ), + ); + + // Added to upstream's assertions: both documents getting diagnostics is + // also what a single runtime would produce, so "run concurrently" is + // pinned through the extension's exports — two live Lint runtimes, one + // per physical core, inside this one workspace folder. + const runtimeStates = getRuntimeStates(); + assert.strictEqual( + runtimeStates.size, + 2, + `expected two Lint runtimes, saw ${[...runtimeStates.keys()].join(', ')}`, + ); + }); + test('root file should use root config (no-explicit-any: error)', async () => { const doc = await openFile('src/index.ts'); await vscode.window.showTextDocument(doc); diff --git a/packages/vscode/e2e/lint/suite/extension.test.ts b/packages/vscode/e2e/lint/suite/extension.test.ts index c9a544c..551779d 100644 --- a/packages/vscode/e2e/lint/suite/extension.test.ts +++ b/packages/vscode/e2e/lint/suite/extension.test.ts @@ -384,6 +384,68 @@ suite('rslint extension', function () { ); }); + test('incremental edit after an emoji keeps UTF-16 positions aligned', async () => { + const doc = await openFixture('autofix.ts'); + const editor = await vscode.window.showTextDocument(doc); + await waitForDiagnostics(doc); + + const eol = doc.eol === vscode.EndOfLine.CRLF ? '\r\n' : '\n'; + const cleanContent = `const marker = '😀'; export { marker };${eol}`; + await editor.edit((builder) => { + builder.replace( + new vscode.Range( + doc.positionAt(0), + doc.positionAt(doc.getText().length), + ), + cleanContent, + ); + }); + await waitForDiagnosticsCount(doc, 0); + + const insertedContent = `const unsafeValue: any = {};${eol}unsafeValue.foo;${eol}`; + const insertionOffset = doc.getText().indexOf('export'); + assert.ok(insertionOffset > 0, 'Expected an export insertion anchor'); + await editor.edit((builder) => { + builder.insert(doc.positionAt(insertionOffset), insertedContent); + }); + + assert.strictEqual( + doc.getText(), + cleanContent.replace('export', `${insertedContent}export`), + 'VS Code should preserve the document EOL while applying the edit', + ); + const diagnostics = await waitForDiagnosticsWithMessage( + doc, + 'no-unsafe-member-access', + ); + const unsafeMember = diagnostics.find((diagnostic) => + diagnostic.message.includes('no-unsafe-member-access'), + ); + assert.ok(unsafeMember, 'Expected an unsafe member access diagnostic'); + assert.deepStrictEqual( + { + start: { + line: unsafeMember.range.start.line, + character: unsafeMember.range.start.character, + }, + end: { + line: unsafeMember.range.end.line, + character: unsafeMember.range.end.character, + }, + }, + { + start: { line: 1, character: 'unsafeValue.'.length }, + end: { line: 1, character: 'unsafeValue.foo'.length }, + }, + 'The server should return the UTF-16 range of the inserted property', + ); + assert.strictEqual( + doc.getText(unsafeMember.range), + 'foo', + 'The server diagnostic should select the inserted property', + ); + }); + test('diagnostics clear completely when all errors removed', async () => { const doc = await openFixture('index.ts'); const editor = await vscode.window.showTextDocument(doc); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index ecbb88d..bee55c4 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -154,7 +154,7 @@ "type": "string", "default": "", "scope": "resource", - "markdownDescription": "Path to an `@rslint/core` package directory. Relative paths are resolved from the workspace folder. When empty, the extension resolves the core from the folder's selected lint config source." + "markdownDescription": "Path to an `@rslint/core` package directory. Relative paths are resolved from the workspace folder. When empty, a native folder uses the nearest installation above each file and a bridged folder (root `rstack.config.*`) uses the one its `rstack` depends on." }, "rstack.rslint.trace.server": { "order": 2, diff --git a/packages/vscode/src/stacks/fmt/status.ts b/packages/vscode/src/stacks/fmt/status.ts index 21b052d..09109a4 100644 --- a/packages/vscode/src/stacks/fmt/status.ts +++ b/packages/vscode/src/stacks/fmt/status.ts @@ -43,9 +43,10 @@ export interface FmtFolderStatus { * reason). * * `disabled` outranks `running` — deliberately the opposite of the shell's - * and lint's tables, where `disabled` is a kill switch the user flipped. Here - * it means "this folder has no `rstack` installed and will never format", a - * fact worth showing over a healthy sibling. `stopped` ranks with `starting`: + * table, where `disabled` is a kill switch the user flipped. Here it means + * "this folder has no `rstack` installed and will never format", a fact worth + * showing over a healthy sibling (lint's fold ranks it the same way, for the + * same reason). `stopped` ranks with `starting`: * both are "no server right now, none of it an error" (a restart passes * through `stopped` on its way back up). */ diff --git a/packages/vscode/src/stacks/lint/CoreResolver.ts b/packages/vscode/src/stacks/lint/CoreResolver.ts new file mode 100644 index 0000000..54f7fcf --- /dev/null +++ b/packages/vscode/src/stacks/lint/CoreResolver.ts @@ -0,0 +1,155 @@ +// Ported from web-infra-dev/rslint `packages/vscode-extension/src/CoreResolver.ts` +// (rslint #1617, commit 39536fd6) and adapted to this extension. +// +// Upstream's resolver *loads* the selected `@rslint/core`: it requires +// `config-loader` / `eslint-plugin` from the package directory, calls +// `resolveRslintBinary()` and keeps the module factories on the installation. +// Here the extension host must never load project code (adaptation 3, +// `docs/adr/0003-lint-through-editor-worker.md`): the host walks the chain as +// far as the **core directory** with `fs.stat` + `package.json` reads, and the +// Lint worker takes the last hop (`--core `) on the User Node runtime. +// +// What is kept verbatim in spirit: +// - identity is the **real path** of the `@rslint/core` package directory, so +// two same-version copies stay separate cores and a symlink to one copy is +// that copy (CONTEXT.md, "Rslint core"); +// - the runtime key is `workspaceFolder + core identity`, so one folder runs +// one Lint runtime per physical core; +// - resolution is per document. +// +// Not kept: upstream's installation cache. It memoizes the module loading this +// host no longer does; the chain walk left is `fs.statSync` + one +// `package.json` read per hop and must see the current `node_modules`, so +// nothing is cached and `clear()` is a no-op kept for the `RuntimeManager` +// contract. + +import path from 'node:path'; +import type { TextDocument, WorkspaceFolder } from 'vscode'; +import { + checkPackageVersion, + formatVersionMismatch, +} from '../../shared/versionCheck'; +import { + resolveRslint, + type RslintMode, + type RslintResolution, +} from './resolution'; +import { RslintVersionMismatchError } from './status'; + +/** + * One **Rslint core** the extension is willing to run, plus the folder-level + * config choice the Lint worker needs on its command line. + * + * `shimPath` is part of the runtime's identity, not decoration: protocol 2 + * locks `configPath` for the server's lifetime (ADR 0003), so a folder that + * flips native ↔ bridged must get a *new* runtime even when both modes resolve + * the same physical core. Upstream has no bridged mode and keys on the core + * alone. + */ +export interface CoreInstallation { + /** Stable physical identity. Never merge cores by version text alone. */ + readonly identity: string; + readonly packageDirectory: string; + readonly version: string | undefined; + readonly mode: RslintMode; + readonly shimPath?: string; + readonly rstackDirectory?: string; + readonly rstackVersion?: string; +} + +export interface ResolvedCoreRuntime { + readonly key: string; + readonly workspaceFolder: WorkspaceFolder; + readonly installation: CoreInstallation; +} + +/** What the folder's detection decided, handed down per document. */ +export interface CoreResolutionRequest { + readonly mode: RslintMode; + /** `rstack.rslint.corePath`, read with the document's URI (resource scope). */ + readonly corePath?: string; +} + +function normalizeIdentity(filePath: string): string { + const normalized = path.normalize(filePath); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +export function runtimeKey( + workspaceFolder: WorkspaceFolder, + installation: CoreInstallation, +): string { + return `${workspaceFolder.uri.toString()}\0${installation.identity}\0${ + installation.shimPath ?? '' + }`; +} + +/** + * The version gate, applied **per resolved core** rather than per folder: two + * runtimes in one folder can sit on different copies, and only the failing one + * must be refused. A mismatch names the directory it came from, because with + * several cores in play "0.7.3 is not supported" is not actionable alone. + */ +function validateInstallation( + identity: string, + resolution: RslintResolution, +): CoreInstallation { + if (resolution.mode === 'bridged') { + const rstackCheck = checkPackageVersion('rstack', resolution.rstackVersion); + if (rstackCheck.kind === 'mismatch') { + throw new RslintVersionMismatchError( + `${formatVersionMismatch('rstack', rstackCheck)} (${resolution.rstackDir ?? 'unknown location'})`, + ); + } + } + const coreCheck = checkPackageVersion('@rslint/core', resolution.coreVersion); + if (coreCheck.kind === 'mismatch') { + throw new RslintVersionMismatchError( + `${formatVersionMismatch('@rslint/core', coreCheck)} (${resolution.coreDir})`, + ); + } + return { + identity, + packageDirectory: resolution.coreDir, + version: resolution.coreVersion, + mode: resolution.mode, + ...(resolution.shimPath === undefined + ? {} + : { + shimPath: resolution.shimPath, + rstackDirectory: resolution.rstackDir, + rstackVersion: resolution.rstackVersion, + }), + }; +} + +/** + * Resolves the Rslint core each open document should be linted by, and gives + * every physical core inside one workspace folder one stable runtime key. + */ +export class CoreResolver { + /** Nothing is cached (see the header); the `RuntimeManager` port calls this. */ + public clear(): void {} + + public async resolve( + document: Pick, + workspaceFolder: WorkspaceFolder, + request: CoreResolutionRequest, + ): Promise { + const resolution = resolveRslint({ + folderRoot: workspaceFolder.uri.fsPath, + mode: request.mode, + corePath: request.corePath, + documentDirectory: path.dirname(document.uri.fsPath), + }); + const installation = validateInstallation( + normalizeIdentity(resolution.coreDir), + resolution, + ); + return { + key: runtimeKey(workspaceFolder, installation), + workspaceFolder, + installation, + }; + } +} diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index d3a0c03..d0c810e 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -33,14 +33,11 @@ import { resolveUserNodeOnce, } from '../../shared/nodeResolution'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; -import { - checkPackageVersion, - formatVersionMismatch, -} from '../../shared/versionCheck'; import type { StackState } from '../../types'; +import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; import type { Logger } from './logger'; -import { resolveRslint, type RslintMode } from './resolution'; +import type { RslintMode } from './resolution'; import { RslintVersionMismatchError, runningRslintStatus, @@ -191,6 +188,19 @@ export interface LanguageClientCloseTarget { } export class ManagedLanguageClient extends LanguageClient { + public override async start(): Promise { + const outerStart = super.start(); + if (this.state !== State.Starting) return outerStart; + + // languageclient v9 keeps a shared start promise behind its public async + // start() call. A transport close during initialize clears the private + // reference before the outer call adopts it, leaving its rejection + // unobserved. A second, idempotent call adopts that shared promise now. + const sharedStart = super.start(); + void outerStart.catch(() => undefined); + return sharedStart; + } + public override async stop(timeout?: number): Promise { const stateBeforeStop = this.state; try { @@ -278,12 +288,17 @@ export type RslintStatusSink = (state: StackState) => void; export interface RslintOptions { readonly rootKey: string; readonly workspaceFolder: WorkspaceFolder; + /** + * The one Rslint core this runtime serves, already resolved and version + * gated by `CoreResolver`. Upstream passes loaded module factories here; we + * pass paths, because the host never loads project code (ADR 0003). + */ + readonly installation: CoreInstallation; readonly outputChannel: OutputChannel; readonly lspOutputChannel: OutputChannel; readonly router: WorkspaceDocumentRouter; readonly logger: Logger; readonly reportStatus: RslintStatusSink; - readonly getMode: () => RslintMode; } export class Rslint implements Disposable { @@ -293,7 +308,7 @@ export class Rslint implements Disposable { public readonly workspaceFolder: WorkspaceFolder; private readonly router: WorkspaceDocumentRouter; private readonly reportStatus: RslintStatusSink; - private readonly getMode: () => RslintMode; + private readonly installation: CoreInstallation; private readonly lspOutputChannel: OutputChannel; private readonly outputChannel: OutputChannel; private readonly configWatchers: FileSystemWatcher[] = []; @@ -315,7 +330,7 @@ export class Rslint implements Disposable { this.workspaceFolder = options.workspaceFolder; this.router = options.router; this.reportStatus = options.reportStatus; - this.getMode = options.getMode; + this.installation = options.installation; this.logger = options.logger; this.lspOutputChannel = options.lspOutputChannel; this.outputChannel = options.outputChannel; @@ -362,47 +377,21 @@ export class Rslint implements Disposable { this.report({ kind: 'starting' }); const folderRoot = this.workspaceFolder.uri.fsPath; - const mode = this.getMode(); - const corePath = workspace - .getConfiguration('rstack.rslint', this.workspaceFolder.uri) - .get('corePath'); - const resolution = resolveRslint({ folderRoot, mode, corePath }); - this.assertStartCurrent(epoch, signal); - - if (mode === 'bridged') { - const rstackCheck = checkPackageVersion( - 'rstack', - resolution.rstackVersion, - ); - if (rstackCheck.kind === 'mismatch') { - throw new RslintVersionMismatchError( - formatVersionMismatch('rstack', rstackCheck), - ); - } - } - const coreCheck = checkPackageVersion( - '@rslint/core', - resolution.coreVersion, - ); - if (coreCheck.kind === 'mismatch') { - throw new RslintVersionMismatchError( - formatVersionMismatch('@rslint/core', coreCheck), - ); - } + const { mode, packageDirectory, shimPath } = this.installation; this.logger.info( - `Rslint ${mode} mode: @rslint/core ${resolution.coreVersion ?? 'unknown'} at ${resolution.coreDir}`, + `Rslint ${mode} mode: @rslint/core ${this.installation.version ?? 'unknown'} at ${packageDirectory}`, ); - if (resolution.shimPath !== undefined) { - this.logger.info(`Rstack lint shim: ${resolution.shimPath}`); + if (shimPath !== undefined) { + this.logger.info(`Rstack lint shim: ${shimPath}`); } const nodeExecutable = await this.resolveNodeExecutable(); this.assertStartCurrent(epoch, signal); const workerPath = path.resolve(__dirname, 'lint-worker.js'); - const workerArgs = [workerPath, '--lsp', '--core', resolution.coreDir]; - if (resolution.shimPath !== undefined) { - workerArgs.push('--config', resolution.shimPath); + const workerArgs = [workerPath, '--lsp', '--core', packageDirectory]; + if (shimPath !== undefined) { + workerArgs.push('--config', shimPath); } const serverProcessOwner = new LanguageServerProcessOwner( nodeExecutable, diff --git a/packages/vscode/src/stacks/lint/RuntimeManager.ts b/packages/vscode/src/stacks/lint/RuntimeManager.ts new file mode 100644 index 0000000..2551c2c --- /dev/null +++ b/packages/vscode/src/stacks/lint/RuntimeManager.ts @@ -0,0 +1,484 @@ +// Ported from web-infra-dev/rslint `packages/vscode-extension/src/RuntimeManager.ts` +// (rslint #1617, commit 39536fd6). The lifecycle logic — refcounting a runtime +// by open document, start-before-switch handoff, forward-only cleanup, the +// per-document serialized tail and epochs — is upstream's and must stay +// diffable against it. +// +// Adaptations (see packages/vscode/AGENTS.md): +// - Detection is the gate. A document whose folder detection did not light up +// for Rslint is never linted, so the manager asks `folderMode` for the +// folder's ownership choice and detaches the document when it has none. +// - Settings live under `rstack.rslint.*`. +// - A failed resolution reports through the **folder status**, never a +// `window.showWarningMessage`: this stack owns no UI chrome (adaptation 4). +// The last-good rule is upstream's — the document keeps the runtime it +// already had, and a never-bound document is simply not linted. +// - The extra hooks (`onDocumentFailure` / `onDocumentSettled` / +// `onRuntimeClosed`) exist only so the controller can keep its per-folder +// status fold in step; they carry no lifecycle decisions. + +import { workspace, type TextDocument, type WorkspaceFolder } from 'vscode'; +import type { + CoreResolutionRequest, + ResolvedCoreRuntime, +} from './CoreResolver'; +import type { RslintMode } from './resolution'; +import { + isSupportedWorkspaceDocument, + type DocumentRoutingRuntime, + type WorkspaceDocumentRouter, +} from './WorkspaceDocumentRouter'; + +export interface ManagedRslintRuntime extends DocumentRoutingRuntime { + start(signal: AbortSignal): Promise; + close(): Promise; +} + +export type ManagedRslintRuntimeFactory = ( + resolved: ResolvedCoreRuntime, +) => ManagedRslintRuntime; + +export interface RuntimeCoreResolver { + clear(): void; + resolve( + document: TextDocument, + workspaceFolder: WorkspaceFolder, + request: CoreResolutionRequest, + ): Promise; +} + +export interface RuntimeManagerLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + error(message: string, error?: unknown, ...args: unknown[]): void; +} + +export interface DocumentResolutionFailure { + readonly document: TextDocument; + readonly workspaceFolder: WorkspaceFolder; + readonly error: unknown; + /** The core whose runtime failed to start; absent when resolution itself failed. */ + readonly resolved?: ResolvedCoreRuntime; +} + +export interface RuntimeManagerOptions { + /** + * The folder's Rslint ownership choice, or `undefined` when detection did + * not light the folder up. Undefined means "never lint this document" — the + * detection gate, not a failure. + */ + readonly folderMode: (folder: WorkspaceFolder) => RslintMode | undefined; + readonly documentIsOpen?: (document: TextDocument) => boolean; + readonly onDocumentFailure?: (failure: DocumentResolutionFailure) => void; + /** The document has no outstanding failure: bound, unchanged, or detached. */ + readonly onDocumentSettled?: (document: TextDocument) => void; + readonly onRuntimeClosed?: (resolved: ResolvedCoreRuntime) => void; +} + +interface RuntimeEntry { + readonly resolved: ResolvedCoreRuntime; + readonly runtime: ManagedRslintRuntime; + readonly abortController: AbortController; + readonly users: Set; + startPromise: Promise; + active: boolean; + closePromise?: Promise; +} + +function documentKey(document: TextDocument): string { + return document.uri.toString(); +} + +function cancellationError(key: string): Error { + const error = new Error(`Rslint runtime ${JSON.stringify(key)} was released`); + error.name = 'AbortError'; + return error; +} + +/** + * Resolves the core for each open document and shares one runtime for every + * physical core installation used inside the same VS Code workspace folder. + * A replacement is started before its document leaves the last-good runtime. + */ +export class RuntimeManager { + private readonly entries = new Map(); + private readonly closingRuntimes = new Map>(); + private readonly bindings = new Map(); + private readonly documentEpochs = new Map(); + private readonly documentTails = new Map>(); + private readonly documentIsOpen: (document: TextDocument) => boolean; + private closePromise: Promise | undefined; + private closing = false; + + public constructor( + private readonly router: WorkspaceDocumentRouter, + private readonly resolver: RuntimeCoreResolver, + private readonly runtimeFactory: ManagedRslintRuntimeFactory, + private readonly logger: RuntimeManagerLogger, + private readonly options: RuntimeManagerOptions, + ) { + this.documentIsOpen = + options.documentIsOpen ?? + ((document) => workspace.textDocuments.includes(document)); + } + + public initialize(documents: readonly TextDocument[]): void { + for (const document of documents) { + void this.reconcile(document).catch((error: unknown) => { + this.logger.error(`Failed to initialize ${document.uri}`, error); + }); + } + } + + public clearResolutionCache(): void { + this.resolver.clear(); + } + + public async reconcileOpenDocuments(): Promise { + await Promise.allSettled( + workspace.textDocuments.map(async (document) => this.reconcile(document)), + ); + } + + public async reconcile(document: TextDocument): Promise { + if (this.closing) return; + const key = documentKey(document); + const epoch = this.nextDocumentEpoch(key); + this.releasePendingDocumentUses(key); + await this.enqueueDocument(key, async () => { + if (!this.isCurrentDocument(document, epoch)) return; + await this.reconcileCurrentDocument(document, epoch); + }); + } + + /** Defer cleanup until LanguageClient's didClose middleware has run. */ + public documentClosed(document: TextDocument): void { + const key = documentKey(document); + const epoch = this.nextDocumentEpoch(key); + this.releasePendingDocumentUses(key); + setTimeout(() => { + void this.enqueueDocument(key, async () => { + if (this.documentEpochs.get(key) !== epoch) return; + await this.detachDocument(document); + this.documentEpochs.delete(key); + }).catch((error: unknown) => { + this.logger.error(`Failed to release ${document.uri}`, error); + }); + }, 0); + } + + public async close(): Promise { + await (this.closePromise ??= this.closeImpl()); + } + + private async reconcileCurrentDocument( + document: TextDocument, + epoch: number, + ): Promise { + const key = documentKey(document); + const existing = this.bindings.get(key); + const workspaceFolder = workspace.getWorkspaceFolder(document.uri); + const mode = workspaceFolder + ? this.options.folderMode(workspaceFolder) + : undefined; + if ( + !isSupportedWorkspaceDocument(document) || + !workspaceFolder || + mode === undefined + ) { + await this.detachDocument(document); + return; + } + const configuration = workspace.getConfiguration( + 'rstack.rslint', + document.uri, + ); + + let resolved: ResolvedCoreRuntime; + try { + resolved = await this.resolver.resolve(document, workspaceFolder, { + mode, + corePath: configuration.get('corePath'), + }); + } catch (error) { + if (this.isCurrentDocument(document, epoch)) { + this.reportFailure(document, workspaceFolder, error, existing); + } + return; + } + if (!this.isCurrentDocument(document, epoch)) return; + if (existing?.resolved.key === resolved.key) { + this.options.onDocumentSettled?.(document); + return; + } + + let replacement: RuntimeEntry | undefined; + let switched = false; + try { + replacement = this.acquireRuntime(resolved, key); + await replacement.startPromise; + if (!this.isCurrentDocument(document, epoch)) { + await this.releaseRuntimeAfterFailure(replacement, key); + return; + } + await this.router.assign(document, resolved.key); + this.bindings.set(key, replacement); + switched = true; + this.options.onDocumentSettled?.(document); + this.logger.debug( + `Using ${resolved.installation.packageDirectory} for ${document.uri}`, + ); + } catch (error) { + if (replacement && !switched) { + await this.releaseRuntimeAfterFailure(replacement, key); + } + if (this.isCurrentDocument(document, epoch)) { + this.reportFailure( + document, + workspaceFolder, + error, + existing, + resolved, + ); + } + return; + } + + // The handoff is committed once didOpen reaches the replacement. Cleanup + // of the old process is forward-only: a shutdown failure must not withdraw + // the now-owning runtime or leave the binding pointing at a closed process. + if (existing) { + void this.releaseRuntime(existing, key).catch((error: unknown) => { + this.logger.error( + `Failed to close superseded Rslint core ${existing.resolved.installation.packageDirectory}`, + error, + ); + }); + } + } + + private acquireRuntime( + resolved: ResolvedCoreRuntime, + documentUri: string, + ): RuntimeEntry { + let entry = this.entries.get(resolved.key); + if (!entry) { + const runtime = this.runtimeFactory(resolved); + const abortController = new AbortController(); + entry = { + resolved, + runtime, + abortController, + users: new Set(), + active: false, + startPromise: Promise.resolve(), + }; + const startPromise = this.startRuntime(entry); + entry.startPromise = startPromise; + this.entries.set(resolved.key, entry); + void startPromise.catch(() => undefined); + } + entry.users.add(documentUri); + return entry; + } + + private async startRuntime( + entry: Omit, + ): Promise { + await this.closingRuntimes.get(entry.resolved.key); + await entry.runtime.start(entry.abortController.signal); + if (this.closing || entry.abortController.signal.aborted) { + throw cancellationError(entry.resolved.key); + } + await this.router.activate(entry.runtime); + entry.active = true; + this.logger.info( + `Rslint core ${entry.resolved.installation.version ?? 'unknown'} ready for ${entry.resolved.workspaceFolder.name}`, + ); + } + + private async releaseRuntime( + entry: RuntimeEntry, + documentUri: string, + ): Promise { + entry.users.delete(documentUri); + if (entry.users.size > 0) return; + await this.closeRuntime(entry); + } + + private async closeRuntime(entry: RuntimeEntry): Promise { + if (!entry.closePromise) { + if (this.entries.get(entry.resolved.key) === entry) { + this.entries.delete(entry.resolved.key); + } + const closePromise = (async () => { + entry.abortController.abort(cancellationError(entry.resolved.key)); + await entry.startPromise.catch(() => undefined); + const errors: unknown[] = []; + if (entry.active) { + try { + await this.router.deactivate(entry.resolved.key); + } catch (error) { + errors.push(error); + } + entry.active = false; + } + try { + await entry.runtime.close(); + } catch (error) { + errors.push(error); + } finally { + this.options.onRuntimeClosed?.(entry.resolved); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, 'failed to close Rslint runtime'); + } + })(); + entry.closePromise = closePromise; + const previousBarrier = this.closingRuntimes.get(entry.resolved.key); + const barrier = previousBarrier + ? Promise.all([previousBarrier, closePromise]).then(() => undefined) + : closePromise; + this.closingRuntimes.set(entry.resolved.key, barrier); + void barrier.then( + () => { + if (this.closingRuntimes.get(entry.resolved.key) === barrier) { + this.closingRuntimes.delete(entry.resolved.key); + } + }, + () => undefined, + ); + } + await entry.closePromise; + } + + private async releaseRuntimeAfterFailure( + entry: RuntimeEntry, + documentUri: string, + ): Promise { + try { + await this.releaseRuntime(entry, documentUri); + } catch (error) { + this.logger.error( + `Failed to clean up unusable Rslint core ${entry.resolved.installation.packageDirectory}`, + error, + ); + } + } + + private releasePendingDocumentUses(documentUri: string): void { + const bound = this.bindings.get(documentUri); + for (const entry of [...this.entries.values()]) { + if (entry === bound || !entry.users.has(documentUri)) continue; + void this.releaseRuntime(entry, documentUri).catch((error: unknown) => { + this.logger.error( + `Failed to cancel pending Rslint core ${entry.resolved.installation.packageDirectory}`, + error, + ); + }); + } + } + + private async detachDocument(document: TextDocument): Promise { + const key = documentKey(document); + const existing = this.bindings.get(key); + await this.router.assign(document, undefined); + this.bindings.delete(key); + this.options.onDocumentSettled?.(document); + if (existing) await this.releaseRuntime(existing, key); + } + + /** + * Upstream shows one deduplicated warning toast per workspace and failure + * category. Here the same event becomes a folder status entry (the stack + * owns no UI chrome), so no deduplication is needed: a status is a value, + * not a notification, and the controller replaces the document's previous + * one. The Output-channel line stays. + */ + private reportFailure( + document: TextDocument, + workspaceFolder: WorkspaceFolder, + error: unknown, + existing: RuntimeEntry | undefined, + resolved?: ResolvedCoreRuntime, + ): void { + const suffix = existing + ? ` (keeping ${existing.resolved.installation.packageDirectory} active)` + : ''; + this.logger.error( + `Could not select an Rslint core for ${document.uri}${suffix}`, + error, + ); + this.options.onDocumentFailure?.({ + document, + workspaceFolder, + error, + resolved, + }); + } + + private isCurrentDocument(document: TextDocument, epoch: number): boolean { + return ( + !this.closing && + this.documentEpochs.get(documentKey(document)) === epoch && + this.documentIsOpen(document) + ); + } + + private nextDocumentEpoch(key: string): number { + const epoch = (this.documentEpochs.get(key) ?? 0) + 1; + this.documentEpochs.set(key, epoch); + return epoch; + } + + private async enqueueDocument( + key: string, + operation: () => Promise, + ): Promise { + const previous = this.documentTails.get(key) ?? Promise.resolve(); + const run = previous.then(operation, operation); + const tail = run.catch(() => undefined); + this.documentTails.set(key, tail); + try { + await run; + } finally { + if (this.documentTails.get(key) === tail) { + this.documentTails.delete(key); + } + } + } + + private async closeImpl(): Promise { + if (this.closing) return; + this.closing = true; + for (const key of this.documentEpochs.keys()) this.nextDocumentEpoch(key); + for (const entry of this.entries.values()) { + entry.abortController.abort(cancellationError(entry.resolved.key)); + } + + const errors: unknown[] = []; + try { + await this.router.closeAll(); + } catch (error) { + errors.push(error); + } + const closingBeforeShutdown = [...this.closingRuntimes.values()]; + const results = await Promise.allSettled([ + ...closingBeforeShutdown, + ...[...this.entries.values()].map(async (entry) => + this.closeRuntime(entry), + ), + ]); + for (const result of results) { + if (result.status === 'rejected') errors.push(result.reason); + } + this.entries.clear(); + this.closingRuntimes.clear(); + this.bindings.clear(); + this.documentEpochs.clear(); + if (errors.length > 0) { + throw new AggregateError(errors, 'failed to close runtime manager'); + } + } +} diff --git a/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts b/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts index 387683e..f488a59 100644 --- a/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts +++ b/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts @@ -1,5 +1,4 @@ import { - languages, workspace, RelativePattern, type CodeAction, @@ -28,7 +27,6 @@ export interface DocumentRoutingRuntime { interface ActiveRuntime { readonly runtime: DocumentRoutingRuntime; - readonly selector: DocumentFilter[]; } interface ServerOpenDocumentSession { @@ -87,6 +85,7 @@ export function isSupportedWorkspaceDocument( */ export class WorkspaceDocumentRouter { private activeRoots = new Map(); + private documentOwners = new Map(); private readonly serverOpenDocuments = new Map< string, ServerOpenDocumentSession @@ -180,17 +179,45 @@ export class WorkspaceDocumentRouter { if (existing?.runtime === runtime) return; if (existing) { throw new Error( - `workspace root ${JSON.stringify(runtime.rootKey)} is already active`, + `runtime key ${JSON.stringify(runtime.rootKey)} is already active`, ); } const before = this.activeRoots; const after = new Map(before); - after.set(runtime.rootKey, { - runtime, - selector: createWorkspaceDocumentSelector(runtime.workspaceFolder), - }); - await this.transferDocuments(before, after, true); + after.set(runtime.rootKey, { runtime }); + await this.transferDocuments( + before, + after, + this.documentOwners, + this.documentOwners, + true, + ); + }); + } + + /** Assign one open document to the runtime selected by local-core resolution. */ + public async assign( + document: TextDocument, + rootKey: string | undefined, + ): Promise { + return this.enqueue(async () => { + const uri = documentKey(document); + const previous = this.documentOwners.get(uri); + const next = isSupportedWorkspaceDocument(document) ? rootKey : undefined; + if (previous === next) return; + + const afterOwners = new Map(this.documentOwners); + if (next) afterOwners.set(uri, next); + else afterOwners.delete(uri); + await this.transferDocuments( + this.activeRoots, + this.activeRoots, + this.documentOwners, + afterOwners, + true, + [document], + ); }); } @@ -203,7 +230,13 @@ export class WorkspaceDocumentRouter { after.delete(rootKey); const errors: unknown[] = []; try { - await this.transferDocuments(before, after, false); + await this.transferDocuments( + before, + after, + this.documentOwners, + this.documentOwners, + false, + ); } catch (error) { errors.push(...errorList(error)); } @@ -252,6 +285,7 @@ export class WorkspaceDocumentRouter { } this.serverOpenDocuments.clear(); this.activeRoots = new Map(); + this.documentOwners = new Map(); throwCollectedErrors(errors, 'failed to close routed documents'); }); } @@ -261,20 +295,27 @@ export class WorkspaceDocumentRouter { } public ownerKeyForDocument(document: TextDocument): string | undefined { - return this.ownerKeyForDocumentIn(this.activeRoots, document); + return this.ownerKeyForDocumentIn( + this.activeRoots, + this.documentOwners, + document, + ); } private async transferDocuments( before: Map, after: Map, + beforeOwners: Map, + afterOwners: Map, rollbackOnFailure: boolean, + documents: readonly TextDocument[] = workspace.textDocuments, ): Promise { - const transfers = workspace.textDocuments + const transfers = documents .filter(isSupportedWorkspaceDocument) .map((document) => ({ document, - oldOwnerKey: this.ownerKeyForDocumentIn(before, document), - newOwnerKey: this.ownerKeyForDocumentIn(after, document), + oldOwnerKey: this.ownerKeyForDocumentIn(before, beforeOwners, document), + newOwnerKey: this.ownerKeyForDocumentIn(after, afterOwners, document), })) .filter(({ oldOwnerKey, newOwnerKey }) => oldOwnerKey !== newOwnerKey); @@ -302,7 +343,7 @@ export class WorkspaceDocumentRouter { } if (errors.length > 0 && rollbackOnFailure) { - await this.restoreOldOwners(before, closedOld, errors); + await this.restoreOldOwners(before, beforeOwners, closedOld, errors); throw new AggregateError( errors, 'failed to close previous document owners', @@ -310,6 +351,7 @@ export class WorkspaceDocumentRouter { } this.activeRoots = after; + this.documentOwners = afterOwners; for (const transfer of transfers) { if (!transfer.newOwnerKey) continue; @@ -348,12 +390,14 @@ export class WorkspaceDocumentRouter { this.releaseDocumentSession(newOwner.runtime, transfer.document, errors); } this.activeRoots = before; - await this.restoreOldOwners(before, closedOld, errors); + this.documentOwners = beforeOwners; + await this.restoreOldOwners(before, beforeOwners, closedOld, errors); throw new AggregateError(errors, 'failed to activate document owner'); } private async restoreOldOwners( before: Map, + beforeOwners: Map, transfers: ReadonlyArray<{ document: TextDocument; oldOwnerKey: string | undefined; @@ -361,6 +405,7 @@ export class WorkspaceDocumentRouter { errors: unknown[], ): Promise { this.activeRoots = before; + this.documentOwners = beforeOwners; for (const transfer of transfers) { if (!transfer.oldOwnerKey) continue; const oldOwner = before.get(transfer.oldOwnerKey); @@ -380,24 +425,12 @@ export class WorkspaceDocumentRouter { private ownerKeyForDocumentIn( roots: ReadonlyMap, + owners: ReadonlyMap, document: TextDocument, ): string | undefined { if (!isSupportedWorkspaceDocument(document)) return undefined; - let best: { key: string; depth: number } | undefined; - for (const [key, entry] of roots) { - if (languages.match(entry.selector, document) <= 0) continue; - const depth = entry.runtime.workspaceFolder.uri.path - .split('/') - .filter(Boolean).length; - if ( - !best || - depth > best.depth || - (depth === best.depth && key.localeCompare(best.key) < 0) - ) { - best = { key, depth }; - } - } - return best?.key; + const key = owners.get(documentKey(document)); + return key && roots.has(key) ? key : undefined; } private ownerForDocument( diff --git a/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts b/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts deleted file mode 100644 index bf90ab6..0000000 --- a/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts +++ /dev/null @@ -1,530 +0,0 @@ -import type { WorkspaceFolder, WorkspaceFoldersChangeEvent } from 'vscode'; -import type { DocumentRoutingRuntime } from './WorkspaceDocumentRouter'; - -export interface WorkspaceRuntime extends DocumentRoutingRuntime { - start(signal: AbortSignal): Promise; - close(): Promise; -} - -export type WorkspaceRuntimeFactory = ( - folder: WorkspaceFolder, - rootKey: string, -) => WorkspaceRuntime; - -export interface WorkspaceRootRouter { - activate(runtime: DocumentRoutingRuntime): Promise; - deactivate(rootKey: string): Promise; - closeAll(): Promise; -} - -export interface WorkspaceCoordinatorLogger { - debug(message: string, ...args: unknown[]): void; - info(message: string, ...args: unknown[]): void; - warn(message: string, ...args: unknown[]): void; - error(message: string, error?: unknown, ...args: unknown[]): void; -} - -interface DesiredRoot { - readonly folder: WorkspaceFolder; - readonly generation: number; - readonly readiness: Deferred; -} - -interface CurrentRuntime { - readonly generation: number; - readonly runtime: WorkspaceRuntime; - readonly abortController: AbortController; - phase: 'starting' | 'active' | 'closing' | 'close-failed'; - closeError?: unknown; - closeFailureRecorded?: boolean; -} - -interface RootSlot { - readonly key: string; - current?: CurrentRuntime; - failedGeneration?: number; - worker?: Promise; - rerun: boolean; -} - -interface Deferred { - readonly promise: Promise; - resolve(value: T | PromiseLike): void; - reject(reason?: unknown): void; -} - -function deferred(): Deferred { - let resolvePromise!: (value: T | PromiseLike) => void; - let rejectPromise!: (reason?: unknown) => void; - let settled = false; - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - // Root readiness is also observed by dynamic fire-and-forget reconciliation. - // Keep a rejection handler attached even when no activation caller awaits it. - void promise.catch(() => undefined); - return { - promise, - resolve(value) { - if (settled) return; - settled = true; - resolvePromise(value); - }, - reject(reason) { - if (settled) return; - settled = true; - rejectPromise(reason); - }, - }; -} - -function cancellationError(rootKey: string): Error { - const error = new Error( - `workspace root ${JSON.stringify(rootKey)} was superseded`, - ); - error.name = 'AbortError'; - return error; -} - -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError'; -} - -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function errorReasons(error: unknown): unknown[] { - if (!(error instanceof AggregateError)) return [error]; - const reasons: readonly unknown[] = error.errors; - return [...reasons]; -} - -function folderMetadataChanged( - previous: WorkspaceFolder, - next: WorkspaceFolder, -): boolean { - // index is positional metadata and routinely changes when an unrelated root - // is inserted before this one. It is neither identity nor a restart reason. - return previous.name !== next.name; -} - -export function workspaceRootKey(folder: WorkspaceFolder): string { - return folder.uri.toString(); -} - -/** - * Reconciles VS Code workspace-folder identity with independently-lived root - * runtimes. It never serializes different roots behind config evaluation or - * worker shutdown; only each URI slot is ordered. - */ -export class WorkspaceRslintCoordinator { - private readonly desiredRoots = new Map(); - private readonly slots = new Map(); - private readonly generations = new Map(); - private readonly terminalCloseErrors: unknown[] = []; - private topologyChanged = deferred(); - private closePromise: Promise | undefined; - private closing = false; - - public constructor( - private readonly router: WorkspaceRootRouter, - private readonly runtimeFactory: WorkspaceRuntimeFactory, - private readonly logger: WorkspaceCoordinatorLogger, - /** - * Classifies a start rejection as an expected outcome instead of a failure. - * A root that deliberately does not start (for example a folder with no - * lint configuration at all) still rejects — that is how a slot is kept - * from being retried — but it must not be logged as an error. - */ - private readonly isExpectedStartFailure: (error: unknown) => boolean = () => - false, - ) {} - - public async initialize(folders: readonly WorkspaceFolder[]): Promise { - this.reconcile(folders); - await this.waitForAnyDesiredRoot(); - } - - public handleWorkspaceFoldersChanged( - event: WorkspaceFoldersChangeEvent, - folders: readonly WorkspaceFolder[], - forceReplaceRoots: ReadonlySet = new Set(), - ): void { - if (this.closing) return; - const removedKeys = new Set(); - for (const folder of event.removed) { - removedKeys.add(workspaceRootKey(folder)); - } - const forceReplace = new Set(forceReplaceRoots); - for (const folder of event.added) { - const key = workspaceRootKey(folder); - // A rename/remove+add replacement can preserve the URI. A plain added - // event for a URI already captured by the activation snapshot is not a - // replacement and must not abort that in-flight initial generation. - if (removedKeys.has(key)) forceReplace.add(key); - } - this.reconcile(folders, forceReplace); - } - - /** - * Re-attempts every root whose last start failed. A failed slot is pinned to - * its `failedGeneration` so `reconcile` never spins on it, and folder - * identity — the only thing `reconcile` keys on — does not change when the - * failure cause goes away (e.g. `pnpm install` materializes the Rslint - * binary, observed as a lockfile-driven detection pass). The retry therefore - * has to bump the generation explicitly. `failedGeneration` matching the - * desired generation is the precise "currently failed" predicate — a live or - * in-flight runtime always carries a newer generation — so healthy roots are - * left alone. - */ - public retryFailedRoots(): void { - if (this.closing) return; - const changedKeys = new Set(); - for (const [key, desired] of this.desiredRoots) { - if (this.slots.get(key)?.failedGeneration !== desired.generation) { - continue; - } - this.desiredRoots.set(key, this.createDesiredRoot(desired.folder)); - changedKeys.add(key); - } - for (const key of changedKeys) this.kick(key); - if (changedKeys.size > 0) this.signalTopologyChanged(); - } - - public async close(): Promise { - await (this.closePromise ??= this.closeImpl()); - } - - private reconcile( - folders: readonly WorkspaceFolder[], - forceReplace: ReadonlySet = new Set(), - ): void { - if (this.closing) return; - const nextFolders = new Map( - folders.map((folder) => [workspaceRootKey(folder), folder]), - ); - const changedKeys = new Set(); - - for (const [key, desired] of this.desiredRoots) { - const next = nextFolders.get(key); - if (!next) { - this.desiredRoots.delete(key); - this.nextGeneration(key); - desired.readiness.reject(cancellationError(key)); - changedKeys.add(key); - this.slots - .get(key) - ?.current?.abortController.abort(cancellationError(key)); - continue; - } - if ( - forceReplace.has(key) || - folderMetadataChanged(desired.folder, next) - ) { - desired.readiness.reject(cancellationError(key)); - const replacement = this.createDesiredRoot(next); - this.desiredRoots.set(key, replacement); - changedKeys.add(key); - this.slots - .get(key) - ?.current?.abortController.abort(cancellationError(key)); - } - nextFolders.delete(key); - } - - for (const [key, folder] of nextFolders) { - this.desiredRoots.set(key, this.createDesiredRoot(folder)); - changedKeys.add(key); - } - - for (const key of changedKeys) this.kick(key); - if (changedKeys.size > 0) this.signalTopologyChanged(); - } - - private createDesiredRoot(folder: WorkspaceFolder): DesiredRoot { - return { - folder, - generation: this.nextGeneration(workspaceRootKey(folder)), - readiness: deferred(), - }; - } - - private async waitForAnyDesiredRoot(): Promise { - for (;;) { - const snapshot = [...this.desiredRoots.entries()]; - if (snapshot.length === 0) return; - // Resolve as soon as one independent root is usable. A pending root - // cannot undo another root's successful activation. - const readiness: Promise[] = []; - for (const [, desired] of snapshot) { - readiness.push(desired.readiness.promise); - } - const result = await Promise.race([ - Promise.any(readiness).then( - () => ({ kind: 'ready' as const }), - (error: unknown) => ({ kind: 'failed' as const, error }), - ), - this.topologyChanged.promise.then(() => ({ - kind: 'topology' as const, - })), - ]); - if (result.kind === 'topology') continue; - if (result.kind === 'ready') { - return; - } - if (this.closing) throw result.error; - const topologyChanged = - snapshot.length !== this.desiredRoots.size || - snapshot.some( - ([key, desired]) => - this.desiredRoots.get(key)?.generation !== desired.generation, - ); - // Folder events are installed before initialization. If that topology - // superseded every promise in this snapshot, observe the new desired - // generations instead of treating cancellation as an activation - // failure. - if (topologyChanged) continue; - - const reasons = errorReasons(result.error); - throw new AggregateError( - reasons, - `All Rslint workspace roots failed: ${reasons - .map((reason) => - reason instanceof Error ? reason.message : String(reason), - ) - .join('; ')}`, - ); - } - } - - private signalTopologyChanged(): void { - const previous = this.topologyChanged; - this.topologyChanged = deferred(); - previous.resolve(undefined); - } - - private nextGeneration(rootKey: string): number { - const generation = (this.generations.get(rootKey) ?? 0) + 1; - this.generations.set(rootKey, generation); - return generation; - } - - private kick(rootKey: string): void { - let slot = this.slots.get(rootKey); - if (!slot) { - slot = { key: rootKey, rerun: false }; - this.slots.set(rootKey, slot); - } - slot.rerun = true; - if (slot.worker) return; - slot.worker = this.runSlot(slot).finally(() => { - slot.worker = undefined; - if (slot.rerun && !this.closing) { - this.kick(rootKey); - } else if (!slot.current && !this.desiredRoots.has(rootKey)) { - this.slots.delete(rootKey); - this.generations.delete(rootKey); - } - }); - } - - private async runSlot(slot: RootSlot): Promise { - while (slot.rerun || this.slotNeedsReconcile(slot)) { - slot.rerun = false; - const desired = this.desiredRoots.get(slot.key); - const current = slot.current; - - if ( - current && - (!desired || desired.generation !== current.generation || this.closing) - ) { - if (!(await this.closeCurrent(slot, current))) return; - continue; - } - - if (!current && desired && !this.closing) { - if (slot.failedGeneration === desired.generation) return; - if (!(await this.startDesired(slot, desired))) return; - continue; - } - - return; - } - } - - private slotNeedsReconcile(slot: RootSlot): boolean { - const desired = this.desiredRoots.get(slot.key); - const current = slot.current; - if (this.closing) return current !== undefined; - if (!current) { - return !!desired && slot.failedGeneration !== desired.generation; - } - return !desired || desired.generation !== current.generation; - } - - private async startDesired( - slot: RootSlot, - desired: DesiredRoot, - ): Promise { - const abortController = new AbortController(); - let runtime: WorkspaceRuntime; - try { - runtime = this.runtimeFactory(desired.folder, slot.key); - } catch (error) { - slot.failedGeneration = desired.generation; - desired.readiness.reject(error); - this.logger.error(`Failed to create Rslint workspace ${slot.key}`, error); - return true; - } - const current: CurrentRuntime = { - generation: desired.generation, - runtime, - abortController, - phase: 'starting', - }; - slot.current = current; - this.logger.debug( - `Starting Rslint workspace ${slot.key} generation ${desired.generation}`, - ); - - try { - await runtime.start(abortController.signal); - if ( - this.closing || - abortController.signal.aborted || - this.desiredRoots.get(slot.key)?.generation !== desired.generation - ) { - throw cancellationError(slot.key); - } - await this.router.activate(runtime); - if ( - this.closing || - abortController.signal.aborted || - this.desiredRoots.get(slot.key)?.generation !== desired.generation - ) { - await this.router.deactivate(slot.key).catch((error: unknown) => { - this.logger.error( - `Failed to withdraw stale workspace ${slot.key}`, - error, - ); - }); - throw cancellationError(slot.key); - } - current.phase = 'active'; - desired.readiness.resolve(undefined); - this.logger.info(`Rslint workspace ready: ${slot.key}`); - return true; - } catch (error) { - const stale = - isAbortError(error) || - abortController.signal.aborted || - this.desiredRoots.get(slot.key)?.generation !== desired.generation || - this.closing; - if (!stale) { - slot.failedGeneration = desired.generation; - desired.readiness.reject(error); - if (this.isExpectedStartFailure(error)) { - this.logger.info( - `Rslint workspace ${slot.key} was not started: ${describeError(error)}`, - ); - } else { - this.logger.error( - `Failed to start Rslint workspace ${slot.key}`, - error, - ); - } - } else { - desired.readiness.reject(cancellationError(slot.key)); - } - return this.closeCurrent(slot, current); - } - } - - private async closeCurrent( - slot: RootSlot, - current: CurrentRuntime, - ): Promise { - if (slot.current !== current) return true; - current.abortController.abort(cancellationError(slot.key)); - if (current.phase === 'active') { - try { - await this.router.deactivate(slot.key); - } catch (error) { - this.logger.error( - `Failed to transfer documents away from ${slot.key}`, - error, - ); - } - } - current.phase = 'closing'; - try { - await current.runtime.close(); - } catch (error) { - current.phase = 'close-failed'; - current.closeError = error; - this.logger.error(`Failed to close Rslint workspace ${slot.key}`, error); - if (!current.closeFailureRecorded) { - current.closeFailureRecorded = true; - this.terminalCloseErrors.push(error); - } - - // A runtime whose resources did not close remains the sole owner of its - // URI slot. Starting a replacement here could overlap native processes, - // workers, watchers, and diagnostics for one workspace. Quarantine it - // until an explicit later reconciliation (or terminal close) retries. - const replacement = this.desiredRoots.get(slot.key); - if (replacement && replacement.generation !== current.generation) { - slot.failedGeneration = replacement.generation; - replacement.readiness.reject( - new Error( - `Could not replace Rslint workspace ${JSON.stringify(slot.key)} because the previous runtime failed to close`, - { cause: error }, - ), - ); - } - return false; - } - if (slot.current === current) slot.current = undefined; - this.logger.debug(`Closed Rslint workspace ${slot.key}`); - return true; - } - - private async closeImpl(): Promise { - if (this.closing) return; - this.closing = true; - for (const [key, desired] of this.desiredRoots) { - desired.readiness.reject(cancellationError(key)); - } - this.desiredRoots.clear(); - for (const slot of this.slots.values()) { - slot.current?.abortController.abort(cancellationError(slot.key)); - slot.rerun = true; - } - - const routerResult = await Promise.allSettled([ - Promise.resolve().then(async () => { - await this.router.closeAll(); - }), - ]); - for (const slot of this.slots.values()) this.kick(slot.key); - const slotPromises: Promise[] = []; - for (const slot of this.slots.values()) { - slotPromises.push(slot.worker ?? Promise.resolve()); - } - const slotResults = await Promise.allSettled(slotPromises); - this.slots.clear(); - - const errors = this.terminalCloseErrors.splice(0); - for (const result of [...routerResult, ...slotResults]) { - if (result.status === 'rejected') { - const reason: unknown = result.reason; - errors.push(reason); - } - } - if (errors.length > 0) { - throw new AggregateError(errors, 'failed to close workspace coordinator'); - } - } -} diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index b1cfffb..8a9d672 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -6,111 +6,65 @@ import type { StackState, } from '../../types'; import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; +import { CoreResolver, type ResolvedCoreRuntime } from './CoreResolver'; import { Logger } from './logger'; import { Rslint } from './Rslint'; import type { RslintMode } from './resolution'; -import { WorkspaceDocumentRouter } from './WorkspaceDocumentRouter'; +import { RuntimeManager } from './RuntimeManager'; import { - WorkspaceRslintCoordinator, - workspaceRootKey, -} from './WorkspaceRslintCoordinator'; + aggregateFolderStates, + attributeToCore, + foldRslintFolderState, + statusForRslintStartFailure, +} from './status'; +import { WorkspaceDocumentRouter } from './WorkspaceDocumentRouter'; /** * Replaces upstream's `main.ts` + `Extension.ts` + `statusBar.ts` + * `commands.ts` (the shell-activation and status-aggregation adaptations). * - * Upstream activates the extension itself and `await`s - * `coordinator.initialize()`, which resolves only once a language server root - * is ready — the readiness contract its E2E harness depends on. Here the shell - * owns activation: `register()` must return as soon as the runtimes are - * *scheduled*, never block on a Go process start, and every state transition - * flows into the shared status bar instead of an own status bar item. + * Upstream activates the extension itself; here the shell owns activation, so + * `register()` must return as soon as the folders are registered and the open + * documents are *scheduled* for reconciliation — never block on a Go process + * start — and every state transition flows into the shared status bar instead + * of an own status bar item. + * + * Since rslint #1617 the unit of lifecycle is a **Lint runtime** per Rslint + * core per folder, refcounted by open document: a folder with no open document + * holds no runtime and reports idle. What this controller owns is exactly what + * upstream's `Extension.ts` owns — the document and topology triggers — plus + * the per-folder status fold the shell requires. */ -const STATE_RANK: Readonly> = { - crashed: 5, - 'version-mismatch': 4, - starting: 3, - running: 2, - disabled: 1, - 'not-detected': 0, -}; - -interface FolderStatus { - readonly name: string; - readonly state: StackState; -} - -const detailOf = (state: StackState): string | undefined => { - switch (state.kind) { - case 'crashed': - case 'version-mismatch': - return state.detail; - case 'starting': - case 'running': - return state.detail; - case 'disabled': - return state.reason; - case 'not-detected': - return undefined; - } -}; - /** - * Folds every workspace folder's runtime into the one state the status bar - * shows for the Rslint stack. The worst state wins, and the detail names the - * folders it came from — with multiple roots, "crashed" without a folder name - * is not actionable. + * The one core-topology signal the shell's detection watcher does not carry: + * a core swapped in place. Lockfiles — upstream's other half of this glob — + * are already detection's business, and a detection pass notifies this stack + * even when the folder set is unchanged. `files.watcherExclude` hides + * `node_modules` by default, so in practice the lockfile path is the one that + * fires; this watcher costs nothing and covers the rest. */ -export const aggregateFolderStates = ( - statuses: readonly FolderStatus[], -): StackState => { - if (statuses.length === 0) { - return { kind: 'starting' }; - } - let worst = statuses[0]!; - for (const candidate of statuses) { - if (STATE_RANK[candidate.state.kind] > STATE_RANK[worst.state.kind]) { - worst = candidate; - } - } - const multiRoot = statuses.length > 1; - const details = statuses - .filter((entry) => entry.state.kind === worst.state.kind) - .map((entry) => { - const detail = detailOf(entry.state); - if (!detail) { - return multiRoot ? entry.name : undefined; - } - return multiRoot ? `${entry.name}: ${detail}` : detail; - }) - .filter((entry): entry is string => entry !== undefined); - const detail = details.length > 0 ? details.join(' | ') : undefined; +const CORE_TOPOLOGY_GLOB = '**/node_modules/@rslint/core/package.json'; - switch (worst.state.kind) { - case 'crashed': - return { - kind: 'crashed', - detail: detail ?? 'the language server failed', - }; - case 'version-mismatch': - return { - kind: 'version-mismatch', - detail: detail ?? 'unsupported @rslint/core version', - }; - case 'starting': - return { kind: 'starting', detail }; - case 'running': - return { kind: 'running', detail }; - case 'disabled': - return { kind: 'disabled', reason: detail }; - case 'not-detected': - return { kind: 'not-detected' }; - } -}; +/** Everything one detected folder contributes to its status fold. */ +interface FolderStates { + /** One entry per live Lint runtime, keyed by its runtime key. */ + readonly runtimes: Map; + /** One entry per document whose core resolution currently fails. */ + readonly failures: Map; +} + +const folderKeyOf = (folder: vscode.WorkspaceFolder): string => + folder.uri.toString(); class RslintController implements StackController { readonly id = 'rslint' as const; + /** + * Upstream reconciles in place when `corePath` changes; here it stays a + * restart trigger. The shell answers a relevant settings change with one + * full restart pass (a stack must never rebuild itself), which also clears + * the shared User Node preflight memo — an in-place reconcile would keep it. + */ readonly restartOnSettings = [ NODE_EXECUTABLE_SETTING, 'corePath', @@ -119,11 +73,11 @@ class RslintController implements StackController { #context: StackContext | undefined; #logger: Logger | undefined; - #coordinator: WorkspaceRslintCoordinator | undefined; + #runtimeManager: RuntimeManager | undefined; + /** The detection gate: a folder lints only while the snapshot lights it. */ #snapshot: DetectionSnapshot | undefined; readonly #subscriptions: vscode.Disposable[] = []; - readonly #folderStates = new Map(); - readonly #folderModes = new Map(); + readonly #folderStates = new Map(); #disposed = false; async register(context: StackContext): Promise> { @@ -131,175 +85,251 @@ class RslintController implements StackController { this.#snapshot = context.detection; this.#logger = new Logger(context.output); + this.startRuntimeManager(); + this.#subscriptions.push( context.onDidChangeDetection((snapshot) => { - const changedModes = new Set(); - for (const entry of snapshot.foldersFor('rslint')) { - const key = workspaceRootKey(entry.folder); - const mode = entry.stacks.rslint.mode; - if (mode !== undefined && this.#folderModes.get(key) !== mode) { - changedModes.add(key); - } - } this.#snapshot = snapshot; - this.reconcileFolders({ added: [], removed: [] }, changedModes); + this.pruneDepartedFolders(); // A detection pass fires on config topology and lockfile changes — - // exactly the moments a previously failed root (missing binary, - // uninstalled dependencies) may have become startable. - this.#coordinator?.retryFailedRoots(); + // exactly the moments a document's core may have appeared, moved or + // changed ownership. This replaces the coordinator's `retryFailedRoots`. + this.reconcileOpenDocuments('detection change'); + }), + vscode.workspace.onDidChangeWorkspaceFolders(() => { + this.pruneDepartedFolders(); + this.reconcileOpenDocuments('workspace-folder change'); }), - vscode.workspace.onDidChangeWorkspaceFolders((event) => { - this.reconcileFolders(event); + vscode.workspace.onDidOpenTextDocument((document) => { + const manager = this.#runtimeManager; + if (!manager) return; + void manager.reconcile(document).catch((error: unknown) => { + this.#logger?.error( + `Failed to open ${document.uri} with Rslint`, + error, + ); + }); }), + vscode.workspace.onDidCloseTextDocument((document) => { + this.#runtimeManager?.documentClosed(document); + }), + ); + + const topologyWatcher = + vscode.workspace.createFileSystemWatcher(CORE_TOPOLOGY_GLOB); + const onTopologyChange = () => { + this.reconcileOpenDocuments('dependency change'); + }; + this.#subscriptions.push( + topologyWatcher, + topologyWatcher.onDidCreate(onTopologyChange), + topologyWatcher.onDidChange(onTopologyChange), + topologyWatcher.onDidDelete(onTopologyChange), ); - this.startCoordinator(); + this.publishStatus(); + // Adaptation #1: activation must not wait for a language server. Documents + // already open are reconciled in the background; failures surface per + // folder through the status reporter. + this.#runtimeManager?.initialize(vscode.workspace.textDocuments); return this.buildExports(); } /** * Published through the extension's public exports channel * (`RstackExtensionExports.whenStackActive('rslint')`). The E2E harness uses - * it as the "the shell registered the lint stack" signal — the upstream - * suites relied on `extension.activate()` resolving only once a server root - * was ready, a contract the shell no longer provides (the shell-activation - * adaptation). The folder-state snapshot is exposed for assertions and - * debugging; it is not a stable API. + * it as the "the shell registered the lint stack" signal: it resolves once + * this controller has registered its detected folders and scheduled the open + * documents — a folder holding no runtime yet (idle) counts as active, since + * a runtime only exists while a document uses one. Suites that need a live + * server open a document and await diagnostics. + * + * The state snapshots are exposed for assertions and debugging; they are not + * a stable API. */ private buildExports(): Record { return { stackId: this.id, getFolderStates: (): ReadonlyMap => new Map( - [...this.#folderStates].map(([key, value]) => [key, value.state]), + this.detectedFolders().map((entry) => [ + folderKeyOf(entry.folder), + this.folderState(folderKeyOf(entry.folder)), + ]), + ), + /** Live Lint runtimes across all folders, by runtime key. */ + getRuntimeStates: (): ReadonlyMap => + new Map( + [...this.#folderStates.values()].flatMap((states) => [ + ...states.runtimes, + ]), ), }; } - /** The workspace folders detection lit up for Rslint. */ - private detectedFolders(): vscode.WorkspaceFolder[] { - return (this.#snapshot?.foldersFor('rslint') ?? []).map( - (entry) => entry.folder, - ); - } - - private modeFor(folder: vscode.WorkspaceFolder): RslintMode { - const mode = this.#snapshot?.forFolder(folder)?.stacks.rslint.mode; - if (mode === undefined) { - throw new Error( - `Rslint mode is unavailable for ${folder.uri.toString()}`, - ); - } - return mode; - } - - private startCoordinator(): void { + private startRuntimeManager(): void { const context = this.#context; const logger = this.#logger; if (!context || !logger || this.#disposed) { return; } const router = new WorkspaceDocumentRouter(); - const coordinator = new WorkspaceRslintCoordinator( + this.#runtimeManager = new RuntimeManager( router, - (workspaceFolder, rootKey) => - new Rslint({ - rootKey, - workspaceFolder, - outputChannel: context.output, - // The extension is capped at four output channels, so the - // LSP trace shares the stack's channel instead of opening a fifth. - lspOutputChannel: context.output, - router, - logger: logger.forScope(workspaceFolder.name), - reportStatus: (state) => { - this.setFolderState(rootKey, workspaceFolder.name, state); - }, - getMode: () => this.modeFor(workspaceFolder), - }), + new CoreResolver(), + (resolved) => this.createRuntime(router, context, logger, resolved), logger, + { + folderMode: (folder) => this.folderMode(folder), + onDocumentFailure: ({ document, workspaceFolder, error, resolved }) => { + // Last-good semantics: the document keeps whatever runtime it had. + // The failure is still the folder's worst news, so it is folded in + // beside the runtimes rather than shown as a toast. A start failure + // outlives its (already closed) runtime here, so it names the core. + const status = statusForRslintStartFailure(error); + this.setState( + folderKeyOf(workspaceFolder), + 'failures', + document.uri.toString(), + resolved + ? attributeToCore(status, resolved.installation.packageDirectory) + : status, + ); + }, + onDocumentSettled: (document) => { + this.clearState('failures', document.uri.toString()); + }, + onRuntimeClosed: (resolved) => { + this.clearState('runtimes', resolved.key); + }, + }, ); - this.#coordinator = coordinator; - - const folders = this.detectedFolders(); - for (const folder of folders) { - const key = workspaceRootKey(folder); - this.#folderModes.set(key, this.modeFor(folder)); - this.setFolderState(key, folder.name, { - kind: 'starting', - }); - } + } - // 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. - void coordinator.initialize(folders).catch((error: unknown) => { - if (coordinator !== this.#coordinator) { - return; - } - logger.error('No Rslint workspace root started', error); + private createRuntime( + router: WorkspaceDocumentRouter, + context: StackContext, + logger: Logger, + resolved: ResolvedCoreRuntime, + ): Rslint { + const { workspaceFolder, installation } = resolved; + const folderKey = folderKeyOf(workspaceFolder); + this.setState(folderKey, 'runtimes', resolved.key, { kind: 'starting' }); + return new Rslint({ + rootKey: resolved.key, + workspaceFolder, + installation, + outputChannel: context.output, + // The extension is capped at four output channels, so the LSP trace + // shares the stack's channel instead of opening a fifth. + lspOutputChannel: context.output, + router, + logger: logger.forScope( + `${workspaceFolder.name} @rslint/core ${installation.version ?? 'unknown'}`, + ), + reportStatus: (state) => { + this.setState( + folderKey, + 'runtimes', + resolved.key, + attributeToCore(state, installation.packageDirectory), + ); + }, }); } - private reconcileFolders( - event: vscode.WorkspaceFoldersChangeEvent, - forceReplace: ReadonlySet = new Set(), - ): void { - const coordinator = this.#coordinator; - if (!coordinator || this.#disposed) { - return; - } - const folders = this.detectedFolders(); - const keys = new Set(folders.map(workspaceRootKey)); - for (const key of [...this.#folderStates.keys()]) { - if (!keys.has(key)) { - this.#folderStates.delete(key); - this.#folderModes.delete(key); - } - } - for (const folder of folders) { - const key = workspaceRootKey(folder); - this.#folderModes.set(key, this.modeFor(folder)); - if (!this.#folderStates.has(key)) { - this.setFolderState(key, folder.name, { kind: 'starting' }); - } + private detectedFolders() { + return (this.#snapshot?.foldersFor('rslint') ?? []).filter( + (entry) => entry.stacks.rslint.mode !== undefined, + ); + } + + private folderMode(folder: vscode.WorkspaceFolder): RslintMode | undefined { + return this.#snapshot?.forFolder(folder)?.stacks.rslint.mode; + } + + /** Drops the states of folders detection no longer lights. */ + private pruneDepartedFolders(): void { + if (this.#disposed) return; + const detected = new Set( + this.detectedFolders().map((entry) => folderKeyOf(entry.folder)), + ); + for (const folderKey of [...this.#folderStates.keys()]) { + if (!detected.has(folderKey)) this.#folderStates.delete(folderKey); } this.publishStatus(); - coordinator.handleWorkspaceFoldersChanged(event, folders, forceReplace); } - private setFolderState( - rootKey: string, - name: string, + private reconcileOpenDocuments(reason: string): void { + const manager = this.#runtimeManager; + if (!manager || this.#disposed) return; + manager.clearResolutionCache(); + void manager.reconcileOpenDocuments().catch((error: unknown) => { + this.#logger?.error( + `Failed to reconcile Rslint runtimes after ${reason}`, + error, + ); + }); + } + + private setState( + folderKey: string, + bucket: keyof FolderStates, + key: string, state: StackState, ): void { - if (this.#disposed) { - return; + if (this.#disposed) return; + let states = this.#folderStates.get(folderKey); + if (!states) { + states = { runtimes: new Map(), failures: new Map() }; + this.#folderStates.set(folderKey, states); } - this.#folderStates.set(rootKey, { name, state }); + states[bucket].set(key, state); this.publishStatus(); } + private clearState(bucket: keyof FolderStates, key: string): void { + if (this.#disposed) return; + for (const states of this.#folderStates.values()) { + if (states[bucket].delete(key)) { + this.publishStatus(); + return; + } + } + } + + private folderState(folderKey: string): StackState { + const states = this.#folderStates.get(folderKey); + return foldRslintFolderState( + states ? [...states.runtimes.values(), ...states.failures.values()] : [], + ); + } + private publishStatus(): void { const context = this.#context; if (!context || this.#disposed) { return; } context.status.report( - aggregateFolderStates([...this.#folderStates.values()]), + aggregateFolderStates( + this.detectedFolders().map((entry) => ({ + name: entry.folder.name, + state: this.folderState(folderKeyOf(entry.folder)), + })), + ), ); } - private async closeCoordinator(): Promise { - const coordinator = this.#coordinator; - this.#coordinator = undefined; - if (!coordinator) { + private async closeRuntimeManager(): Promise { + const manager = this.#runtimeManager; + this.#runtimeManager = undefined; + if (!manager) { return; } try { - await coordinator.close(); + await manager.close(); } catch (error) { - this.#logger?.error('Failed to close the Rslint coordinator', error); + this.#logger?.error('Failed to close the Rslint runtime manager', error); } } @@ -308,9 +338,8 @@ class RslintController implements StackController { for (const subscription of this.#subscriptions.splice(0)) { subscription.dispose(); } - await this.closeCoordinator(); + await this.closeRuntimeManager(); this.#folderStates.clear(); - this.#folderModes.clear(); this.#logger = undefined; this.#context = undefined; this.#snapshot = undefined; diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index ab06a0c..ad32e8c 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -61,6 +61,9 @@ function readPackageLocation( `${packageJsonPath} is not a valid ${packageName} package`, ); } + // Upstream's CoreResolver refuses a package without a version string. Here + // an unknown version soft-passes the floor instead (`shared/versionCheck.ts`, + // the toolchain-wide policy), so only the name is a hard requirement. return { directory: path.dirname(packageJsonPath), version: typeof pkg.version === 'string' ? pkg.version : undefined, @@ -104,6 +107,12 @@ export interface ResolveRslintOptions { readonly folderRoot: string; readonly mode: RslintMode; readonly corePath?: string; + /** + * Where the native walk-up starts (per-document resolution, rslint #1617); + * defaults to the folder root. Bridged mode starts at rstack's directory + * instead — one config choice per folder, ADR 0003. + */ + readonly documentDirectory?: string; } /** Resolves the same package chain `rs lint` uses without loading project code. */ @@ -111,6 +120,7 @@ export function resolveRslint({ folderRoot, mode, corePath, + documentDirectory, }: ResolveRslintOptions): RslintResolution { let rstack: PackageLocation | undefined; let shimPath: string | undefined; @@ -133,7 +143,7 @@ export function resolveRslint({ ? resolveConfiguredCore(folderRoot, configuredCorePath) : resolveInstalledPackage( '@rslint/core', - rstack?.directory ?? folderRoot, + rstack?.directory ?? documentDirectory ?? folderRoot, 'missing-core', ); diff --git a/packages/vscode/src/stacks/lint/status.ts b/packages/vscode/src/stacks/lint/status.ts index fffcf37..0f99f4b 100644 --- a/packages/vscode/src/stacks/lint/status.ts +++ b/packages/vscode/src/stacks/lint/status.ts @@ -28,7 +28,154 @@ export const statusForRslintStartFailure = (error: unknown): StackState => { }; }; +/** + * Names the Rslint core a runtime's failure came from. With several runtimes + * in one folder, "the language server stopped" alone does not say which core + * to look at; the resolver's own messages already carry the directory, so it + * is appended only when missing. + */ +export const attributeToCore = ( + state: StackState, + coreDirectory: string, +): StackState => { + if (state.kind !== 'crashed' && state.kind !== 'version-mismatch') { + return state; + } + if (state.detail.includes(coreDirectory)) return state; + return { kind: state.kind, detail: `${state.detail} (${coreDirectory})` }; +}; + export const runningRslintStatus = (advisory?: string): StackState => advisory === undefined ? { kind: 'running' } : { kind: 'version-mismatch', detail: advisory }; + +/** A detected folder with no Lint runtime: `running` plus a detail, never a new kind (AGENTS.md, lint gotcha). */ +const RSLINT_IDLE_DETAIL = 'idle'; + +/** + * Complete on purpose: a new state cannot be added without ranking itself, so + * nothing silently falls through to `running`. `disabled` outranks `running` + * as in the fmt stack's table: at folder level it only ever means "no `rstack` + * installed, this bridged folder will never lint", a fact worth showing over a + * healthy runtime or sibling folder — unlike the shell's kill switch. + */ +const STATE_RANK: Readonly> = { + crashed: 5, + 'version-mismatch': 4, + disabled: 3, + starting: 2, + running: 1, + 'not-detected': 0, +}; + +const detailOf = (state: StackState): string | undefined => { + switch (state.kind) { + case 'crashed': + case 'version-mismatch': + case 'starting': + case 'running': + return state.detail; + case 'disabled': + return state.reason; + case 'not-detected': + return undefined; + } +}; + +const worstKind = (states: readonly StackState[]): StackState['kind'] => + states.reduce( + (worst, state) => + STATE_RANK[state.kind] > STATE_RANK[worst] ? state.kind : worst, + 'not-detected', + ); + +const joinDetails = ( + details: readonly (string | undefined)[], +): string | undefined => { + const unique = [ + ...new Set( + details.filter((detail): detail is string => detail !== undefined), + ), + ]; + return unique.length > 0 ? unique.join(' | ') : undefined; +}; + +/** + * Folds one workspace folder's Lint runtimes (plus any document whose core + * resolution failed) into that folder's state. + * + * Worst-of, so a healthy runtime never masks a failing one: a folder running + * two cores where one is below the floor is a folder the user has to fix. + */ +export const foldRslintFolderState = ( + states: readonly StackState[], +): StackState => { + if (states.length === 0) { + return { kind: 'running', detail: RSLINT_IDLE_DETAIL }; + } + const kind = worstKind(states); + return withDetail( + kind, + joinDetails(states.filter((state) => state.kind === kind).map(detailOf)), + ); +}; + +export interface RslintFolderStatus { + readonly name: string; + readonly state: StackState; +} + +/** + * Folds every workspace folder's state into the one state the status bar shows + * for the Rslint stack. The worst state wins, and the detail names the folders + * it came from — with multiple roots, "crashed" without a folder name is not + * actionable. + */ +export const aggregateFolderStates = ( + statuses: readonly RslintFolderStatus[], +): StackState => { + if (statuses.length === 0) { + return { kind: 'starting' }; + } + const kind = worstKind(statuses.map((entry) => entry.state)); + const multiRoot = statuses.length > 1; + return withDetail( + kind, + joinDetails( + statuses + .filter((entry) => entry.state.kind === kind) + .map((entry) => { + const detail = detailOf(entry.state); + if (!detail) return multiRoot ? entry.name : undefined; + return multiRoot ? `${entry.name}: ${detail}` : detail; + }), + ), + ); +}; + +const withDetail = ( + kind: StackState['kind'], + detail: string | undefined, +): StackState => { + switch (kind) { + case 'crashed': + return { + kind: 'crashed', + detail: detail ?? 'the language server failed', + }; + case 'version-mismatch': + return { + kind: 'version-mismatch', + detail: detail ?? 'unsupported @rslint/core version', + }; + case 'starting': + return { kind: 'starting', detail }; + case 'running': + return { kind: 'running', detail }; + case 'disabled': + return { kind: 'disabled', reason: detail }; + case 'not-detected': + return { kind: 'not-detected' }; + } +}; diff --git a/packages/vscode/tests/stacks/lint/coreResolver.test.ts b/packages/vscode/tests/stacks/lint/coreResolver.test.ts new file mode 100644 index 0000000..cd2e802 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/coreResolver.test.ts @@ -0,0 +1,126 @@ +/** + * What `CoreResolver` adds on top of `resolveRslint` (`resolution.test.ts` + * owns the chain walk itself): the physical identity of a core and the + * runtime key built from it — the two things rslint #1617 turned into + * lifecycle decisions. Pinned here instead of in an Electron E2E run because + * the resolver imports only *types* from `vscode`. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from '@rstest/core'; +import type { TextDocument, WorkspaceFolder } from 'vscode'; +import { CoreResolver } from '../../../src/stacks/lint/CoreResolver'; +import { RslintVersionMismatchError } from '../../../src/stacks/lint/status'; +import { + installPackage, + installShim, + removeTemporaryDirectories, + temporaryDirectory, + writePackage, +} from './packageFixtures'; + +function folderOf(root: string, name = 'fixture'): WorkspaceFolder { + return { + name, + index: 0, + uri: { toString: () => `file://${root}`, fsPath: root }, + } as unknown as WorkspaceFolder; +} + +function documentAt(filePath: string): Pick { + return { uri: { fsPath: filePath } } as unknown as Pick; +} + +afterEach(removeTemporaryDirectories); + +describe('CoreResolver', () => { + it('identifies a core by its real path, so a symlink is the copy it points at', async () => { + const root = temporaryDirectory(); + const store = writePackage( + path.join(root, 'store', 'core-0.8.0'), + '@rslint/core', + '0.8.0', + ); + const link = path.join(root, 'app', 'node_modules', '@rslint', 'core'); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(store, link, 'dir'); + const app = path.join(root, 'app'); + + const resolved = await new CoreResolver().resolve( + documentAt(path.join(app, 'src', 'index.ts')), + folderOf(app), + { mode: 'native' }, + ); + + expect(resolved.installation.identity).toBe(store); + }); + + it('keeps two same-version copies apart: one folder, two Lint runtimes', async () => { + const root = temporaryDirectory(); + const left = path.join(root, 'left'); + const right = path.join(root, 'right'); + installPackage(left, '@rslint/core', '0.8.0'); + installPackage(right, '@rslint/core', '0.8.0'); + const folder = folderOf(root); + const resolver = new CoreResolver(); + + const fromLeft = await resolver.resolve( + documentAt(path.join(left, 'index.ts')), + folder, + { mode: 'native' }, + ); + const fromRight = await resolver.resolve( + documentAt(path.join(right, 'index.ts')), + folder, + { mode: 'native' }, + ); + + expect(fromLeft.installation.version).toBe(fromRight.installation.version); + expect(fromLeft.installation.identity).not.toBe( + fromRight.installation.identity, + ); + expect(fromLeft.key).not.toBe(fromRight.key); + expect(fromLeft.key.startsWith(`${folder.uri.toString()}\0`)).toBe(true); + }); + + it('gives one folder different runtime keys for its native and bridged ownership of the same core', async () => { + // Protocol 2 locks `configPath` for the server's lifetime (ADR 0003), so a + // native <-> bridged flip must replace the runtime even when both modes + // land on the same physical core. Upstream, which has no bridge, keys on + // the core alone. + const root = temporaryDirectory(); + installPackage(root, '@rslint/core', '0.8.0'); + const rstack = installPackage(root, 'rstack', '0.6.1'); + const shimPath = installShim(rstack); + const folder = folderOf(root); + const resolver = new CoreResolver(); + const document = documentAt(path.join(root, 'src', 'index.ts')); + + const native = await resolver.resolve(document, folder, { + mode: 'native', + }); + const bridged = await resolver.resolve(document, folder, { + mode: 'bridged', + }); + + expect(bridged.installation.identity).toBe(native.installation.identity); + expect(bridged.key).not.toBe(native.key); + expect(bridged.installation.shimPath).toBe(shimPath); + }); + + it('refuses a core below the floor and names its directory', async () => { + // With several cores in one folder, "0.7.3 is not supported" alone would + // not say which one to fix. + const root = temporaryDirectory(); + const core = installPackage(root, '@rslint/core', '0.7.3'); + const resolve = () => + new CoreResolver().resolve( + documentAt(path.join(root, 'src', 'index.ts')), + folderOf(root), + { mode: 'native' }, + ); + + await expect(resolve()).rejects.toThrow(RslintVersionMismatchError); + await expect(resolve()).rejects.toThrow(core); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/packageFixtures.ts b/packages/vscode/tests/stacks/lint/packageFixtures.ts new file mode 100644 index 0000000..2e9044f --- /dev/null +++ b/packages/vscode/tests/stacks/lint/packageFixtures.ts @@ -0,0 +1,56 @@ +/** + * Throwaway `node_modules` layouts for the lint resolution tests. Every path + * handed back is a real path, so identity assertions do not trip over + * `/tmp` → `/private/tmp` on macOS. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const temporaryDirectories: string[] = []; + +export function temporaryDirectory(prefix = 'rslint-'): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + temporaryDirectories.push(directory); + return fs.realpathSync(directory); +} + +/** Call from `afterEach`. */ +export function removeTemporaryDirectories(): void { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +export function writePackage( + directory: string, + name: 'rstack' | '@rslint/core', + version: string, +): string { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ name, version }), + ); + return directory; +} + +export function installPackage( + root: string, + name: 'rstack' | '@rslint/core', + version: string, +): string { + return writePackage( + path.join(root, 'node_modules', ...name.split('/')), + name, + version, + ); +} + +/** rstack's published `dist/rslintConfig.js`, the bridged folder's shim. */ +export function installShim(rstackDirectory: string): string { + const shimPath = path.join(rstackDirectory, 'dist', 'rslintConfig.js'); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(shimPath, 'module.exports = [];'); + return shimPath; +} diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts index b09fc85..216e293 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -1,49 +1,18 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; import { resolveRslint, RslintResolutionError, } from '../../../src/stacks/lint/resolution'; +import { + installPackage, + installShim, + removeTemporaryDirectories, + temporaryDirectory, + writePackage, +} from './packageFixtures'; -const temporaryDirectories: string[] = []; - -function temporaryDirectory(): string { - const directory = fs.mkdtempSync( - path.join(os.tmpdir(), 'rslint-resolution-'), - ); - temporaryDirectories.push(directory); - return directory; -} - -function writePackage( - directory: string, - name: 'rstack' | '@rslint/core', - version: string, -): void { - fs.mkdirSync(directory, { recursive: true }); - fs.writeFileSync( - path.join(directory, 'package.json'), - JSON.stringify({ name, version }), - ); -} - -function installPackage( - root: string, - name: 'rstack' | '@rslint/core', - version: string, -): string { - const directory = path.join(root, 'node_modules', ...name.split('/')); - writePackage(directory, name, version); - return fs.realpathSync(directory); -} - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - fs.rmSync(directory, { recursive: true, force: true }); - } -}); +afterEach(removeTemporaryDirectories); describe('resolveRslint', () => { it('resolves a native folder directly from its @rslint/core installation', () => { @@ -61,9 +30,7 @@ describe('resolveRslint', () => { const root = temporaryDirectory(); const rstackDir = installPackage(root, 'rstack', '0.6.1'); const coreDir = installPackage(rstackDir, '@rslint/core', '0.8.0'); - const shimPath = path.join(rstackDir, 'dist', 'rslintConfig.js'); - fs.mkdirSync(path.dirname(shimPath)); - fs.writeFileSync(shimPath, 'export default [];'); + const shimPath = installShim(rstackDir); expect(resolveRslint({ folderRoot: root, mode: 'bridged' })).toEqual({ mode: 'bridged', @@ -78,12 +45,12 @@ describe('resolveRslint', () => { it('uses corePath for the core hop in both modes', () => { const root = temporaryDirectory(); const rstackDir = installPackage(root, 'rstack', '0.6.1'); - const shimPath = path.join(rstackDir, 'dist', 'rslintConfig.js'); - fs.mkdirSync(path.dirname(shimPath)); - fs.writeFileSync(shimPath, 'export default [];'); - const customCorePath = path.join(root, 'custom-core'); - writePackage(customCorePath, '@rslint/core', '0.8.1'); - const customCore = fs.realpathSync(customCorePath); + installShim(rstackDir); + const customCore = writePackage( + path.join(root, 'custom-core'), + '@rslint/core', + '0.8.1', + ); for (const mode of ['native', 'bridged'] as const) { const resolution = resolveRslint({ @@ -96,6 +63,49 @@ describe('resolveRslint', () => { } }); + it('starts the native walk at the document directory, not the folder root', () => { + // Per-document core resolution (rslint #1617): a file in a nested package + // lints with that package's copy, exactly as `rs lint` run there would. + const root = temporaryDirectory(); + installPackage(root, '@rslint/core', '0.8.0'); + const nested = path.join(root, 'packages', 'app'); + const nestedCore = installPackage(nested, '@rslint/core', '0.8.1'); + + expect( + resolveRslint({ + folderRoot: root, + mode: 'native', + documentDirectory: path.join(nested, 'src'), + }), + ).toEqual({ mode: 'native', coreDir: nestedCore, coreVersion: '0.8.1' }); + }); + + it('ignores the document directory for a bridged folder', () => { + // A bridged folder is one config choice for the whole folder, so it is + // always exactly one core: rstack's own. + const root = temporaryDirectory(); + const rstackDir = installPackage(root, 'rstack', '0.6.1'); + const coreDir = installPackage(rstackDir, '@rslint/core', '0.8.0'); + const shimPath = installShim(rstackDir); + const nested = path.join(root, 'packages', 'app'); + installPackage(nested, '@rslint/core', '0.8.1'); + + expect( + resolveRslint({ + folderRoot: root, + mode: 'bridged', + documentDirectory: path.join(nested, 'src'), + }), + ).toEqual({ + mode: 'bridged', + coreDir, + coreVersion: '0.8.0', + rstackDir, + rstackVersion: '0.6.1', + shimPath, + }); + }); + it('reports a missing rstack shim before resolving its core', () => { const root = temporaryDirectory(); installPackage(root, 'rstack', '0.6.1'); diff --git a/packages/vscode/tests/stacks/lint/status.test.ts b/packages/vscode/tests/stacks/lint/status.test.ts index d8a5c25..8db589e 100644 --- a/packages/vscode/tests/stacks/lint/status.test.ts +++ b/packages/vscode/tests/stacks/lint/status.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from '@rstest/core'; import { RslintResolutionError } from '../../../src/stacks/lint/resolution'; import { + aggregateFolderStates, + attributeToCore, + foldRslintFolderState, RslintVersionMismatchError, runningRslintStatus, statusForRslintStartFailure, @@ -49,3 +52,114 @@ describe('Rslint status classification', () => { }); }); }); + +describe('attributeToCore', () => { + const core = '/w/packages/a/node_modules/@rslint/core'; + + it('names the core a runtime failure came from', () => { + expect( + attributeToCore( + { kind: 'crashed', detail: 'the Rslint language server stopped' }, + core, + ), + ).toEqual({ + kind: 'crashed', + detail: `the Rslint language server stopped (${core})`, + }); + }); + + it('leaves a detail that already names the core, and healthy states, alone', () => { + const mismatch = { + kind: 'version-mismatch', + detail: `@rslint/core 0.7.3 is not supported (${core})`, + } as const; + expect(attributeToCore(mismatch, core)).toBe(mismatch); + const running = { kind: 'running' } as const; + expect(attributeToCore(running, core)).toBe(running); + }); +}); + +describe('foldRslintFolderState', () => { + it('reports a detected folder with no runtime as running/idle', () => { + // Zero runtimes is the resting state since rslint #1617: a Lint runtime + // exists only while an open document uses it. The folder is live, so the + // kind stays `running` — "idle" is a detail, not a state of health. + expect(foldRslintFolderState([])).toEqual({ + kind: 'running', + detail: 'idle', + }); + }); + + it('never lets a healthy runtime mask a failing one in the same folder', () => { + expect( + foldRslintFolderState([ + { kind: 'running' }, + { kind: 'version-mismatch', detail: '@rslint/core 0.7.3 (/a/core)' }, + { kind: 'running' }, + ]), + ).toEqual({ + kind: 'version-mismatch', + detail: '@rslint/core 0.7.3 (/a/core)', + }); + }); + + it('joins every detail sharing the worst kind, once each', () => { + expect( + foldRslintFolderState([ + { kind: 'crashed', detail: 'left died' }, + { kind: 'crashed', detail: 'right died' }, + { kind: 'crashed', detail: 'left died' }, + { kind: 'starting' }, + ]), + ).toEqual({ kind: 'crashed', detail: 'left died | right died' }); + }); + + it('folds a failed resolution beside the runtime the document kept', () => { + // Last-good semantics: the runtime stays up, and the failure is still the + // folder's worst news — reported as status, never as a toast. + expect( + foldRslintFolderState([ + { kind: 'running' }, + { kind: 'crashed', detail: 'Could not resolve @rslint/core from /b' }, + ]), + ).toEqual({ + kind: 'crashed', + detail: 'Could not resolve @rslint/core from /b', + }); + }); + + it('lets a bridged folder that lost rstack outrank its live runtime', () => { + // Inside a folder `disabled` only ever means "missing rstack" — a + // failure the user must see, not the shell's kill switch — so, unlike the + // cross-folder rank, it beats a healthy runtime. + expect( + foldRslintFolderState([ + { kind: 'running' }, + { kind: 'disabled', reason: 'rstack is not installed in /w' }, + ]), + ).toEqual({ kind: 'disabled', reason: 'rstack is not installed in /w' }); + }); +}); + +describe('aggregateFolderStates', () => { + it('names the folder a failure came from in a multi-root workspace', () => { + expect( + aggregateFolderStates([ + { name: 'app', state: { kind: 'running', detail: 'idle' } }, + { name: 'lib', state: { kind: 'crashed', detail: 'worker exited' } }, + ]), + ).toEqual({ kind: 'crashed', detail: 'lib: worker exited' }); + }); + + it('keeps a single-root detail unprefixed', () => { + expect( + aggregateFolderStates([ + { name: 'app', state: { kind: 'running', detail: 'idle' } }, + ]), + ).toEqual({ kind: 'running', detail: 'idle' }); + }); + + it('reports starting before any folder registered', () => { + expect(aggregateFolderStates([])).toEqual({ kind: 'starting' }); + }); +});