From 82f9256deb3f4e77fe07ab7c9c6c62acc1861977 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 18 Aug 2026 15:15:15 +0800 Subject: [PATCH 1/2] chore(vscode): remove the legacy settings migration Drop `migration.ts`, its tests, the `rstack.migrateSettings` command (manifest entry, palette registration and status-bar hover action) and the activation-time prompt with its `rstack.migration.dismissed` state. Pre-1.0 the extension owes no compatibility to earlier states, and the migration only served users of the two retired standalone extensions; keeping it meant every settings change carried a mapping-table update plus tests for a one-off flow, and the table already had to model dropped features (`rslint.binPath` / `customBinPath`, #14). The README keeps a one-paragraph note telling standalone-extension users to re-enter their settings under `rstack.*` and re-bind keybindings; AGENTS.md's namespace adaptation and pre-1.0 rule are reworded so no migration is implied. Closes #15 --- CONTEXT.md | 2 +- packages/vscode/AGENTS.md | 6 +- packages/vscode/README.md | 9 +- packages/vscode/e2e/suite/shell.test.ts | 1 - packages/vscode/package.json | 5 - packages/vscode/src/channels.ts | 2 +- packages/vscode/src/extension.ts | 6 - packages/vscode/src/migration.ts | 674 ---------------------- packages/vscode/src/stacks/test/config.ts | 3 +- packages/vscode/src/statusBar.ts | 12 +- packages/vscode/tests/extension.test.ts | 4 - packages/vscode/tests/migration.test.ts | 372 ------------ packages/vscode/tests/statusBar.test.ts | 7 +- 13 files changed, 14 insertions(+), 1089 deletions(-) delete mode 100644 packages/vscode/src/migration.ts delete mode 100644 packages/vscode/tests/migration.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index ee725e5..9cd4efc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,7 +5,7 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev ## Core - **Stack** — one tool integration (lint, test, fmt) hosted by the extension shell. A stack registers against the shell and reports status through it; stacks never own UI chrome. -- **Shell** — the always-activating extension core: detection, status bar, output channels, settings migration, stack lifecycle. +- **Shell** — the always-activating extension core: detection, status bar, output channels, stack lifecycle. - **Detection** — the per-workspace-folder scan deciding which stacks a folder lights up. Detection signals are config files and installed tool binaries, never user settings. - **Gate** — the per-stack activation condition: detected, workspace trusted, and the enable settings on. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 78d4df9..f37a97a 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — `rstack.rstack` VS Code extension -One extension replacing the standalone `rstack.rslint` and `rstack.rstest` extensions: a thin shell (activation, detection, status bar, settings migration) hosting one stack per tool under `src/stacks/`. +One extension replacing the standalone `rstack.rslint` and `rstack.rstest` extensions: a thin shell (activation, detection, status bar) hosting one stack per tool under `src/stacks/`. ## The copies are intentional @@ -10,7 +10,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## 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). +2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` settings and command ids are not read, aliased or migrated (breaking old settings and 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. Enforced by lint: `@typescript-eslint/no-restricted-imports` in the root `rstack.config.ts` rejects any non-type import of `@rslint/core`, `@rstest/core`, `rstack` or `jiti` under `src/` — types only at compile time, runtime modules through explicit project paths. 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. 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. @@ -19,7 +19,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Rules -- **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. Only the **latest released** `rstack`, `@rstest/core` and `@rslint/core` need support: whenever a change touches a floor in `SUPPORT_MATRIX`, set it to the latest release at that time — do not reason about which older release would still work — and raise it without a transition story (the floor status names the required version). The settings migration exists for users of the two retired standalone extensions, never for earlier states of this one. +- **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. Only the **latest released** `rstack`, `@rstest/core` and `@rslint/core` need support: whenever a change touches a floor in `SUPPORT_MATRIX`, set it to the latest release at that time — do not reason about which older release would still work — and raise it without a transition story (the floor status names the required version). No settings migration exists either — not for earlier states of this extension, and not for the two retired standalone extensions (removed in #15; users re-enter their settings under `rstack.*`). - **The three tools are treated uniformly by default.** Detection, dependency-change retry, restart semantics, version gating and status reporting follow one shared pattern across the lint/test/fmt stacks; a stack diverges only when its tool forces it, and the divergence is recorded here as a gotcha. When adding behavior to one stack, first ask whether it belongs to all three. This is about behavior, not code — the upstream copies still must not be deduplicated. - One stack failing to register or crashing must never take another stack (or the shell) down. - The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. Enable-settings are coarse kill switches only. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index a244589..e77ab9b 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -103,14 +103,9 @@ To use `rs fmt` as the formatter for supported documents, opt in through your VS Formatting runs one `rs fmt` language server per workspace folder, which loads `define.fmt()` from the `rstack.config.*` at the **folder root** — the same config `rs fmt` in a terminal there would use, so a config in a subdirectory is not picked up (open that subdirectory as its own workspace folder if it needs different settings). Editing the config restarts the server for you. Your editor's own formatting options (tab size, spaces) are not consulted: the project config decides, exactly as on the command line. -## Migrating from the standalone extensions +## Coming from the standalone extensions -Run **Rstack: Migrate Rslint/Rstest Settings** from the Command Palette (it is also offered once, dismissibly, when legacy keys are found). - -- Settings are migrated per layer (User, Workspace, Workspace Folder), and the legacy keys are removed after they are copied. Workspace and folder layers touch files inside your repository, so nothing is written before you confirm the previewed key mapping. -- Legacy `rslint.binPath` / `rslint.customBinPath` values are left untouched: a standalone binary path cannot be translated safely into the `@rslint/core` directory the worker requires. -- **Keybindings are not migrated.** Command ids were renamed to `rstack.*` with no aliases, and VS Code has no keybindings API, so any keybinding bound to an old `rslint.*` / `rstest.*` command id has to be re-bound by hand. -- Projects with only `rslint.json` / `rslint.jsonc` are reported as `not detected`; run `rslint --init` to migrate to a JS/TS config. +Settings and keybindings are not carried over from the retired `rstack.rslint` / `rstack.rstest` extensions: re-enter your settings under the `rstack.*` keys listed above and re-bind any keybinding to the new `rstack.*` command ids. Legacy `rslint.binPath` / `rslint.customBinPath` have no equivalent — use `rstack.rslint.corePath` to point at an `@rslint/core` package directory if you still need an override. ## Community diff --git a/packages/vscode/e2e/suite/shell.test.ts b/packages/vscode/e2e/suite/shell.test.ts index a6307ab..9ed46cf 100644 --- a/packages/vscode/e2e/suite/shell.test.ts +++ b/packages/vscode/e2e/suite/shell.test.ts @@ -34,7 +34,6 @@ suite('shell', () => { for (const command of [ 'rstack.showOutput', 'rstack.restart', - 'rstack.migrateSettings', 'rstack.rslint.output.focus', 'rstack.rslint.restart', 'rstack.rstest.output.focus', diff --git a/packages/vscode/package.json b/packages/vscode/package.json index ebc1885..ecbb88d 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -55,11 +55,6 @@ "category": "Rstack", "icon": "$(debug-restart)" }, - { - "command": "rstack.migrateSettings", - "title": "Migrate Rslint/Rstest Settings", - "category": "Rstack" - }, { "command": "rstack.rslint.output.focus", "title": "Show Rslint Log", diff --git a/packages/vscode/src/channels.ts b/packages/vscode/src/channels.ts index c435afd..852b4ab 100644 --- a/packages/vscode/src/channels.ts +++ b/packages/vscode/src/channels.ts @@ -10,7 +10,7 @@ const CHANNEL_NAMES: Readonly> = { /** * The extension's four output channels — a deliberate cap: one per stack plus - * one for the shell (detection results, state transitions, migration logs). + * one for the shell (detection results, state transitions). * * Stacks never create their own channel — a copied stack that used to create * one per workspace folder has to log into the shared channel instead. diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 3142684..a514b7a 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,7 +1,6 @@ import vscode from 'vscode'; import { Channels } from './channels'; import { DetectionService } from './detection'; -import { maybePromptForMigration, runSettingsMigration } from './migration'; import { resetUserNodeCaches } from './shared/nodeResolution'; import { StatusBar } from './statusBar'; import { @@ -132,8 +131,6 @@ class ExtensionShell { // is still initialising — and running beside it would let that restart // retire a controller whose `register()` has not returned yet. await this.reconcile(); - - void maybePromptForMigration(this.context, this.#channels.shell); } private registerCommands(): void { @@ -145,9 +142,6 @@ class ExtensionShell { register('rstack.showOutput', () => this.#channels.shell.show()); register('rstack.restart', () => this.restart()); - register('rstack.migrateSettings', () => - runSettingsMigration(this.#channels.shell), - ); for (const stack of STACK_IDS) { register(stackCommand(stack, 'output.focus'), () => this.#channels.forStack(stack).show(), diff --git a/packages/vscode/src/migration.ts b/packages/vscode/src/migration.ts deleted file mode 100644 index 61b6061..0000000 --- a/packages/vscode/src/migration.ts +++ /dev/null @@ -1,674 +0,0 @@ -import vscode from 'vscode'; - -/** - * Settings migration from retired names into current ones: the two standalone - * extensions (`rstack.rslint` and `rstack.rstest`) into the unified `rstack.*` - * namespace. - * - * Shape of the feature: - * - * - legacy keys are discovered with `workspace.getConfiguration().inspect()`, - * which reports the *explicitly set* value per layer and never the default — - * defaults must not be materialised into the user's settings files; - * - three layers are handled: User, Workspace and WorkspaceFolder, and each one - * is written back separately, against its own `ConfigurationTarget`; - * - Workspace and Folder writes touch files inside the user's repository, so - * nothing is written before a preview (old key -> new key, per layer) has been - * confirmed; - * - detection of legacy keys raises exactly one dismissible prompt; the command - * itself is always available from the Command Palette. - * - * Command ids are deliberately *not* migrated: VS Code exposes no keybindings - * API, so keybindings bound to `rslint.*` / `rstest.*` command ids break - * silently. That cost is deliberately accepted and is restated in the - * confirmation dialog. - * - * Everything above `collectLegacyReadings` is pure and free of the `vscode` - * namespace object, so the mapping rules can be unit tested without an - * extension host (`migration.test.ts`). - */ - -// --------------------------------------------------------------------------- -// Pure layer: the mapping table and the planner -// --------------------------------------------------------------------------- - -/** The configuration layers `inspect()` reports and `update()` can target. */ -export type MigrationLayer = 'user' | 'workspace' | 'folder'; - -/** - * Why a legacy key that *is* set was not carried over. Reported in the preview - * so a skipped key is never silently dropped. - */ -export type SkipReason = - /** The legacy value has no equivalent in the new setting's schema. */ - | 'unsupported-value' - /** The new key already has an explicit value in the same layer. */ - | 'target-already-set' - /** A folder-layer value for a window-scoped target setting. */ - | 'not-folder-scoped' - /** The legacy setting represented a feature that no longer exists. */ - | 'no-equivalent-setting'; - -type ValueMapping = - | { readonly kind: 'value'; readonly value: unknown } - | { readonly kind: 'skip'; readonly reason: SkipReason }; - -export interface LegacyMapping { - /** Fully qualified legacy key, e.g. `rslint.enable`. */ - readonly from: string; - /** Fully qualified new key, e.g. `rstack.rslint.enable`. */ - readonly to: string; - /** - * Scope of the *new* key in this extension's manifest. A window-scoped - * setting cannot be written to `ConfigurationTarget.WorkspaceFolder`, so a - * folder-layer legacy value for one of those is skipped instead of throwing - * at write time. - */ - readonly targetScope: 'resource' | 'window'; - /** - * Value rewrite. Absent for the mechanical renames, which carry the value - * over untouched. - */ - readonly mapValue?: (value: unknown) => ValueMapping; -} - -/** - * Legacy Rstest keys, in manifest order. Every one of them is a mechanical - * `rstest.` -> `rstack.rstest.` rename; only the scope of the *new* - * key differs, and it is the new manifest that decides it. The one exception, - * `rstest.nodeExecutable`, is mapped explicitly below: its target is the - * shared `rstack.nodeExecutable`, not a `rstack.rstest.*` key. - * - * Derived from `rstest/packages/vscode/package.json` (14 settings) and this - * repository's `contributes.configuration`. Rstest contributes no `enable` - * setting upstream, so `rstack.rstest.enable` has no legacy source. - */ -const RSTEST_KEYS: readonly (readonly [string, 'resource' | 'window'])[] = [ - ['rstestPackagePath', 'resource'], - ['nodeExecArgs', 'resource'], - ['nodeEnv', 'resource'], - ['debugNodeEnv', 'resource'], - ['debugExclude', 'resource'], - ['debugOutFiles', 'resource'], - ['debuggerPort', 'resource'], - ['debuggerAddress', 'resource'], - ['configFileGlobPattern', 'window'], - ['testCaseCollectMethod', 'window'], - ['applyDiagnostic', 'window'], - ['terminalShellPath', 'window'], - ['terminalShellArgs', 'window'], -]; - -/** - * The migratable legacy inventory: 3 Rslint keys + 14 Rstest keys. Kept in one - * table so the preview, writer and tests cannot disagree. - */ -export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ - { - from: 'rslint.enable', - // The manifest declares `rstack.rslint.enable` as window-scoped (the - // shell reads it without a resource URI), so a folder-level legacy value - // cannot be preserved and must be skipped as `not-folder-scoped`. - to: 'rstack.rslint.enable', - targetScope: 'window', - }, - { - from: 'rslint.corePath', - to: 'rstack.rslint.corePath', - targetScope: 'resource', - }, - { - from: 'rslint.trace.server', - to: 'rstack.rslint.trace.server', - targetScope: 'resource', - }, - { - // The runtime pin became a shared setting when the fmt LSP server joined - // the test worker on the User Node runtime, so the standalone extension's - // key maps to `rstack.nodeExecutable`, not to a `rstack.rstest.*` one. - from: 'rstest.nodeExecutable', - to: 'rstack.nodeExecutable', - targetScope: 'resource', - }, - ...RSTEST_KEYS.map(([key, targetScope]) => ({ - from: `rstest.${key}`, - to: `rstack.rstest.${key}`, - targetScope, - })), -]; - -/** - * These retired binary-only settings cannot name the core package directory - * required by the lint worker. Detect them for the preview, but never rewrite - * or remove them. - */ -const DROPPED_LEGACY_KEY_SET: ReadonlySet = new Set([ - 'rslint.binPath', - 'rslint.customBinPath', -]); - -const LEGACY_SOURCE_KEYS: readonly string[] = [ - ...LEGACY_MAPPINGS.map((mapping) => mapping.from), - ...DROPPED_LEGACY_KEY_SET, -]; - -const MAPPINGS_BY_KEY = new Map( - LEGACY_MAPPINGS.map((mapping) => [mapping.from, mapping]), -); - -/** One explicitly-set legacy value found in one layer. */ -export interface LegacyReading { - /** - * Opaque, stable identifier of the configuration scope the value was read - * from. The pure layer only groups by it; the `vscode` layer maps it back to - * a workspace folder. Folder names are *not* unique in a multi-root - * workspace, which is why the label is carried separately. - */ - readonly scopeId: string; - readonly layer: MigrationLayer; - /** Display name of the workspace folder; only set for the `folder` layer. */ - readonly folderLabel?: string; - readonly key: string; - readonly value: unknown; - /** - * Explicit value of the *new* key in the same layer, if the user already set - * it. Migrating on top of it would silently overwrite a deliberate choice. - */ - readonly targetValue?: unknown; -} - -export interface PlannedWrite { - readonly scopeId: string; - readonly layer: MigrationLayer; - readonly folderLabel?: string; - readonly from: string; - readonly to: string; - readonly fromValue: unknown; - readonly value: unknown; - /** True when the mapping changed the value (`built-in` -> `local`). */ - readonly rewritten: boolean; -} - -export interface PlannedSkip { - readonly scopeId: string; - readonly layer: MigrationLayer; - readonly folderLabel?: string; - readonly from: string; - readonly to?: string; - readonly value: unknown; - readonly reason: SkipReason; -} - -/** All writes that go to one `ConfigurationTarget` in one scope. */ -export interface PlannedScope { - readonly scopeId: string; - readonly layer: MigrationLayer; - readonly folderLabel?: string; - readonly label: string; - readonly writes: readonly PlannedWrite[]; -} - -export interface MigrationPlan { - /** Non-empty scopes, ordered User -> Workspace -> Folders. */ - readonly scopes: readonly PlannedScope[]; - readonly skips: readonly PlannedSkip[]; - readonly writeCount: number; - /** True when at least one write lands in a file inside the user's repo. */ - readonly touchesRepositoryFiles: boolean; -} - -export const layerLabel = ( - layer: MigrationLayer, - folderLabel?: string, -): string => { - switch (layer) { - case 'user': - return 'User Settings'; - case 'workspace': - return 'Workspace Settings'; - case 'folder': - return `Folder Settings — ${folderLabel ?? '?'}`; - } -}; - -const LAYER_ORDER: Readonly> = { - user: 0, - workspace: 1, - folder: 2, -}; - -/** - * Turns raw readings into the exact set of writes to perform, grouped by the - * scope they are written to. Pure: same readings in, same plan out. - * - * Readings for unknown keys and readings whose value is `undefined` (i.e. not - * explicitly set in that layer) are ignored. - */ -export const planMigration = ( - readings: readonly LegacyReading[], -): MigrationPlan => { - const scopes: PlannedScope[] = []; - const writesByScope = new Map(); - const skips: PlannedSkip[] = []; - - const scopeFor = (reading: LegacyReading): PlannedWrite[] => { - const existing = writesByScope.get(reading.scopeId); - if (existing) { - return existing; - } - const writes: PlannedWrite[] = []; - writesByScope.set(reading.scopeId, writes); - scopes.push({ - scopeId: reading.scopeId, - layer: reading.layer, - folderLabel: reading.folderLabel, - label: layerLabel(reading.layer, reading.folderLabel), - writes, - }); - return writes; - }; - - const order = new Map(LEGACY_SOURCE_KEYS.map((key, index) => [key, index])); - const sorted = [...readings].sort((a, b) => { - const byLayer = LAYER_ORDER[a.layer] - LAYER_ORDER[b.layer]; - if (byLayer !== 0) { - return byLayer; - } - return (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0); - }); - - for (const reading of sorted) { - if (reading.value === undefined) { - continue; - } - if (DROPPED_LEGACY_KEY_SET.has(reading.key)) { - skips.push({ - scopeId: reading.scopeId, - layer: reading.layer, - folderLabel: reading.folderLabel, - from: reading.key, - value: reading.value, - reason: 'no-equivalent-setting', - }); - continue; - } - const mapping = MAPPINGS_BY_KEY.get(reading.key); - if (!mapping) continue; - - const skip = (reason: SkipReason): void => { - skips.push({ - scopeId: reading.scopeId, - layer: reading.layer, - folderLabel: reading.folderLabel, - from: mapping.from, - to: mapping.to, - value: reading.value, - reason, - }); - }; - - if (reading.targetValue !== undefined) { - skip('target-already-set'); - continue; - } - if (reading.layer === 'folder' && mapping.targetScope === 'window') { - skip('not-folder-scoped'); - continue; - } - - const mapped: ValueMapping = mapping.mapValue - ? mapping.mapValue(reading.value) - : { kind: 'value', value: reading.value }; - if (mapped.kind === 'skip') { - skip(mapped.reason); - continue; - } - - const writes = scopeFor(reading); - writes.push({ - scopeId: reading.scopeId, - layer: reading.layer, - folderLabel: reading.folderLabel, - from: mapping.from, - to: mapping.to, - fromValue: reading.value, - value: mapped.value, - rewritten: mapped.value !== reading.value, - }); - } - - const nonEmpty = scopes.filter((scope) => scope.writes.length > 0); - return { - scopes: nonEmpty, - skips, - writeCount: nonEmpty.reduce( - (total, scope) => total + scope.writes.length, - 0, - ), - touchesRepositoryFiles: nonEmpty.some((scope) => scope.layer !== 'user'), - }; -}; - -const MAX_VALUE_LENGTH = 60; - -/** Compact, single-line rendering of a settings value for the preview. */ -export const formatValue = (value: unknown): string => { - let text: string; - try { - text = JSON.stringify(value) ?? String(value); - } catch { - text = String(value); - } - return text.length > MAX_VALUE_LENGTH - ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}…` - : text; -}; - -const SKIP_EXPLANATIONS: Readonly< - Record string> -> = { - 'unsupported-value': (skip) => - `${formatValue(skip.value)} is not a valid value of ${skip.to}`, - 'target-already-set': (skip) => `${skip.to} is already set here`, - 'not-folder-scoped': (skip) => - `${skip.to} is a window-scoped setting and cannot be set per folder`, - 'no-equivalent-setting': () => - 'this binary-only setting has no equivalent; configure rstack.rslint.corePath with an @rslint/core package directory if an override is still needed', -}; - -/** - * The old -> new preview shown before anything is written. Plain text: it is - * rendered in a modal dialog's `detail`, which does not interpret markdown. - */ -export const formatPreview = (plan: MigrationPlan): string => { - const blocks: string[] = []; - - for (const scope of plan.scopes) { - const lines = scope.writes.map((write) => { - const rewrite = write.rewritten - ? ` (${formatValue(write.fromValue)} -> ${formatValue(write.value)})` - : ''; - return ` ${write.from} -> ${write.to}${rewrite}`; - }); - blocks.push([scope.label, ...lines].join('\n')); - } - - if (plan.skips.length > 0) { - const lines = plan.skips.map((skip) => { - const where = layerLabel(skip.layer, skip.folderLabel); - return ` ${skip.from} (${where}): ${SKIP_EXPLANATIONS[skip.reason](skip)}`; - }); - blocks.push(['Left untouched', ...lines].join('\n')); - } - - return blocks.join('\n\n'); -}; - -// --------------------------------------------------------------------------- -// vscode layer: reading, confirming, writing -// --------------------------------------------------------------------------- - -const USER_SCOPE = 'user'; -const WORKSPACE_SCOPE = 'workspace'; - -const targetOf = (layer: MigrationLayer): vscode.ConfigurationTarget => { - switch (layer) { - case 'user': - return vscode.ConfigurationTarget.Global; - case 'workspace': - return vscode.ConfigurationTarget.Workspace; - case 'folder': - return vscode.ConfigurationTarget.WorkspaceFolder; - } -}; - -/** - * Reads every legacy key in every layer. `inspect()` is the only API that - * separates "explicitly set in this layer" from "inherited or defaulted", which - * is exactly the distinction the migration is built on. - */ -export const collectLegacyReadings = (): { - readonly readings: readonly LegacyReading[]; - readonly folders: ReadonlyMap; -} => { - const readings: LegacyReading[] = []; - const folders = new Map(); - // A Folder layer distinct from the Workspace layer only exists once a - // `.code-workspace` file is in play (which is also what multi-root implies — - // adding a second folder creates an untitled workspace file). With a single - // folder opened directly, `.vscode/settings.json` *is* the Workspace layer and - // `inspect()` reports its values as both `workspaceValue` and - // `workspaceFolderValue`; scanning the folder layer there would plan every - // reading twice and, for the window-scoped Rstest keys, report a bogus - // "cannot be set per folder" skip next to the write that actually happens. - const hasFolderLayer = vscode.workspace.workspaceFile !== undefined; - const workspaceFolders = hasFolderLayer - ? (vscode.workspace.workspaceFolders ?? []) - : []; - for (const folder of workspaceFolders) { - folders.set(folder.uri.toString(), folder); - } - - for (const key of LEGACY_SOURCE_KEYS) { - const mapping = MAPPINGS_BY_KEY.get(key); - const legacy = vscode.workspace.getConfiguration().inspect(key); - const target = mapping - ? vscode.workspace.getConfiguration().inspect(mapping.to) - : undefined; - - if (legacy?.globalValue !== undefined) { - readings.push({ - scopeId: USER_SCOPE, - layer: 'user', - key, - value: legacy.globalValue, - targetValue: target?.globalValue, - }); - } - if (legacy?.workspaceValue !== undefined) { - readings.push({ - scopeId: WORKSPACE_SCOPE, - layer: 'workspace', - key, - value: legacy.workspaceValue, - targetValue: target?.workspaceValue, - }); - } - - for (const folder of workspaceFolders) { - const scoped = vscode.workspace.getConfiguration(undefined, folder.uri); - const value = scoped.inspect(key)?.workspaceFolderValue; - if (value === undefined) { - continue; - } - readings.push({ - scopeId: folder.uri.toString(), - layer: 'folder', - folderLabel: folder.name, - key, - value, - targetValue: mapping - ? scoped.inspect(mapping.to)?.workspaceFolderValue - : undefined, - }); - } - } - - return { readings, folders }; -}; - -/** Convenience wrapper: is there anything at all to migrate? */ -export const hasLegacySettings = (): boolean => - planMigration(collectLegacyReadings().readings).writeCount > 0; - -const errorMessage = (error: unknown): string => - error instanceof Error ? error.message : String(error); - -const KEYBINDING_NOTE = - 'Command ids were renamed to rstack.* with no aliases: keybindings bound to the old rslint.* / rstest.* commands are not migrated and have to be re-bound manually.'; - -interface WriteOutcome { - readonly migrated: number; - readonly failed: number; -} - -/** - * Writes one scope. The legacy key is removed only after its replacement has - * been written, so a failure can never lose the value. - */ -const writeScope = async ( - scope: PlannedScope, - folder: vscode.WorkspaceFolder | undefined, - output: vscode.LogOutputChannel, -): Promise => { - const configuration = vscode.workspace.getConfiguration( - undefined, - folder?.uri, - ); - const target = targetOf(scope.layer); - let migrated = 0; - let failed = 0; - - for (const write of scope.writes) { - try { - await configuration.update(write.to, write.value, target); - } catch (error) { - failed += 1; - output.error( - `[${scope.label}] failed to write ${write.to}: ${errorMessage(error)}`, - ); - continue; - } - migrated += 1; - output.info( - `[${scope.label}] ${write.from} -> ${write.to} = ${formatValue(write.value)}`, - ); - try { - await configuration.update(write.from, undefined, target); - } catch (error) { - // The value is already carried over; a leftover legacy key is cosmetic. - output.warn( - `[${scope.label}] migrated ${write.from} but could not remove it: ${errorMessage(error)}`, - ); - } - } - - return { migrated, failed }; -}; - -export interface MigrationRunOptions { - /** Skip the "nothing to migrate" notification (used by the prompt path). */ - readonly silentWhenEmpty?: boolean; -} - -/** - * The `rstack.migrateSettings` command. Always available from the Command - * Palette; safe to run repeatedly (a second run finds nothing left to do). - * - * Returns `true` when at least one setting was written. - */ -export const runSettingsMigration = async ( - output: vscode.LogOutputChannel, - { silentWhenEmpty = false }: MigrationRunOptions = {}, -): Promise => { - const { readings, folders } = collectLegacyReadings(); - const plan = planMigration(readings); - - if (plan.writeCount === 0) { - const detail = plan.skips.length > 0 ? `\n${formatPreview(plan)}` : ''; - output.info(`No legacy settings to migrate.${detail}`); - if (!silentWhenEmpty) { - void vscode.window.showInformationMessage( - plan.skips.length > 0 - ? 'Rstack: nothing left to migrate. See the Rstack output channel for the settings that were left untouched.' - : 'Rstack: no settings under legacy names were found.', - ); - } - return false; - } - - const preview = formatPreview(plan); - output.info(`Settings migration preview:\n${preview}`); - - // Workspace and Folder writes land in files inside the user's - // repository, so the preview must be confirmed first. The User layer is - // confirmed along with them — one dialog is both simpler and more honest - // than silently rewriting half of the plan. - const scopeNote = plan.touchesRepositoryFiles - ? 'Workspace and folder settings files in this repository will be modified.' - : 'Only your user settings will be modified.'; - const confirmed = await vscode.window.showInformationMessage( - `Migrate ${plan.writeCount} setting${plan.writeCount === 1 ? '' : 's'} to the rstack.* namespace?`, - { - modal: true, - detail: `${preview}\n\n${scopeNote} The legacy keys are removed once their replacement has been written.\n\n${KEYBINDING_NOTE}`, - }, - 'Migrate', - ); - if (confirmed !== 'Migrate') { - output.info('Settings migration cancelled by the user.'); - return false; - } - - let migrated = 0; - let failed = 0; - // Each layer is written back separately, against its own target. - for (const scope of plan.scopes) { - const folder = - scope.layer === 'folder' ? folders.get(scope.scopeId) : undefined; - if (scope.layer === 'folder' && !folder) { - // The folder disappeared between the preview and the confirmation. - output.warn(`[${scope.label}] workspace folder is gone, skipped.`); - continue; - } - const outcome = await writeScope(scope, folder, output); - migrated += outcome.migrated; - failed += outcome.failed; - } - - const summary = - failed > 0 - ? `Rstack: migrated ${migrated} setting(s), ${failed} failed — see the Rstack output channel.` - : `Rstack: migrated ${migrated} setting(s). ${KEYBINDING_NOTE}`; - void vscode.window.showInformationMessage(summary); - output.info( - `Settings migration finished: ${migrated} written, ${failed} failed.`, - ); - return migrated > 0; -}; - -export const PROMPT_DISMISSED_KEY = 'rstack.migration.dismissed'; - -/** - * The single dismissible prompt. Shown at most once per user - * once "Don't ask again" is chosen; "Not now" leaves it for the next window, - * and the command stays available from the palette either way. - */ -export const maybePromptForMigration = async ( - context: vscode.ExtensionContext, - output: vscode.LogOutputChannel, -): Promise => { - if (context.globalState.get(PROMPT_DISMISSED_KEY) === true) { - return; - } - - const plan = planMigration(collectLegacyReadings().readings); - if (plan.writeCount === 0) { - return; - } - output.info(`Found ${plan.writeCount} setting(s) under legacy names.`); - - const migrate = 'Migrate…'; - const dismiss = "Don't ask again"; - const choice = await vscode.window.showInformationMessage( - 'Rstack found settings under legacy names (from the standalone Rslint/Rstest extensions). Migrate them to their current names?', - migrate, - 'Not now', - dismiss, - ); - if (choice === migrate) { - await runSettingsMigration(output, { silentWhenEmpty: true }); - } else if (choice === dismiss) { - await context.globalState.update(PROMPT_DISMISSED_KEY, true); - } -}; diff --git a/packages/vscode/src/stacks/test/config.ts b/packages/vscode/src/stacks/test/config.ts index 6d040b4..1d2d0bf 100644 --- a/packages/vscode/src/stacks/test/config.ts +++ b/packages/vscode/src/stacks/test/config.ts @@ -17,8 +17,7 @@ import vscode from 'vscode'; /** * The namespace adaptation: the unified namespace is `rstack.*`, so * every key below is read from the `rstack.rstest` section instead of the - * legacy `rstest` one. No aliases — `rstack.migrateSettings` is the migration - * path. + * legacy `rstest` one. No aliases. */ export const CONFIG_SECTION = 'rstack.rstest'; diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts index 9b83109..5fb5391 100644 --- a/packages/vscode/src/statusBar.ts +++ b/packages/vscode/src/statusBar.ts @@ -415,10 +415,10 @@ export class StatusBar implements vscode.Disposable { `${actionCells.join('')}` ); }); - // In the same table as the stacks so all six icons share one column; a + // In the same table as the stacks so all five icons share one column; a // second table would size its columns independently and the two halves - // would drift apart. One row per action rather than three across, because - // three labelled actions do not fit across a card this narrow — they would + // would drift apart. One row per action rather than two across, because + // two labelled actions do not fit across a card this narrow — they would // wrap, and a wrapped row of links reads as one ragged paragraph. // // Unlike the per-stack restarts, "Relaunch" is unconditional: it is the @@ -427,12 +427,6 @@ export class StatusBar implements vscode.Disposable { const shellActions = [ actionRow(columns, 'rstack.restart', '$(debug-restart)', 'Relaunch'), actionRow(columns, 'rstack.showOutput', '$(selection)', 'Extension log'), - actionRow( - columns, - 'rstack.migrateSettings', - '$(arrow-right)', - 'Migrate settings', - ), ]; const body = [...rows, ...sectionBreak(columns), ...shellActions]; // The notices sit under their own divider, one full-width cell each. Named diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index 6a9f585..b392bb2 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -250,10 +250,6 @@ rs.mock('../src/stacks/test', () => ({ rs.mock('../src/stacks/fmt', () => ({ createFmtController: () => harness.controller('fmt'), })); -rs.mock('../src/migration', () => ({ - maybePromptForMigration: async () => undefined, - runSettingsMigration: async () => undefined, -})); // The real reset is inert in tests; the harness counts the calls. rs.mock('../src/shared/nodeResolution', () => ({ resetUserNodeCaches: () => { diff --git a/packages/vscode/tests/migration.test.ts b/packages/vscode/tests/migration.test.ts deleted file mode 100644 index aa09752..0000000 --- a/packages/vscode/tests/migration.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { describe, expect, it, rs } from '@rstest/core'; - -// `migration.ts` imports the `vscode` namespace for the write path. The rules -// under test never touch it, but the module still has to load, so the module is -// stubbed away rather than exercised: these tests must run in plain Node, with -// no extension host (unit tests are Rstest, E2E is Electron). -rs.mock('vscode', () => { - const vscode = { - ConfigurationTarget: { Global: 1, Workspace: 2, WorkspaceFolder: 3 }, - }; - return { ...vscode, default: vscode }; -}); - -import { - formatPreview, - formatValue, - LEGACY_MAPPINGS, - type LegacyReading, - layerLabel, - planMigration, -} from '../src/migration'; - -const reading = (partial: Partial & { key: string }) => - ({ - scopeId: 'user', - layer: 'user', - value: 'value', - ...partial, - }) satisfies LegacyReading; - -const folderReading = ( - scopeId: string, - folderLabel: string, - key: string, - value: unknown, -): LegacyReading => ({ - scopeId, - layer: 'folder', - folderLabel, - key, - value, -}); - -describe('LEGACY_MAPPINGS', () => { - it('covers the migratable legacy inventory of both retired extensions', () => { - // The two Rslint binary settings have no valid core-directory equivalent; - // the remaining 3 Rslint settings and all 14 Rstest settings are mapped. - expect(LEGACY_MAPPINGS.map((mapping) => mapping.from)).toEqual([ - 'rslint.enable', - 'rslint.corePath', - 'rslint.trace.server', - 'rstest.nodeExecutable', - 'rstest.rstestPackagePath', - 'rstest.nodeExecArgs', - 'rstest.nodeEnv', - 'rstest.debugNodeEnv', - 'rstest.debugExclude', - 'rstest.debugOutFiles', - 'rstest.debuggerPort', - 'rstest.debuggerAddress', - 'rstest.configFileGlobPattern', - 'rstest.testCaseCollectMethod', - 'rstest.applyDiagnostic', - 'rstest.terminalShellPath', - 'rstest.terminalShellArgs', - ]); - }); - - it('renames every key into the rstack..* namespace, except the shared runtime pin', () => { - for (const mapping of LEGACY_MAPPINGS) { - if (mapping.to === 'rstack.nodeExecutable') { - continue; - } - const [stack, ...rest] = mapping.from.split('.'); - expect(mapping.to).toBe(`rstack.${stack}.${rest.join('.')}`); - } - }); - - it('has no duplicate source keys and no shared targets', () => { - // One source per target: the planner writes each target at most once per - // layer, so no mapping order can silently decide which value survives. - expect(new Set(LEGACY_MAPPINGS.map((m) => m.from)).size).toBe( - LEGACY_MAPPINGS.length, - ); - expect(new Set(LEGACY_MAPPINGS.map((m) => m.to)).size).toBe( - LEGACY_MAPPINGS.length, - ); - }); - - it('marks the window-scoped settings as such', () => { - const windowScoped = LEGACY_MAPPINGS.filter( - (mapping) => mapping.targetScope === 'window', - ).map((mapping) => mapping.from); - expect(windowScoped).toEqual([ - 'rslint.enable', - 'rstest.configFileGlobPattern', - 'rstest.testCaseCollectMethod', - 'rstest.applyDiagnostic', - 'rstest.terminalShellPath', - 'rstest.terminalShellArgs', - ]); - }); - - it('does not rewrite any migrated value', () => { - const rewriting = LEGACY_MAPPINGS.filter((mapping) => mapping.mapValue); - expect(rewriting).toEqual([]); - }); -}); - -describe('planMigration — mechanical renames', () => { - it('maps the standalone Rslint core package override', () => { - const plan = planMigration([ - reading({ key: 'rslint.corePath', value: './vendor/rslint-core' }), - ]); - - expect(plan.scopes[0]?.writes[0]).toMatchObject({ - from: 'rslint.corePath', - to: 'rstack.rslint.corePath', - value: './vendor/rslint-core', - rewritten: false, - }); - }); - - it('carries values over untouched', () => { - const plan = planMigration([ - reading({ key: 'rstest.nodeExecArgs', value: ['--flag'] }), - ]); - expect(plan.writeCount).toBe(1); - expect(plan.scopes[0]?.writes[0]).toMatchObject({ - from: 'rstest.nodeExecArgs', - to: 'rstack.rstest.nodeExecArgs', - value: ['--flag'], - rewritten: false, - }); - }); - - it('preserves falsy values that are explicitly set', () => { - const plan = planMigration([ - reading({ key: 'rslint.enable', value: false }), - reading({ key: 'rstest.debuggerPort', value: 0 }), - reading({ key: 'rstest.terminalShellPath', value: '' }), - ]); - expect(plan.writeCount).toBe(3); - expect(plan.scopes[0]?.writes.map((write) => write.value)).toEqual([ - false, - 0, - '', - ]); - }); - - it('ignores keys that are not part of the inventory', () => { - const plan = planMigration([ - reading({ key: 'rslint.somethingElse' }), - reading({ key: 'editor.defaultFormatter' }), - ]); - expect(plan.writeCount).toBe(0); - expect(plan.skips).toEqual([]); - }); - - it('reports retired Rslint binary settings without migrating them', () => { - const plan = planMigration([ - reading({ key: 'rslint.binPath', value: 'custom' }), - reading({ key: 'rslint.customBinPath', value: '/opt/rslint' }), - ]); - - expect(plan.writeCount).toBe(0); - expect(plan.skips).toMatchObject([ - { - from: 'rslint.binPath', - value: 'custom', - reason: 'no-equivalent-setting', - }, - { - from: 'rslint.customBinPath', - value: '/opt/rslint', - reason: 'no-equivalent-setting', - }, - ]); - const preview = formatPreview(plan); - expect(preview).toContain('Left untouched'); - expect(preview).toContain('rslint.binPath'); - expect(preview).toContain('rslint.customBinPath'); - expect(preview).toContain('this binary-only setting has no equivalent'); - expect(preview).toContain('rstack.rslint.corePath'); - }); - - it('ignores readings whose value is undefined', () => { - // `inspect()` reports `undefined` for a layer that does not set the key; - // migrating it would materialise the default into the settings file. - const plan = planMigration([ - reading({ key: 'rstest.applyDiagnostic', value: undefined }), - ]); - expect(plan.writeCount).toBe(0); - }); -}); - -describe('planMigration — layers', () => { - it('groups writes per layer and orders them user, workspace, folder', () => { - const plan = planMigration([ - folderReading('file:///w/app', 'app', 'rstest.rstestPackagePath', '/x'), - reading({ - scopeId: 'workspace', - layer: 'workspace', - key: 'rstest.nodeExecArgs', - value: [], - }), - reading({ key: 'rslint.enable', value: true }), - ]); - expect(plan.scopes.map((scope) => scope.label)).toEqual([ - 'User Settings', - 'Workspace Settings', - 'Folder Settings — app', - ]); - expect(plan.writeCount).toBe(3); - }); - - it('keeps same-named folders of a multi-root workspace apart', () => { - const plan = planMigration([ - folderReading('file:///a/app', 'app', 'rstest.rstestPackagePath', '/a'), - folderReading('file:///b/app', 'app', 'rstest.rstestPackagePath', '/b'), - ]); - expect(plan.scopes.map((scope) => scope.scopeId)).toEqual([ - 'file:///a/app', - 'file:///b/app', - ]); - expect(plan.scopes.map((scope) => scope.writes[0]?.value)).toEqual([ - '/a', - '/b', - ]); - }); - - it('reports whether files inside the repository would be touched', () => { - expect( - planMigration([reading({ key: 'rslint.enable', value: true })]) - .touchesRepositoryFiles, - ).toBe(false); - expect( - planMigration([ - reading({ - scopeId: 'workspace', - layer: 'workspace', - key: 'rslint.enable', - value: true, - }), - ]).touchesRepositoryFiles, - ).toBe(true); - }); - - it('orders the writes of one layer by the inventory, not by input order', () => { - const plan = planMigration([ - reading({ key: 'rstest.applyDiagnostic', value: false }), - reading({ key: 'rslint.enable', value: true }), - ]); - expect(plan.scopes[0]?.writes.map((write) => write.from)).toEqual([ - 'rslint.enable', - 'rstest.applyDiagnostic', - ]); - }); - - it('skips a window-scoped setting at the folder layer only', () => { - const folder = planMigration([ - folderReading('file:///w', 'w', 'rstest.applyDiagnostic', false), - ]); - expect(folder.writeCount).toBe(0); - expect(folder.skips[0]).toMatchObject({ reason: 'not-folder-scoped' }); - - const workspace = planMigration([ - reading({ - scopeId: 'workspace', - layer: 'workspace', - key: 'rstest.applyDiagnostic', - value: false, - }), - ]); - expect(workspace.writeCount).toBe(1); - }); - - it('keeps a resource-scoped setting at the folder layer', () => { - const plan = planMigration([ - folderReading('file:///w', 'w', 'rstest.nodeExecutable', '/usr/bin/node'), - ]); - expect(plan.writeCount).toBe(1); - }); -}); - -describe('planMigration — conflicts', () => { - it('never overwrites a new key the user already set in the same layer', () => { - const plan = planMigration([ - reading({ - key: 'rslint.trace.server', - value: 'messages', - targetValue: 'verbose', - }), - ]); - expect(plan.writeCount).toBe(0); - expect(plan.skips[0]).toMatchObject({ - from: 'rslint.trace.server', - to: 'rstack.rslint.trace.server', - reason: 'target-already-set', - }); - }); - - it('treats a conflict as layer-local', () => { - const plan = planMigration([ - reading({ key: 'rslint.enable', value: true, targetValue: false }), - reading({ - scopeId: 'workspace', - layer: 'workspace', - key: 'rslint.enable', - value: true, - }), - ]); - expect(plan.writeCount).toBe(1); - expect(plan.scopes[0]?.layer).toBe('workspace'); - expect(plan.skips).toHaveLength(1); - }); -}); - -describe('formatValue', () => { - it('renders values on a single line', () => { - expect(formatValue('local')).toBe('"local"'); - expect(formatValue(['a', 'b'])).toBe('["a","b"]'); - expect(formatValue(undefined)).toBe('undefined'); - }); - - it('truncates long values', () => { - const formatted = formatValue('x'.repeat(200)); - expect(formatted.length).toBe(60); - expect(formatted.endsWith('…')).toBe(true); - }); -}); - -describe('formatPreview', () => { - it('shows the old -> new mapping under its layer heading', () => { - const preview = formatPreview( - planMigration([ - reading({ key: 'rslint.trace.server', value: 'messages' }), - folderReading('file:///w/app', 'app', 'rstest.rstestPackagePath', '/x'), - ]), - ); - expect(preview).toContain('User Settings'); - expect(preview).toContain( - 'rslint.trace.server -> rstack.rslint.trace.server', - ); - expect(preview).toContain('Folder Settings — app'); - expect(preview).toContain( - 'rstest.rstestPackagePath -> rstack.rstest.rstestPackagePath', - ); - }); - - it('lists what was left untouched and why', () => { - const preview = formatPreview( - planMigration([ - folderReading('file:///w/app', 'app', 'rstest.applyDiagnostic', false), - ]), - ); - expect(preview).toContain('Left untouched'); - expect(preview).toContain('window-scoped setting'); - }); - - it('is empty when there is nothing to report', () => { - expect(formatPreview(planMigration([]))).toBe(''); - }); -}); - -describe('layerLabel', () => { - it('names every layer', () => { - expect(layerLabel('user')).toBe('User Settings'); - expect(layerLabel('workspace')).toBe('Workspace Settings'); - expect(layerLabel('folder', 'pkg')).toBe('Folder Settings — pkg'); - }); -}); diff --git a/packages/vscode/tests/statusBar.test.ts b/packages/vscode/tests/statusBar.test.ts index 4dc46ba..3b5d518 100644 --- a/packages/vscode/tests/statusBar.test.ts +++ b/packages/vscode/tests/statusBar.test.ts @@ -110,11 +110,10 @@ describe('StatusBar hover', () => { it('shows one row per stack and the shell actions when nothing is detected', () => { const { html } = build(); expect(stackRows(html())).toHaveLength(3); - // Divider, spacer, and the three shell actions still follow the stacks. - expect(rowsOf(html())).toHaveLength(8); + // Divider, spacer, and the two shell actions still follow the stacks. + expect(rowsOf(html())).toHaveLength(7); expect(html()).toContain('command:rstack.restart'); expect(html()).toContain('command:rstack.showOutput'); - expect(html()).toContain('command:rstack.migrateSettings'); }); it('spells a failure message out below the table, named by its stack', () => { @@ -255,7 +254,7 @@ describe('StatusBar hover', () => { // `disabled` does spell its reason out — when it has one. Absent, it must // not leave a stray notice (or its divider) behind. expect(noticesOf(html())).toEqual([]); - expect(rowsOf(html())).toHaveLength(8); + expect(rowsOf(html())).toHaveLength(7); }); it('spells out why a stack was deliberately turned off', () => { From 804450e7404ddd8a6312cc4d8330a2d60c5bb981 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 18 Aug 2026 15:24:18 +0800 Subject: [PATCH 2/2] docs(adr): stop describing rstest.nodeExecutable as migrated ADR 0001 and 0002 said the standalone Rstest extension's `rstest.nodeExecutable` "migrates to" `rstack.nodeExecutable`; the migration was removed in #15, so the parentheticals now state only that the legacy key had the same role. --- docs/adr/0001-node-runtime-selection.md | 2 +- docs/adr/0002-fmt-lsp-on-user-node-runtime.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0001-node-runtime-selection.md b/docs/adr/0001-node-runtime-selection.md index 8aeb9ca..94a66e4 100644 --- a/docs/adr/0001-node-runtime-selection.md +++ b/docs/adr/0001-node-runtime-selection.md @@ -43,7 +43,7 @@ This decision was written for one path, the rstest worker, and named two others ## Consequences -- An explicit `rstack.nodeExecutable` (shared with the fmt server since ADR 0002; the standalone Rstest extension's `rstest.nodeExecutable` migrates to it) is always honoured, but it is probed too: falling short of the floor produces a status, not a refusal. The escape hatch stays an escape hatch; it stops being silent. +- An explicit `rstack.nodeExecutable` (shared with the fmt server since ADR 0002; the standalone Rstest extension's `rstest.nodeExecutable` had this role — it is not migrated, #15) is always honoured, but it is probed too: falling short of the floor produces a status, not a refusal. The escape hatch stays an escape hatch; it stops being silent. - A below-floor configured executable is reported through the same status as "no runtime found at all", so the two messages must state their _consequence_ explicitly — one says tests will not run, the other says the extension is running with it anyway. - The interactive-shell probe is the recovery path and does not exist on Windows (no `-i -c` equivalent reliably evaluates a user's profile across cmd and PowerShell). A Windows user whose PATH `node` is below the floor gets the failure status with no second candidate. - `NODE_OPTIONS` can carry `--no-strip-types`, which defeats the floor on any version. Deliberately not detected: the same setting breaks `rs test` in the terminal, so the editor failing identically is correct, and special-casing one flag would be permanent trivia bought for one diagnostic. diff --git a/docs/adr/0002-fmt-lsp-on-user-node-runtime.md b/docs/adr/0002-fmt-lsp-on-user-node-runtime.md index 3f361c3..839ff08 100644 --- a/docs/adr/0002-fmt-lsp-on-user-node-runtime.md +++ b/docs/adr/0002-fmt-lsp-on-user-node-runtime.md @@ -16,7 +16,7 @@ One rule across both stacks now: the workspace folder root is the config root. A ## Why the User Node runtime -The server loads the project's `rstack.config.*` through `@rstackjs/load-config` with `loader: 'native'` — the exact path ADR 0001 analysed to set the worker floor, with no jiti fallback and no `process.features.typescript` consultation. So fmt is not a new case: it is the second caller of the same decision, and it takes the floor, the candidate order (PATH `node`, then the user's interactive shell) and the failure reporting out of the one shared module, `shared/nodeResolution.ts`. The escape hatch is shared too — `rstack.nodeExecutable`, resource-scoped, honoured whenever it is set and probed anyway, advisory-only. A user pinning a Node for one tool means it for the toolchain, so there is one setting rather than one per stack (the standalone Rstest extension's `rstest.nodeExecutable` migrates to it). +The server loads the project's `rstack.config.*` through `@rstackjs/load-config` with `loader: 'native'` — the exact path ADR 0001 analysed to set the worker floor, with no jiti fallback and no `process.features.typescript` consultation. So fmt is not a new case: it is the second caller of the same decision, and it takes the floor, the candidate order (PATH `node`, then the user's interactive shell) and the failure reporting out of the one shared module, `shared/nodeResolution.ts`. The escape hatch is shared too — `rstack.nodeExecutable`, resource-scoped, honoured whenever it is set and probed anyway, advisory-only. A user pinning a Node for one tool means it for the toolchain, so there is one setting rather than one per stack (the standalone Rstest extension's `rstest.nodeExecutable` had this role — it is not migrated, #15). Falling back to the VS Code Node runtime stays rejected — ADR 0001's load-bearing "no", unchanged. It is worth naming that the old fmt path did exactly that: `process.execPath` with `ELECTRON_RUN_AS_NODE=1`, loading the user's config on Electron's Node, with no floor and no preflight. Moving the server onto a User Node runtime is what takes fmt off that ADR's debt list.