Skip to content
Closed
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.
- **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.

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` 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.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 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:

- **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** 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 (`0003-lint-rstack-bridge.md`) 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.

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.

Expand Down
42 changes: 42 additions & 0 deletions docs/adr/0003-lint-rstack-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Linting bridged folders through a generated shim

A **bridged folder** — no native `rslint.config.*` anywhere in the workspace folder, an `rstack.config.*` at its root — is linted from `define.lint()`: the extension writes a **generated shim** into the project (`node_modules/.cache/rstack-editor/rslint.config.mjs`) and pins that folder's language server to it through the optional `configPath` of `rslint/configRefresh`, added by config-discovery protocol 2 (web-infra-dev/rslint#1630, first released in `@rslint/core` 0.8.0). Native mode stays byte-identical to upstream: the field is simply absent and the server keeps doing its own discovery. The rule, the shim and the gates live in `stacks/lint/rstackBridge.ts`, a pure module the shell's detection and the stack's mode selection both consult — one decision seen twice.

## Why a generated shim

`rs lint`'s own answer to "lint from the Rstack config" is a shim rstack ships (`dist/rslintConfig.js`) and injects through Rslint's ordinary explicit-config channel. The editor cannot point the server at that file: it calls `loadRstackConfig()` with no arguments, which probes the **evaluation** cwd — and the server evaluates config modules in the extension host, whose cwd is meaningless. So the extension renders its own shim with two absolute paths baked in: the project's `rstack/config` export (resolved out of the package's own `exports` map, so a layout change in rstack follows automatically) and the folder-root `rstack.config.*`. The body mirrors rstack's shipped shim — take `configs.lint ?? []`, await a function, default-export the result. `define.lint`'s value **is** an Rslint flat config; no translation happens anywhere in the chain, and none may ever be added.

The shim lives inside the project, not in extension storage, so module and plugin resolution from it anchors on the project. `node_modules/.cache/` is the conventional home for tool-generated files, and living under `node_modules` is what makes its deliberately config-like basename safe: detection excludes `node_modules` outright, so the shim can never be mistaken for a native config and flip Ownership against itself. Its lifecycle follows the pin: written before the server starts, rewritten only when its content actually differs (the config watcher normally never sees shim writes — the default `files.watcherExclude` swallows `node_modules` events — but that is a user setting, and the no-churn rule is what guarantees a write cannot feed a refresh loop), re-materialized under the _same_ path on every config refresh so long as its source `rstack.config.*` still exists (a reinstall or a cache wipe can delete the shim or dangle the store path baked into it, such deletions produce no watcher event either, and the pin cannot move — the no-churn write keeps the intact case a pure read; a vanished source means the folder is leaving detection, whose cleanup a rewrite would race), deleted when the folder starts in native mode or leaves detection altogether — a window close deliberately keeps it, since the next start re-materializes in place.

## Why Ownership is per folder, and only a root config bridges

The granularity is the language server's, not a policy choice: the explicit-config decision is fixed for a server process's whole lifetime, there is one server per workspace folder, and its cwd is the folder root. One native `rslint.config.*` anywhere in the folder therefore means pure native mode — the bridge yields entirely, silently — and a `rstack.config.*` in a subdirectory does not light lint at all. That is a documented limitation with the same remedy fmt gives (ADR 0002): a subproject that needs its own config becomes its own workspace folder. A mode flip is a **restart** of the folder's server through the coordinator's existing replacement path, never a message to a live one — `lintConfigModeSignature` is exactly the identity the controller compares to notice one.

## Why a capability gate, not a version number

Bridged mode starts only when the project's own `@rslint/core/config-loader` reports `CONFIG_DISCOVERY_PROTOCOL_VERSION >= 2` — the version whose `rslint/configRefresh` carries `configPath`. The gate reads the constant instead of comparing release numbers because the capability is the thing the mode needs: a guessed release floor would either strand users whose build already speaks protocol 2 or start a mode the server cannot honour. Below the gate the extension starts nothing rather than a half-bridge: "falling back" to automatic discovery in a bridged folder would find no config and report nothing, which reads as a broken extension. The client-side set (`SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS` in `shared/versionCheck.ts`) accepts both 1 and 2, so native mode keeps working on the very releases that make the bridge possible — v2 adds one optional field and changes nothing else.

## Why a gated folder reports `version mismatch`, never `crashed`

The gates refusing a bridged folder are raised as one `RstackBridgeGateError` differing only in message: the **capability** gate above; the **toolchain** gate — `@rslint/core` does not resolve from the folder at all; and the **rstack-loader** gates — `rstack` itself missing, or too old to export `./config` (`< 0.4.0`). All describe package prerequisites whose remedy is an install or an upgrade — exactly what `version mismatch` means. A failure that is _not_ a prerequisite (a present `./config` export in an unusable shape, an unreadable `rstack` manifest, a missing platform binary of a resolvable `@rslint/core`) keeps its ordinary classification: its remedy is not "install/upgrade", so the gate message would mislead. The toolchain gap is the _mainstream_ shape, not an edge case: `@rslint/core` reaches a bridged project only as `rstack`'s transitive dependency, and isolated `node_modules` layouts (pnpm's default) deliberately do not expose transitive dependencies, so the typical rstack-cli app — whose only config is `rstack.config.ts` — 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 (add `@rslint/core` to `devDependencies`, or upgrade it, or write an `rslint.config.*`). Native mode's identical failure stays a crash — there the user wrote an `rslint.config.*`, an explicit request for a tool that is missing. The classification also holds mid-life: a config refresh that finds a prerequisite vanished (`rstack` removed or downgraded under a pinned shim) degrades the folder to `version mismatch`, and the first refresh that re-materializes the shim restores `running`.

## Considered options

**Pointing the server at rstack's shipped shim** — rejected above: it probes the evaluation cwd, which only means the right thing when `rs` runs it from the project directory.

**Re-implementing Rstack config semantics in the extension** — rejected everywhere in this repo (the test bridge set the precedent): the editor and the CLI must evaluate the config identically, which only holds when both go through the project's own `rstack` package.

**Automatic-discovery fallback below the gate** — rejected: a server with no config silently lints nothing; a `version mismatch` status names the version it found and the way out.

**Gating on a release number** — rejected in favour of the capability probe; the protocol constant is published by the exact module whose behaviour is in question.

**Evaluating the Rstack config on a User Node runtime** — not taken now. The bridge evaluates the config on the VS Code Node runtime through rstack's `loadRstackConfig` — the `loader: 'native'`, no-jiti-fallback path ADR 0001 analysed — which widens the lint entry on that ADR's debt list rather than retiring it. Moving lint's config host and plugin pool onto a User Node runtime is its own spawn-and-protocol project, recorded there as known debt.

## Consequences

- A `.ts` Rstack config lints in the editor only when the VS Code Node runtime strips types natively — `@rslint/core`'s jiti fallback covers the entry config file it loads, not the imports that file makes, and the generated shim's import of the config goes through rstack's loader. The preflight (`describeRstackConfigLoaderPreflight`) turns that into a diagnostic, never a behaviour change; VS Code's release cadence owns the condition. A config that then fails to load is not a package prerequisite, so it keeps its ordinary classification — a crash, not a `version mismatch`.
- pnpm users must add `@rslint/core` to `devDependencies` for bridged linting; the README states it and the toolchain gate's status repeats it.
- The generated shim is a build artifact the extension owes hygiene for: never committed, deleted on a native flip and when the folder leaves detection, and `writeGeneratedShim`'s no-churn rule keeps the config watcher from seeing phantom edits.
- The mode choice is immutable per server process, so anything that changes it — a native config appearing, the root Rstack config vanishing — restarts that folder's server; there is no message path to a live one.
- The bridge holds only for the folder root; monorepo subpackages with their own `rstack.config.*` lint through the bridge only when opened as their own workspace folder.
- Yarn PnP is out of scope, extension-wide: resolution walks physical `node_modules` only, matching upstream's resolver ("no PnP or fallback"), so a PnP-only project surfaces the ordinary resolution failure — for a bridged folder, the toolchain gate — with a message naming the layout as the blocker. The resolver's old PnP lookup covered only the find-`@rslint/core` hop; everything downstream (config evaluation, the plugin host, the fmt and test stacks) resolves without PnP hooks, so the hop alone never produced a working folder, and extending it to the generated shim would not help either — the shim's whole import chain (rstack and its dependencies, possibly zip-archived) would need PnP hooks inside the server's evaluation process. Real support is a Yarn editor-SDK-shaped project, the route Prettier's extension takes, not a resolver branch.
Loading