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
10 changes: 8 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev
- **VS Code Node runtime** — the Node.js shipped inside VS Code, which the extension host itself runs on. Its version follows VS Code's release cadence, and it is Electron's Node, on a different ABI line from plain Node. _Avoid_: host runtime, extension host runtime.
- **User Node runtime** — the Node.js the user's own environment provides, discovered by the extension rather than shipped with it. _Avoid_: worker runtime, project-side Node.
- **Load bound** — the limit on what a piece of work can end up loading: what the extension ships, plus ABI-stable N-API bindings. Work that stays inside the bound may run on the VS Code Node runtime; work that can load project code has no load bound and belongs on a User Node runtime. _Avoid_: load surface.
- **Preflight** — the check that picks a User Node runtime, run once per extension host and shared by every process that loads project code (the test worker, the fmt server). Its failure is a status, never a crash.
- **Preflight** — the check that picks a User Node runtime, run once per extension host and shared by every process that loads project code (the lint worker, the test worker, the fmt server). Its failure is a status, never a crash.
- **Runtime floor** — the version range a User Node runtime must satisfy (`NODE_RUNTIME_RANGE` in `shared/versionCheck.ts`). A declared support contract, not a probed capability.

## Tools and configs
Expand All @@ -25,7 +25,13 @@ 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 assigning a directory to one tool when both a native config and a Rstack config are present there: the atomic tool's native config wins and the bridge yields. This rule exists only in the editor; upstream CLIs never face the choice, since each reads only its own config.
- **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.

## lint

- **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.

## fmt

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ Rstack Editor provides unified editor support for [Rstack](https://rstack.rs), t

## Roadmap

The extension takes its configuration from five sources. The tool-native configs are fully supported today; support for driving a stack from `rstack.config.*` is landing one stack at a time.
The extension takes its configuration from five sources. Tool-native configs and each supported `define.*()` bridge are available today.

| Config source | Status |
| --- | --- |
| `rslint.config.*` | **Supported.** Diagnostics, quick fixes and the language server, all resolved from the `@rslint/core` installed in your project. |
| `rstest.config.*` | **Supported.** Test discovery, run and debug, watch mode, coverage and snapshot updates in the Test Explorer. |
| `define.test()` in `rstack.config.*` | **Supported.** Tests run through the same config shim `rs test` uses, so the editor and the CLI resolve the config identically. |
| `define.fmt()` in `rstack.config.*` | **Supported.** Document formatting through the project-local `rs fmt` language server (`rs fmt --lsp`), one per workspace folder, loading the config from the folder root — the same config `rs fmt` run there would use. Needs `rstack` 0.5.2 or newer. |
| `define.lint()` in `rstack.config.*` | **Planned.** Linting a project configured only through `rstack.config.*` needs upstream changes in Rslint and rstack-cli before the editor can evaluate it correctly. `rs lint` on the command line is unaffected. |
| `define.fmt()` in `rstack.config.*` | **Supported.** Document formatting through the project-local `rs fmt` language server (`rs fmt --lsp`), one per workspace folder, loading the config from the folder root — the same config `rs fmt` run there would use. |
| `define.lint()` in `rstack.config.*` | **Supported.** In a folder without a native Rslint config, diagnostics come through rstack's published lint shim — the same config path `rs lint` uses. |

## License

Expand Down
6 changes: 2 additions & 4 deletions docs/adr/0001-node-runtime-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,10 @@ Note that _worker_ names a process, not a runtime. The worker is our own code; t

### Where the line is drawn today

This decision was written for one path, the rstest worker, and named two others that sat on the wrong side of the line. One of them has since moved:
This decision was written for one path, the rstest worker, and named two others that sat on the wrong side of the line. Both have since moved:

- **fmt** used to spawn the project's `rs` bin on `process.execPath` with `ELECTRON_RUN_AS_NODE=1` (`stacks/fmt/run.ts`) — the VS Code Node runtime — and let `rs fmt` load the project's config in that process: unbounded load, no floor, no preflight. It now runs `rs fmt --lsp` as a language server on a User Node runtime chosen by this decision's own logic, against the same floor, with the shared `rstack.nodeExecutable` as its escape hatch. Why the server, and why one per workspace folder: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`.
- **lint** imports the project's `@rslint/core/config-loader` into the extension host and loads the user's `rslint.config.ts` there (`stacks/lint/configLoader.ts`), and runs user plugin rules on the same runtime (`stacks/lint/PluginLintPool.ts`). `stacks/lint/jitiPreflight.ts` already records the resulting divergence in so many words: that loader "runs on the extension host's Node — whose version is fixed by VS Code, not by the user — so the jiti branch can trigger in the editor even when the CLI works fine". Its answer is a diagnostic, not a runtime choice.

Lint is what moving costs when it is not cheap: fmt's move needed a whole upstream language server to exist first, and lint needs its own spawn-and-protocol work for the config loader and the plugin host, with no reported bug behind it yet. It stays known debt, deliberately — the rule is not universal until that entry is gone, and nobody should describe it as if it were.
- **lint** now runs an editor-shipped, vscode-free worker on the same User Node runtime. The worker owns the Go LSP, config evaluation and plugin rules, so no project code is imported into the extension host. The worker and the Rstack lint bridge are the decision in `docs/adr/0003-lint-through-editor-worker.md`; that ADR retires lint from this decision's debt list.

## Consequences

Expand Down
31 changes: 31 additions & 0 deletions docs/adr/0003-lint-through-editor-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
status: accepted
---

# Linting through an editor-shipped worker on the User Node runtime

Rslint's language server is two halves: the Go process (`rslint --lsp`) lints natively, but it hands config evaluation (`rslint/loadConfigs`, `activateConfigs`, `commit`/`abort`) and JS plugin rules (`rslint/pluginLint`) back to its client over reverse requests. Upstream's VS Code extension is that client, and so — as a near-verbatim copy — was ours: the project's `rslint.config.*` was evaluated inside the extension host, on the VS Code Node runtime, with a cwd that means nothing (ADR 0001's lint entry). Linting from a Rstack config (`define.lint()`) forces the issue: `rs lint`'s answer is a shim rstack ships (`<rstack>/dist/rslintConfig.js`, treated as a stable path by agreement with rstack-cli) that calls `loadRstackConfig()` and finds `rstack.config.*` from **the evaluating process's cwd**. Evaluated in the extension host, it finds nothing. The first attempt (PR #10) worked around that by writing a **generated shim** into the project with the absolute config path baked in; it was rejected — the editor must not manufacture bridging artifacts.

**Decision.** The extension ships a **lint worker**: a vscode-free Node script, run once per lint server on a **User Node runtime** (ADR 0001's floor and candidate order, `rstack.nodeExecutable` as the shared escape hatch) with its cwd at the workspace folder root. The worker spawns the Go `rslint --lsp` of the resolved `@rslint/core`, proxies LSP over stdio to the extension, and answers Go's reverse requests itself with that core's `ConfigModuleHost` and `createPluginLintHost`. Its entry is `--lsp [--config <absolute module>]`: with `--config` it pins the server to that module through the protocol-2 `configPath` of `rslint/configRefresh` (`@rslint/core >= 0.8.0`, rslint #1630); without it the server does its ordinary automatic discovery. A **bridged folder** runs the worker with `--config <rstack>/dist/rslintConfig.js`; a **native folder** runs it without. From the extension's side, lint is now the shape fmt already has: a thin language client per server, and no project code loaded in the extension host — which retires the lint entry on ADR 0001's debt list.

## Considered options

**The JS host in `@rslint/core`** — a self-hosted `rslint --lsp` on the JS bin, accepting `--config`; rstack's `rs lint --lsp` becomes the same five-line passthrough `rs lint` already is. The best layering (any editor gets JS configs, not only VS Code), and the shape this worker is deliberately written in so it can move there whole. Not taken now because it gates the feature on an rslint release.

**The JS host in rstack-cli** (`rs lint --lsp` written on `@rslint/core`'s public exports) — serves bridged folders only; native folders would keep the extension-host path, leaving two lint paths in the editor. Rejected.

**Keeping the JS host in the extension host and adapting** — either an in-memory adapter that calls `rstack/config`'s `loadRstackConfig({configFilePath, cwd})` when Go asks for the Rstack config path (the editor re-implements the shim's three lines and keeps evaluating project code on the VS Code Node runtime), or setting `globalThis.__rstackCliState` before importing rstack's shim (an internal contract, racy across folders). Both rejected: the first widens ADR 0001's debt and violates "never re-implement rstack config semantics", the second is a hack.

**A child process that only evaluates, with the extension staying Go's client** — every `pluginLint` would cross one more IPC hop (Go → extension → child → worker) and half the host logic would stay in the extension host. Rejected in favour of the worker fronting Go directly.

**A generated shim** (PR #10) — rejected by decision, see above.

## 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 <dir> [--config <shim>]` 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.
- **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.
- The lint copy diverges further from upstream: the reverse-request adapter and plugin pool move into the worker unchanged in logic, and the extension-side `Rslint.ts` keeps only the language-client half. Recorded as an adaptation in `packages/vscode/AGENTS.md`.
Loading