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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ 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 ten ways (the "adaptations" below). When syncing upstream, preserve them. An eleventh 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`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do.
- **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`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. `stacks/test` tracks web-infra-dev/rstest `packages/vscode` through **8f945491** (#1729 — public programmatic API), **d2812754** (#1804 — quoted exact file filters), **99be33e8** (#1805 — public test listing), **88cd5f6d** (#1806 — browser projects in watch), and **994b77e0** (#1807 — public watch API). The earlier targeted lifecycle fix from **d82db4fc31a61ee74b2a74917f14a458e1bca419** is subsumed by this sync; our failed-project retry and worker-cleanup behavior remains ahead of upstream.
- **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. (5) `RuntimeManager` retires a stopped client even when its resolved key is unchanged. The existing closing barrier and pending-use adoption share one replacement across documents; running and starting clients remain untouched (`tests/stacks/lint/runtimeManager.test.ts`).

- **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry failed projects, including real config errors, while preserving single-flight loading and worker cleanup.

## The ten adaptations

1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker.
Expand Down Expand Up @@ -50,11 +48,12 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
- **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics.
- The lint worker is deliberately vscode-free so it can move upstream whole. It takes explicit `--core` / `--config` native paths, writes logs only to stderr because stdout is LSP, and owns the Go child plus config/plugin lifecycles. Config edits use `rslint/configRefresh` with the same pinned path; a native ↔ bridged ownership change replaces the whole folder runtime because the supported config protocols lock 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. Bridged projects resolve `@rstest/core` from the resolved rstack package directory, mirroring lint, so rstack's dependency remains visible under isolated installs. Never re-implement rstack config semantics in the extension.
- Rstest's upstream VS Code extension deep-imports `quoteFilter` from core to mark exact file filters. The published package does not export that helper, so our copy lives in `stacks/test/vendored/coreInternals.ts` beside the other core internals; keep it byte-identical when syncing filter behavior.
- The fmt stack is an LSP client: one `rs fmt --lsp` server per detected workspace folder, spawned at the **folder root** even when a deeper `rstack.config.*` exists. Deepest-config-wins was removed deliberately — `rs fmt` loads one config from its cwd with no upward walk, so anchoring deeper made the editor disagree with `rs fmt` in a terminal; a subproject that needs its own fmt config becomes its own workspace folder. The stack registers **no** `DocumentFormattingEditProvider`: the client registers the provider from the server's `documentFormattingProvider` capability, and adding one by hand would double-register. A config create/change/delete **restarts** the owning folder's server (the server caches its config for its process lifetime and has no config-change message), which is also why the stack watches `RSTACK_CONFIG_GLOB` itself instead of relying on detection — a detection signature records which config files exist, not their contents. A detection pass keeps healthy servers and restarts failed ones in place (`isFailedFmtState`) — lockfile events notify even when the folder set is unchanged, precisely so a completed install or upgrade is retried without a manual restart. There is no stdin fallback below `SUPPORT_MATRIX.rstack`; that is a version gate, not an omission. **Nested workspace folders are a documented limitation, by decision**: when a folder and its subdirectory are both workspace folders and both detect fmt, the parent's per-folder selector also matches the nested folder's files, and which server VS Code hands the request to is not defined — the supported shape is subprojects as _sibling_ workspace folders (or only the subproject opened), not parent-plus-child. Routing (lint's `WorkspaceDocumentRouter` shape) was considered and deferred. Why all of it: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`.
- fmt importing `stacks/lint/LanguageServerProcessOwner.ts` is not a refactor across the copies: that file has no lint imports and no lint behaviour, it only owns the native children of one language client — including the ones vscode-languageclient's automatic restart creates, which is exactly the leak an ad-hoc copy would reintroduce. Lint's `ManagedLanguageClient` is _restated_ in `stacks/fmt/index.ts` instead, because importing it from `Rslint.ts` would couple fmt to the lint stack's runtime graph. Keep that line where it is: shared process ownership yes, shared stack runtime no.
- The VSIX is platform-targeted for exactly one reason: the test stack's AST collection loads a native parser binding. Do not add another native dependency — it multiplies the release matrix.
- `shared/nodeResolution.ts` takes its shell, its cwd and its notify callback as options instead of importing `vscode` and a stack's `logger` singleton, unlike its neighbours. That is not stylistic: it keeps `resolveUserNode` a pure decision table over its inputs, which is what makes the case-by-case unit tests possible without a `vscode` stub. It sat in `stacks/test/` until fmt became the second stack running project code on a User Node runtime — the exact condition its old note named — and moved on that trigger, not before. Its host-scoped preflight memo is reset by the shell's restart pass only when **no** consumer stack (`USER_NODE_STACKS`) survives the pass — a single-stack `rstack.fmt.restart` beside a live Rstest controller deliberately keeps the memo, since the survivor's existing workers were built on that decision; the full `rstack.restart` always clears it. `stacks/fmt/binEntry.ts` and `stacks/fmt/status.ts` are separate pure modules for the same testability reason — `stacks/fmt/index.ts` evaluates `vscode`, and `status.ts` pins the fold's invariant (a healthy sibling folder never masks another folder's failure) in a unit test the single-folder E2E fixtures cannot.
- The uniform Node floor deliberately exceeds `@rstest/core`'s own `engines` (`^20.19.0 || >=22.12.0`) and also mirrors the supported rstack line's `engines.node` (`rstack >= 0.7.0`). Do not specialise the floor per project — that was considered and rejected. Why, and what else was rejected: `docs/adr/0001-node-runtime-selection.md`.
- The uniform Node floor deliberately exceeds `@rstest/core`'s own `engines` (`^20.19.0 || >=22.12.0`) and also mirrors the supported rstack line's `engines.node` (`rstack >= 0.7.6`). Do not specialise the floor per project — that was considered and rejected. Why, and what else was rejected: `docs/adr/0001-node-runtime-selection.md`.
- Bun is not a supported worker runtime (it segfaults running `@rstest/core`). If that is ever revisited, gate it on an explicit setting — never on `bun.lock`, since bun-as-package-manager still runs the `rs` bin through its `#!/usr/bin/env node` shebang.

## Testing
Expand Down
10 changes: 5 additions & 5 deletions packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ A restart re-resolves every binary and package version and respawns every tool p

The project-resolved packages are checked against a support matrix at runtime; a mismatch shows up as the `version mismatch` status bar state.

| Package | Required |
| -------------- | --------- |
| `@rslint/core` | `>=0.8.0` |
| `@rstest/core` | `>=0.6.0` |
| `rstack` | `>=0.7.0` |
| Package | Required |
| -------------- | ---------- |
| `@rslint/core` | `>=0.8.0` |
| `@rstest/core` | `>=0.12.0` |
| `rstack` | `>=0.7.6` |

## Auto-fix on save (Rslint)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"type": "module",
"description": "E2E fixture: rs fmt config imports a package that is not installed.",
"dependencies": {
"rstack": "0.7.4"
"rstack": "0.7.6"
}
}
2 changes: 1 addition & 1 deletion packages/vscode/e2e/fixtures/rstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"description": "E2E fixture: an rstack-cli project whose only config is `rstack.config.ts`, which lights all three stacks.",
"dependencies": {
"rstack": "0.7.4"
"rstack": "0.7.6"
},
"devDependencies": {
"jiti": "^2.0.0"
Expand Down
4 changes: 2 additions & 2 deletions packages/vscode/e2e/fixtures/rstest-ownership/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
"@rstest/core": "0.11.12",
"rstack": "0.7.4"
"@rstest/core": "0.12.0",
"rstack": "0.7.6"
}
}
2 changes: 1 addition & 1 deletion packages/vscode/e2e/fixtures/rstest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"type": "module",
"description": "E2E fixture: a project detected as Rstest only, installed from the npm registry.",
"dependencies": {
"@rstest/core": "0.11.12"
"@rstest/core": "0.12.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"private": true,
"description": "E2E fixture for the ported Rstest suites: upstream `tests/fixtures/workspace-1`, made self-contained on the published `@rstest/core`.",
"dependencies": {
"@rstest/core": "0.11.12"
"@rstest/core": "0.12.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"private": true,
"description": "E2E fixture for the ported Rstest suites: upstream `tests/fixtures/workspace-2`. One install at the root serves both nested projects via the normal node_modules walk-up.",
"dependencies": {
"@rstest/core": "0.11.12"
"@rstest/core": "0.12.0"
}
}
2 changes: 1 addition & 1 deletion packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@
"@rslib/core": "^1.0.0",
"@rslint/core": "^0.9.2",
"@rstackjs/load-config": "^1.0.0",
"@rstest/core": "^0.11.12",
"@rstest/core": "^0.12.0",
"@types/istanbul-lib-report": "^3.0.3",
"@types/mocha": "^10.0.10",
"@types/node": "^22.20.2",
Expand Down
14 changes: 8 additions & 6 deletions packages/vscode/src/shared/versionCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { readPackageJson } from './packageResolve';
*
* Launch floors (verified against npm):
* - `@rslint/core >= 0.8.0` — explicit protocol-2 config selection.
* - `@rstest/core >= 0.6.0` — the existing `MIN_CORE_VERSION` upstream.
* - `rstack >= 0.7.0` — the first release whose lint shim sets `basePath` and
* itself pins `@rslint/core` 0.9.0, preserving project-relative config
* paths (#431).
* - `@rstest/core >= 0.12.0` — the first release exporting the public
* `@rstest/core/api` createRstest instance API; older cores lack it.
* - `rstack >= 0.7.6` — the first release pinning `@rstest/core ~0.12.0`
* (https://github.com/rstackjs/rstack-cli/releases/tag/v0.7.6).
* Bridged `rstack.config.*` projects resolve core from the rstack package
* directory, so earlier releases carry a core below the `@rstest/core` floor.
*
* The rstack floor is **uniform across consumers by decision**: lint, Rstest
* and fmt all check the same entry, so "which rstack does the extension
Expand All @@ -21,8 +23,8 @@ import { readPackageJson } from './packageResolve';
*/
export const SUPPORT_MATRIX = {
'@rslint/core': '>=0.8.0',
'@rstest/core': '>=0.6.0',
rstack: '>=0.7.0',
'@rstest/core': '>=0.12.0',
Comment thread
fi3ework marked this conversation as resolved.
rstack: '>=0.7.6',
} as const;

export type SupportedPackage = keyof typeof SUPPORT_MATRIX;
Expand Down
28 changes: 15 additions & 13 deletions packages/vscode/src/stacks/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import { logUnlessReported } from './coreResolution';
import { RstestDiagnostics } from './diagnostics';
import { TestErrorStore, testMessageText } from './errorStore';
import { logger } from './logger';
import { runningWorkers, warmWorkerNodePreflight } from './master';
import {
closeWorkerGracefully,
runningWorkers,
warmWorkerNodePreflight,
} from './master';
import { quoteFilter } from './vendored/coreInternals';
import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution';
import { Project, WorkspaceManager } from './project';
import { routeToOwners } from './runRouting';
Expand Down Expand Up @@ -484,20 +489,18 @@ class Rstest implements vscode.Disposable {
} else if (data instanceof ProjectFolder) {
// grouping folder spans multiple projects; recurse into children
await discoverTests(gatherTestItems(test.children, false));
} else if (data instanceof TestFolder) {
await data.api.runTest({
...commonOptions,
fileFilter: data.uri.fsPath,
});
} else if (data instanceof TestFile) {
} else if (data instanceof TestFile || data instanceof TestFolder) {
await data.api.runTest({
...commonOptions,
fileFilter: data.uri.fsPath,
fileFilter:
data instanceof TestFolder
? data.uri.fsPath
: quoteFilter(data.uri.fsPath),
});
} else if (data instanceof TestCase) {
await data.api.runTest({
...commonOptions,
fileFilter: data.uri.fsPath,
fileFilter: quoteFilter(data.uri.fsPath),
testCaseNamePath: data.parentNames.concat(test.label),
isSuite: data.type === 'suite',
});
Expand Down Expand Up @@ -526,13 +529,12 @@ class Rstest implements vscode.Disposable {
}
};

dispose() {
dispose(): void {
this.disposed = true;
// Upstream's `deactivate()`. A worker is a child process, so it outlives a
// plain `TestController.dispose()` and has to be closed explicitly.
for (const worker of runningWorkers) {
worker.$close();
}
// Start teardown without awaiting it so the shell's serialized restart queue stays responsive.
for (const worker of runningWorkers) void closeWorkerGracefully(worker);
disposeTerminal();
for (const workspace of this.workspaces.values()) {
workspace.dispose();
Expand Down
Loading
Loading