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
3 changes: 2 additions & 1 deletion packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. Upstream's plugin-host failure toast becomes a status verdict (adaptation 7). Rslint LSP tracing shares the **Rstack: Rslint** Output channel to preserve the four-channel cap; see `stacks/lint/index.ts`. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`.
5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream.
6. **Node runtime selection** (lint, test, fmt) — the Node a project-loading child process runs on is a **User Node runtime** chosen by the extension against one uniform floor, never assumed from PATH; the recovery path is the user's own shell, and the dividing line is the **load bound** (terms in GLOSSARY.md; the full rule and rationale in `docs/adr/0001-node-runtime-selection.md`). All three callers — the lint worker, the rstest worker and the `rs fmt --lsp` server — take the decision from the one shared module (`shared/nodeResolution.ts`) and share one escape hatch, the resource-scoped `rstack.nodeExecutable` (`shared/nodeExecutableSetting.ts`); each appends its own consequence to the shared preflight message.
7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — GLOSSARY.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged runtime passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because every supported config protocol locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. Plugin-host startup failures use a separate, unclassified `plugin` verdict on `rstack/rslintConfigDependency`, report `disabled` (a plugin could not be loaded), and recover through the dependency poll.
7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — GLOSSARY.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged runtime passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because every supported config protocol locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. Plugin-host startup failures use a separate, unclassified `plugin` verdict on `rstack/rslintConfigDependency`, report `disabled` (a plugin could not be loaded), and recover through the dependency poll. A bridged runtime whose Rstack config has no `define.lint()` gets a third verdict, `unconfigured` (#93; why it exists: the lint-shim gotcha below). It reports `running` with the detail `no define.lint() in rstack.config.*` and one `info` line per episode — healthy with nothing to lint, like `idle`: not `disabled` (no poll), not `crashed`, no warning. Adding `define.lint()` recovers in the same worker through the bridged `rstack.config.*` watcher, without a restart.
8. **Self-documenting Rslint diagnostics** — client-side providers parse Inline directives into per-rule hover, DocumentLink and underline-decoration affordances (the hover renders `Rslint(rule-id)`, the shape VS Code gives the published diagnostics), and the router enriches today's `[rule-id] message` diagnostics with a derived Rule docs link. No rule metadata or network lookup is bundled (ADR 0004). The hover provider yields whenever the owning language client's resolved capabilities advertise `hoverProvider`; an optional `Rslint.onClosed` hook identity-safely prunes the controller's capability mirror; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively.
9. **Color env parity with the CLI** (test) — upstream hard-codes `FORCE_COLOR: '1'` into the worker's spawn env; ours mirrors the CLI's `getForceColorEnv` (rstest `packages/core/src/utils/logger.ts`) instead (`stacks/test/shared/colorEnv.ts`): the master injects `FORCE_COLOR=1` into the composed spawn env only when neither `FORCE_COLOR` nor `NO_COLOR` is already set (marking the injection with `RSTACK_FORCE_COLOR_INJECTED`), and the worker retracts the marked injection right after config load if the config set `NO_COLOR` — the CLI's own decision point. Otherwise a project whose config sets `process.env.NO_COLOR` (rstack-cli does) hits Node's "'NO_COLOR' env is ignored" warning in every pool process. A user-set `FORCE_COLOR` beside a config-set `NO_COLOR` still warns, exactly as the bare CLI does.

Expand Down Expand Up @@ -53,6 +53,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
- **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one.
- **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics.
- The lint worker is deliberately vscode-free so it can move upstream whole. It takes explicit `--core` / `--config` native paths, writes logs only to stderr because stdout is LSP, and owns the Go child plus config/plugin lifecycles. Config edits use `rslint/configRefresh` with the same pinned path; a document whose ownership flips native ↔ bridged moves to another runtime, because the supported config protocols lock that choice for the process lifetime; documents whose ownership did not change keep their runtime.
- **Lint is the only tool whose Rstack shim refuses an unconfigured config**, so "the three tools are treated uniformly" is knowingly broken for it. rstack's `rslintConfig.js` exits the process without `define.lint()` (deliberately, for `rs lint`; rstack-cli #490/#493), while `rstestConfig.js` returns `{}` without `define.test()`. Detection cannot tell the cases apart — a static probe for `define.lint` is forbidden (above) and wrong under shared config layers (rstack-cli #546) — so a formatting-only root config still lights lint, and only the shim, inside the worker, decides (the `unconfigured` verdict, adaptation 7). The bridged worker therefore builds its `ConfigModuleHost` with a `loadFresh` wrapped by `withProcessExitAsThrow`, so the exit becomes a tagged failed load result instead of a dead worker, Go, and transport. That is a workaround for rstack 0.8.2 as published: once the shim throws or exports a marker instead of exiting, classify that and drop the wrapper. The shim's own `No lint configuration found…` line (rslog, stderr) and Go's `Skipped config …` lines still reach the Rslint Output channel beside our `info` line. Known gap: after a catalog with `define.lint()` has committed, removing it makes Go reject the refresh and keep linting with the last-good catalog while the status says `no define.lint()`; `rs lint` would refuse.
- The test × `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Bridged projects resolve `@rstest/core` from the resolved rstack package directory, mirroring lint, so rstack's dependency remains visible under isolated installs. Never re-implement rstack config semantics in the extension.
- Rstest's upstream VS Code extension deep-imports `quoteFilter` from core to mark exact file filters. The published package does not export that helper, so our copy lives in `stacks/test/vendored/coreInternals.ts` beside the other core internals; keep it byte-identical when syncing filter behavior.
- The fmt stack is an LSP client: one `rs fmt --lsp` server per detected workspace folder, spawned at the **folder root** even when a deeper `rstack.config.*` exists. Deepest-config-wins was removed deliberately — `rs fmt` loads one config from its cwd with no upward walk, so anchoring deeper made the editor disagree with `rs fmt` in a terminal; a subproject that needs its own fmt config becomes its own workspace folder. The stack registers **no** `DocumentFormattingEditProvider`: the client registers the provider from the server's `documentFormattingProvider` capability, and adding one by hand would double-register. A config create/change/delete **restarts** the owning folder's server (the server caches its config for its process lifetime and has no config-change message), which is also why the stack watches `RSTACK_CONFIG_GLOB` itself instead of relying on detection — a detection signature records which config files exist, not their contents. A detection pass keeps healthy servers and restarts failed ones in place (`isFailedFmtState`) — lockfile events notify even when the folder set is unchanged, precisely so a completed install or upgrade is retried without a manual restart. There is no stdin fallback below `SUPPORT_MATRIX.rstack`; that is a version gate, not an omission. **Nested workspace folders are a documented limitation, by decision**: when a folder and its subdirectory are both workspace folders and both detect fmt, the parent's per-folder selector also matches the nested folder's files, and which server VS Code hands the request to is not defined — the supported shape is subprojects as _sibling_ workspace folders (or only the subproject opened), not parent-plus-child. Routing (lint's `WorkspaceDocumentRouter` shape) was considered and deferred. Why all of it: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`.
Expand Down
1 change: 1 addition & 0 deletions packages/vscode/e2e/fixtures/rstack-fmt-only/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26
13 changes: 13 additions & 0 deletions packages/vscode/e2e/fixtures/rstack-fmt-only/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "rstack-editor-fixture-rstack-fmt-only",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "E2E fixture: a formatting-only `rstack.config.ts` (#93).",
"dependencies": {
"rstack": "0.8.2"
},
"devDependencies": {
"jiti": "^2.7.0"
}
}
10 changes: 10 additions & 0 deletions packages/vscode/e2e/fixtures/rstack-fmt-only/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# E2E fixture install config; rationale in packages/vscode/e2e/setupFixtures.mjs.
minimumReleaseAgeExclude:
- rstack
- '@rslint/*'
- '@rstest/core'
- '@rsbuild/core'
- '@rslib/core'
- '@rstackjs/*'
- rsbuild-plugin-dts
- '@rspack/*'
8 changes: 8 additions & 0 deletions packages/vscode/e2e/fixtures/rstack-fmt-only/rstack.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Rstack configuration guide: https://rstack.rs/config
//
// Formatting only: no `define.lint()`. rstack's lint shim exits the process
// when it finds no lint configuration; the editor-shipped lint worker must
// survive that and report a healthy runtime with nothing to lint (#93).
import { define } from 'rstack';

define.fmt({ singleQuote: true });
6 changes: 6 additions & 0 deletions packages/vscode/e2e/fixtures/rstack-fmt-only/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// A lintable issue that must stay silent until `define.lint()` enables
// `no-debugger` in `rstack.config.ts`.
export function trace(value: unknown): unknown {
debugger;
return value;
}
7 changes: 7 additions & 0 deletions packages/vscode/e2e/lint/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,13 @@ async function main(): Promise<void> {
workspace: sharedFixture('rstack'),
tests: suiteDir('suite-bridge'),
},
{
// A root `rstack.config.ts` with only `define.fmt()`: rstack's lint
// shim refuses it, and the runtime must stay healthy (#93).
name: 'Rstack fmt-only config tests',
workspace: sharedFixture('rstack-fmt-only'),
tests: suiteDir('suite-fmt-only-config'),
},
{
// A root `rstack.config.ts` and a nested `rslint.config.ts` in one
// folder: one bridged and one native runtime side by side (ADR 0006).
Expand Down
Loading
Loading