Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev
- **Rstack config** — the unified `rstack.config.*` file consumed by rstack-cli (`rs`), holding per-tool sections. Tools never read it themselves; `rs` hands each tool its section through a shim.
- **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.
- **Generated shim** — a shim the extension writes itself for a bridged folder, baking in the absolute Rstack config path via the loader rstack publishes (`rstack/config`). Used where the tool's channel evaluates modules away from the project directory, so rstack-cli's shipped shim (which probes the current directory) cannot apply.
- **Bridged folder** — a workspace folder whose lint runs against a Rstack config: no native Rslint config exists anywhere in the folder, a Rstack config sits at the folder root, and the language server is pinned to a generated shim for its whole lifetime. _Avoid_: bridged workspace.
- **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.

## fmt
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The extension takes its configuration from five sources. The tool-native configs
| `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 --stdin-filepath`, resolving the config the same way the CLI does; an `rs fmt` language server is the longer-term path. |
| `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.lint()` in `rstack.config.*` | **Supported, with requirements.** A workspace folder with no `rslint.config.*` anywhere and an `rstack.config.*` at its root is linted from `define.lint()`, evaluated through rstack's own config loader so the editor and `rs lint` see the same config. It needs `@rslint/core` installed in the project — `rstack` depends on it, but pnpm does not expose transitive dependencies, so add it to your `devDependencies` — and new enough to let the editor pin the language server to a config; until both hold, the status bar says which one is missing. A TypeScript `rstack.config.ts` additionally needs a VS Code build whose Node can strip types, since rstack's config loader has no fallback. A folder that does have an `rslint.config.*` keeps using it, unchanged. |

## License

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0001-node-runtime-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Note that _worker_ names a process, not a runtime. The worker is our own code; t
This decision is implemented for one path: the rstest worker. Two others sit on the wrong side of the line today, and this ADR does not move them. Naming them, so the rule is not read as an invariant the extension already holds:

- **fmt** spawns the project's `rs` bin on `process.execPath` with `ELECTRON_RUN_AS_NODE=1` (`stacks/fmt/run.ts`) — the VS Code Node runtime — and `rs fmt` loads the project's config in that process (`stacks/fmt/index.ts`). Unbounded load, no floor, no preflight.
- **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** 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. The lint × rstack bridge widens this entry: a bridged folder evaluates the user's `rstack.config.*` on the same runtime, through rstack's `loadRstackConfig` — the `loader: 'native'`, no-jiti-fallback path this ADR analysed for the worker floor. The generated shim itself is plain JS, and `@rslint/core`'s jiti fallback covers only the entry config file it loads, not the imports that file makes — so a `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively, a condition VS Code's release cadence owns, not the user's environment.

Neither is cheap to move — each needs its own spawn-and-protocol work — and neither has a reported bug behind it yet. Known debt, deliberately: the next stack to load project code should follow the rule, and nobody should describe the rule as already universal.

Expand Down
9 changes: 6 additions & 3 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
## The copies are intentional

- `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 six ways (the "adaptations" below). When syncing upstream, preserve them. A seventh divergence is either a bug or must be added to this list.
- 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.

## The six adaptations
## The seven adaptations

1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker.
2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` names appear only in the migration mapping. Command IDs were renamed without aliases (breaking old keybindings was an accepted cost).
3. **Resolve-from-project** — no tool binaries or tool packages in the VSIX; everything resolves from the user's project so the editor runs the CLI's exact versions. Version floors surface as a status, never a crash. All cooperating lint pieces (binary, config loader, plugin host) must come from one resolution root.
4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists.
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** (test) — the worker's Node 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`). Implemented for the rstest worker only — fmt and lint still load project code on the VS Code Node runtime, known debt recorded in the ADR, not an invariant the extension already holds.
7. **Rstack config bridge** (lint) — a **bridged folder** (no native `rslint.config.*` anywhere in the folder, an `rstack.config.*` at its root) is linted from the Rstack config: the extension writes a **generated shim** into the project and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`. Native mode stays byte-identical to upstream — the field is absent, so the server keeps doing its own discovery. In `Rslint.ts` every line of it sits between a `--- rstack config bridge ---` / `--- end rstack config bridge ---` marker pair (no single-line markers — the pairing is what an upstream-sync diff greps for); the rule, the shim and the gate live in `stacks/lint/rstackBridge.ts`. The shim's lifecycle follows the pin: written before the server starts, re-materialized under the _same_ path on a dependency change (a reinstall can delete it or dangle the store path baked into it, and the pin cannot move), deleted when the folder starts in native mode.

## Rules

Expand All @@ -30,7 +31,9 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

## Gotchas — decisions that look wrong but aren't

- The lint × `rstack.config.*` bridge was built and deliberately removed: a partial editor-side bridge gave wrong results, and a correct one needs upstream work first. `TODO(rstack-bridge)` markers carry the plan. Do not reintroduce a partial bridge.
- The lint × `rstack.config.*` bridge is complete, and every part of it that looks like an arbitrary restriction is forced by the language server. Ownership is decided per **folder**, not per directory (one native config anywhere and the bridge yields entirely, silently), and only a **root** `rstack.config.*` bridges, because the explicit-config choice is fixed for a server process, there is one server per workspace folder, and its cwd is the folder root. A subdirectory `rstack.config.*` therefore does not light lint at all — a documented limitation, not an oversight. The shim is **generated** rather than rstack's shipped `dist/rslintConfig.js`, because that one calls `loadRstackConfig()` with no arguments and probes the _evaluation_ cwd, which for the editor is the extension host's meaningless cwd; the generated one bakes the absolute config path in. It still never interprets the config: `define.lint`'s value is an Rslint flat config, taken through the project's own `rstack/config` export. A mode flip is a **restart** (the controller replays the folder through the coordinator's replacement path), never a message to a live server.
- Bridged mode is gated on a **capability**, not a version number: the project's `@rslint/core/config-loader` must report config-discovery protocol >= 2 (the version that carries `configPath`, rslint PR #1630). Below that the extension does not start a half-bridge — it reports `version mismatch` naming the resolved version and telling the user to upgrade `@rslint/core`. Do not replace the probe with a guessed release number, and do not "fall back" to automatic discovery for a bridged folder: the server would find no config and report nothing, which reads as a broken extension.
- A bridged folder that cannot start reports `version mismatch`, never `crashed` — including when `@rslint/core` does not resolve at all (one `RstackBridgeGateError` for both gates, differing only in message; the coordinator is told it is an expected failure). That is not politeness: `@rslint/core` reaches such a project only as `rstack`'s **transitive** dependency, which pnpm's isolated layout does not expose, so the mainstream rstack-cli project hits it. Nobody in that folder asked for Rslint by name, `crashed` outranks every other folder in the status aggregation, and the status detail is the only place the fix can be stated. Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`.
- 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 a spawn-per-request `rs fmt --stdin-filepath` MVP. Its cwd is the governing config directory because rs fmt resolves config from cwd only, and formatting errors are log-only by design. A single pre-spawned standby that tracks the active editor (see CONTEXT.md) is the accepted, bounded exception to "no warm tier". Do not grow it into a daemon: no long-lived protocol, no process pool, no cross-request state. The endgame is an upstream LSP; the standby retires with it.
- `projectModules.ts` has no cache-invalidation hook and restart must not grow one. Node's ESM registry is keyed by resolved URL and process-lifetime, so clearing the local memo hands back the identical module object (verified); a `?epoch=` query does reload the entry but relative specifiers inside it do not inherit the query, yielding a fresh entry over stale dependencies. In-place reinstalls under an unchanged path need a window reload — say so, don't fake it.
Expand Down
11 changes: 10 additions & 1 deletion packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The extension activates on startup, then decides **per workspace folder** which

| Tool | Started when the folder contains |
| --- | --- |
| Rslint | `rslint.config.{js,mjs,ts,mts}` |
| Rslint | `rslint.config.{js,mjs,ts,mts}`, or an `rstack.config.*` at the folder root when the folder has no `rslint.config.*` at all |
| Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` |
| rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` |

Expand All @@ -40,6 +40,15 @@ The project-resolved packages are checked against a support matrix at runtime; a
| `@rstest/core` | `>=0.6.0` |
| `rstack` | `>=0.3.5` |

Linting a folder from `define.lint()` in `rstack.config.*` asks more:

- `rstack` must publish its config loader (`>=0.4.0`).
- `@rslint/core` must be **installed in the project**. `rstack` depends on it, but package managers with an isolated `node_modules` layout (pnpm by default) do not expose transitive dependencies, so add `@rslint/core` to your `devDependencies`.
- `@rslint/core` must be new enough for the editor to pin the language server to a config. That is not a version number the extension can name yet — no released `@rslint/core` has it.
- A TypeScript `rstack.config.ts` must be loadable by the VS Code extension host's Node: rstack's config loader relies on native type stripping and has no fallback, so on an older VS Code build use `rstack.config.mjs` (or `.js`).

Until all of them hold, such a folder shows `version mismatch` with a message naming the missing piece instead of linting; adding an `rslint.config.*` is the way out today. Folders with an `rslint.config.*` are unaffected.

## Auto-fix on save (Rslint)

To automatically fix lint issues when saving, add this to your VS Code settings (`.vscode/settings.json`):
Expand Down
11 changes: 7 additions & 4 deletions packages/vscode/e2e/fixtures/rstack/rstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
//
// This fixture has NO tool-native config: no `rslint.config.*`, no
// `rstest.config.*`. `rstack.config.ts` is the single config source, and its
// presence alone must light the Rstest and rs fmt stacks (Rslint via
// `define.lint()` is deferred — TODO(rstack-bridge)) — `rs lint` and
// `rs test` inject rstack's own shim configs, so a tool-native file never has
// to exist.
// presence alone must light all three stacks — `rs lint` and `rs test` inject
// rstack's own shim configs, so a tool-native file never has to exist.
//
// It sits at the folder root and no `rslint.config.*` exists anywhere, which
// makes this fixture a *bridged folder*: the lint slice's `suite-rstack-bridge`
// asserts what the extension does with it. `define.lint()`'s value IS an Rslint
// flat config — no translation happens anywhere in the chain.
import { define } from 'rstack';

define.lint([
Expand Down
Loading