From a5f48da1f6bd1e8b7c927f03a02110d567234050 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 16 Oct 2026 16:16:56 +0800 Subject: [PATCH 1/5] feat(vscode): add rstack.rstackCli.packagePath setting Let a workspace point at an rstack installation the node_modules walk-up cannot see, e.g. a monorepo that installs its tooling under infra/ while rstack.config.* sits at the root. One resource-scoped setting covers the fmt server and the lint and test stacks bridged through rstack.config.*; a path that does not resolve is reported as a setting error, not as a missing install. The root config's own `import 'rstack'` cannot see that installation either, so while the setting is set every spawned process that loads rstack.config.* preloads a small resolve hook (NODE_OPTIONS=--import) that resolves `rstack` and `rstack/*` from inside the configured package through Node's own resolver. Configs that need load-config's jiti fallback still require Node >= 24.15, where module.registerHooks covers require.resolve. Closes #103 --- .gitignore | 1 + packages/vscode/.vscodeignore | 1 + packages/vscode/AGENTS.md | 2 +- packages/vscode/README.md | 1 + .../fixtures/rstack-cli-package-path/.nvmrc | 1 + .../.vscode/settings.json | 5 + .../infra/package.json | 13 ++ .../infra/pnpm-workspace.yaml | 10 ++ .../rstack-cli-package-path/package.json | 7 + .../rstack-cli-package-path/rstack.config.ts | 23 +++ .../rstack-cli-package-path/src/index.ts | 6 + .../src/needs-format.ts | 1 + .../tests/basic.test.ts | 7 + packages/vscode/e2e/run.mjs | 11 +- packages/vscode/e2e/runTest.ts | 56 +++++- packages/vscode/e2e/setupFixtures.mjs | 7 + .../suite-rstack-cli-package-path/index.ts | 3 + .../rstack-cli-package-path.test.ts | 159 ++++++++++++++++++ packages/vscode/e2e/suite/fmt.test.ts | 19 +-- packages/vscode/e2e/suite/helpers.ts | 23 +++ packages/vscode/package.json | 13 ++ packages/vscode/rslib.config.mts | 8 + packages/vscode/src/shared/notInstalled.ts | 7 + packages/vscode/src/shared/rstackPackage.ts | 79 +++++++++ .../vscode/src/shared/rstackPackageSetting.ts | 45 +++++ .../vscode/src/shared/rstackResolveHook.ts | 30 ++++ .../src/shared/rstackResolveHookPreload.mjs | 39 +++++ packages/vscode/src/stacks/fmt/index.ts | 40 ++++- .../vscode/src/stacks/lint/CoreResolver.ts | 3 + packages/vscode/src/stacks/lint/Rslint.ts | 6 + .../vscode/src/stacks/lint/RuntimeManager.ts | 8 +- packages/vscode/src/stacks/lint/index.ts | 7 +- packages/vscode/src/stacks/lint/resolution.ts | 39 ++++- packages/vscode/src/stacks/test/bridge.ts | 39 ++++- packages/vscode/src/stacks/test/index.ts | 8 +- packages/vscode/src/stacks/test/master.ts | 12 +- packages/vscode/src/stacks/test/project.ts | 6 + packages/vscode/tests/extension.test.ts | 1 + .../vscode/tests/shared/notInstalled.test.ts | 6 +- .../vscode/tests/shared/rstackPackage.test.ts | 131 +++++++++++++++ .../tests/shared/rstackResolveHook.test.ts | 15 ++ .../vscode/tests/stacks/fmt/runtime.test.ts | 1 + .../tests/stacks/lint/resolution.test.ts | 68 ++++++++ .../vscode/tests/stacks/test/bridge.test.ts | 13 ++ 44 files changed, 940 insertions(+), 40 deletions(-) create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/.nvmrc create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/.vscode/settings.json create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/package.json create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-workspace.yaml create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/package.json create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/rstack.config.ts create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/src/index.ts create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/src/needs-format.ts create mode 100644 packages/vscode/e2e/fixtures/rstack-cli-package-path/tests/basic.test.ts create mode 100644 packages/vscode/e2e/suite-rstack-cli-package-path/index.ts create mode 100644 packages/vscode/e2e/suite-rstack-cli-package-path/rstack-cli-package-path.test.ts create mode 100644 packages/vscode/src/shared/rstackPackage.ts create mode 100644 packages/vscode/src/shared/rstackPackageSetting.ts create mode 100644 packages/vscode/src/shared/rstackResolveHook.ts create mode 100644 packages/vscode/src/shared/rstackResolveHookPreload.mjs create mode 100644 packages/vscode/tests/shared/rstackPackage.test.ts create mode 100644 packages/vscode/tests/shared/rstackResolveHook.test.ts diff --git a/.gitignore b/.gitignore index b2f67e3..33093f8 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,7 @@ packages/vscode/.playground/ # their generated node_modules directories and lockfiles stay disposable. packages/vscode/e2e/fixtures/*/node_modules/ packages/vscode/e2e/fixtures/*/pnpm-lock.yaml +packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-lock.yaml packages/vscode/e2e/lint/fixtures/pnpm-lock.yaml packages/vscode/e2e/lint/fixtures/*/pnpm-lock.yaml packages/vscode/e2e/rstest/fixtures/*/pnpm-lock.yaml diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore index 8b657f0..b5ff7c4 100644 --- a/packages/vscode/.vscodeignore +++ b/packages/vscode/.vscodeignore @@ -2,6 +2,7 @@ # dist/ — never source maps. ** !dist/**/*.js +!dist/rstack-resolve-hook.mjs !dist/**/*.node !dist/*.licenses.json !icon.png diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index fb3408b..209f155 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -12,7 +12,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## The twelve 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.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing. +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). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing. Every consumer of the `rstack` package (fmt, bridged lint, bridged test) also reads the shared `rstack.rstackCli.packagePath` (`shared/rstackPackage.ts`), which replaces the walk-up for `rstack` itself; the tool core is still resolved from the directory it selects unless `corePath` is set. Like `corePath`, it changes resolution only, never detection. While it is set, every process that loads `rstack.config.*` (fmt server, bridged lint and Rstest workers) also preloads a resolve hook so the config's own `import 'rstack'` reaches the same package (`shared/rstackResolveHook.ts`). The hook bridges to Node's resolver; never replace it with `NODE_PATH`, `JITI_ALIAS` or anything else that imitates a package-manager shim or names files inside `rstack`. 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. Upstream's plugin-host failure toast becomes a status verdict (adaptation 7). Rslint LSP tracing shares the **Rstack: Rslint** Output channel to preserve the four-channel cap; see `stacks/lint/index.ts`. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 7baf3bf..2563603 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -77,6 +77,7 @@ All settings live under the unified `rstack.*` namespace. There are no `rslint.* | Setting | Default | Description | | --- | --- | --- | | `rstack.nodeExecutable` | — | Node binary used for the processes that load your project: the lint worker, test worker and `rs fmt` language server. Empty means the extension picks one (`PATH` first, then the `node` your interactive shell resolves). | +| `rstack.rstackCli.packagePath` | — | Path to an `rstack` package directory or its `package.json` (e.g. `infra/node_modules/rstack`); relative paths resolve from the workspace folder and `${workspaceFolder}` is supported. Used by the formatter, and by Rslint and Rstest when a root `rstack.config.*` drives them. Use only when `rstack` is not installed in a `node_modules` above the workspace folder. The config's own `import 'rstack'` resolves to the same package. On Node 22 and Node 24 below 24.15, this works only for configs Node can load natively: no `enum`, `namespace` or parameter properties, and relative imports with their file extension. | | `rstack.rslint.enable` | `true` | Enable/disable the Rslint integration. | | `rstack.rslint.corePath` | — | Path to an `@rslint/core` package directory or its `package.json`; relative paths resolve from the workspace folder. Use only to override automatic resolution. | | `rstack.rslint.trace.server` | `off` | LSP trace level (`off` / `messages` / `verbose`). | diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/.nvmrc b/packages/vscode/e2e/fixtures/rstack-cli-package-path/.nvmrc new file mode 100644 index 0000000..d875019 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/.nvmrc @@ -0,0 +1 @@ +26.11.1 diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/.vscode/settings.json b/packages/vscode/e2e/fixtures/rstack-cli-package-path/.vscode/settings.json new file mode 100644 index 0000000..6df6d1b --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + // The setting under test (#103): the only way the extension can find the + // `rstack` installed under `infra/`. + "rstack.rstackCli.packagePath": "infra/node_modules/rstack" +} diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/package.json b/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/package.json new file mode 100644 index 0000000..95a275c --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/package.json @@ -0,0 +1,13 @@ +{ + "name": "rstack-editor-fixture-rstack-cli-package-path-infra", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "The tooling install of the rstack-cli-package-path fixture, outside the folder root's walk-up.", + "dependencies": { + "rstack": "0.8.4" + }, + "devDependencies": { + "jiti": "^2.7.0" + } +} diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-workspace.yaml b/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-workspace.yaml new file mode 100644 index 0000000..0330b39 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +# E2E fixture install config; rationale in packages/vscode/e2e/setupFixtures.mjs. +minimumReleaseAgeExclude: + - rstack + - '@rslint/*' + - '@rstest/core' + - '@rsbuild/core' + - '@rslib/core' + - '@rstackjs/*' + - rsbuild-plugin-dts + - '@rspack/*' diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/package.json b/packages/vscode/e2e/fixtures/rstack-cli-package-path/package.json new file mode 100644 index 0000000..7b494b9 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/package.json @@ -0,0 +1,7 @@ +{ + "name": "rstack-editor-fixture-rstack-cli-package-path", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture (#103): a root `rstack.config.ts` whose `rstack` is installed only under `infra/`." +} diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/rstack.config.ts b/packages/vscode/e2e/fixtures/rstack-cli-package-path/rstack.config.ts new file mode 100644 index 0000000..480de34 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/rstack.config.ts @@ -0,0 +1,23 @@ +// Rstack configuration guide: https://rstack.rs/config +// +// The #103 layout: this config sits at the workspace folder root, but +// `rstack` is installed only under `infra/`, where no `node_modules` walk-up +// from the root can see it. `.vscode/settings.json` points +// `rstack.rstackCli.packagePath` at that installation, which must light all +// three stacks. The E2E harness copies the fixture out of this repository first, +// so the repository's own `node_modules/rstack` cannot satisfy the walk-up. +import { define } from 'rstack'; + +define.lint([ + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]); + +define.test({ + name: 'rstack-editor-fixture-rstack-cli-package-path', + include: ['tests/**/*.test.ts'], +}); diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/index.ts b/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/index.ts new file mode 100644 index 0000000..ef833c6 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/index.ts @@ -0,0 +1,6 @@ +// A lintable issue for the `no-debugger` rule configured through +// `define.lint()` in `rstack.config.ts`. +export function trace(value: unknown): unknown { + debugger; + return value; +} diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/needs-format.ts b/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/needs-format.ts new file mode 100644 index 0000000..01bb788 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/needs-format.ts @@ -0,0 +1 @@ +const answer={value:'42'}; diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/tests/basic.test.ts b/packages/vscode/e2e/fixtures/rstack-cli-package-path/tests/basic.test.ts new file mode 100644 index 0000000..4db5dbb --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/tests/basic.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from '@rstest/core'; + +// Discovered through `define.test()` in `rstack.config.ts`, not through an +// `rstest.config.*` file. +test('trims a string', () => { + expect(' rstack '.trim()).toBe('rstack'); +}); diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index 781eda1..e230089 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -24,9 +24,16 @@ const SLICES = [ }, { // The shell/detection/fmt suites (`e2e/suite/`) over the multi-root - // workspace of the three shared fixtures. + // workspace of the three shared fixtures, plus the single-folder + // launches in `e2e/runTest.ts`. name: 'vscode', - fixtures: ['rslint', 'rstest', 'rstack', 'fmt-missing-config-dependency'], + fixtures: [ + 'rslint', + 'rstest', + 'rstack', + 'fmt-missing-config-dependency', + 'rstack-cli-package-path', + ], entry: 'tests-dist/e2e/runTest.js', compile: true, }, diff --git a/packages/vscode/e2e/runTest.ts b/packages/vscode/e2e/runTest.ts index dad4d53..d485fbf 100644 --- a/packages/vscode/e2e/runTest.ts +++ b/packages/vscode/e2e/runTest.ts @@ -6,7 +6,7 @@ * control to `suite/index.ts` inside the extension host. */ import { createHash } from 'node:crypto'; -import { existsSync, mkdtempSync } from 'node:fs'; +import { cpSync, existsSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { runTests } from '@vscode/test-electron'; @@ -110,6 +110,60 @@ async function main() { workspace: path.join(fixturesDir, 'fmt-missing-config-dependency'), profileSuffix: 'fmt-missing-config-dependency', }); + await launchRstackCliPackagePath(extensionDevelopmentPath, fixturesDir); +} + +/** + * The #103 layout must run outside this repository: a walk-up from a fixture + * inside the checkout would reach the repository's own `node_modules/rstack`, + * which would satisfy all three stacks without the setting under test. The + * copy keeps the layout and links `infra/node_modules` back to the installed + * fixture, so `rstack` stays reachable only through + * `rstack.rstackCli.packagePath`. + */ +async function launchRstackCliPackagePath( + extensionDevelopmentPath: string, + fixturesDir: string, +): Promise { + const fixture = path.join(fixturesDir, 'rstack-cli-package-path'); + const installed = path.join(fixture, 'infra', 'node_modules'); + if (!existsSync(installed)) { + throw new Error( + 'the rstack-cli-package-path E2E fixture is not installed — run `pnpm test:e2e:fixtures`.', + ); + } + // Short on Unix: VS Code appends socket names to paths under the workspace. + const sandbox = mkdtempSync( + path.join(process.platform === 'win32' ? tmpdir() : '/tmp', 'rsp-'), + ); + const workspace = path.join(sandbox, 'w'); + try { + cpSync(fixture, workspace, { + recursive: true, + filter: (source) => path.basename(source) !== 'node_modules', + }); + symlinkSync( + installed, + path.join(workspace, 'infra', 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await launch({ + extensionDevelopmentPath, + extensionTestsPath: path.resolve( + __dirname, + './suite-rstack-cli-package-path/index', + ), + workspace, + profileSuffix: 'rstack-cli-package-path', + }); + } finally { + rmSync(sandbox, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); + } } main().catch((error) => { diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index 05a6845..0a6d673 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -49,6 +49,13 @@ export const FIXTURES = { FIXTURES_DIR, 'fmt-missing-config-dependency', ), + // The #103 layout installs only its `infra/` tooling directory; the folder + // root has no dependencies of its own. + 'rstack-cli-package-path': path.join( + FIXTURES_DIR, + 'rstack-cli-package-path', + 'infra', + ), 'rstest-workspace-1': path.join(here, 'rstest', 'fixtures', 'workspace-1'), 'rstest-workspace-2': path.join(here, 'rstest', 'fixtures', 'workspace-2'), lint: path.join(here, 'lint', 'fixtures'), diff --git a/packages/vscode/e2e/suite-rstack-cli-package-path/index.ts b/packages/vscode/e2e/suite-rstack-cli-package-path/index.ts new file mode 100644 index 0000000..e86c367 --- /dev/null +++ b/packages/vscode/e2e/suite-rstack-cli-package-path/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(__dirname); diff --git a/packages/vscode/e2e/suite-rstack-cli-package-path/rstack-cli-package-path.test.ts b/packages/vscode/e2e/suite-rstack-cli-package-path/rstack-cli-package-path.test.ts new file mode 100644 index 0000000..967d707 --- /dev/null +++ b/packages/vscode/e2e/suite-rstack-cli-package-path/rstack-cli-package-path.test.ts @@ -0,0 +1,159 @@ +// The #103 layout end to end: `rstack.config.ts` at the folder root, `rstack` +// installed only under `infra/`, and `rstack.rstackCli.packagePath` pointing at +// it (`.vscode/settings.json`). `e2e/runTest.ts` runs this from a copy outside +// the repository, so no `node_modules` walk-up can find any `rstack`: every +// stack that works here found it through the setting. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import { findPackageJsonUncached } from '../../src/shared/packageResolve'; +import { RSTACK_PACKAGE_PATH_SETTING } from '../../src/shared/rstackPackage'; +import type { RstackExtensionExports } from '../../src/types'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics, +} from '../lint/utils/diagnostics'; +import { + createCollectingMockRun, + currentRstestExports, + getTestItemByLabels, + waitFor, +} from '../rstest/suite/helpers'; +import { applyEdits, eventually } from '../suite/helpers'; + +const folder = (): vscode.WorkspaceFolder => { + const [workspaceFolder] = vscode.workspace.workspaceFolders ?? []; + assert.ok(workspaceFolder, 'the fixture workspace folder is unavailable'); + return workspaceFolder; +}; + +/** Where `infra/node_modules/rstack` really lives (the installed fixture). */ +const infraStore = (): string => + fs.realpathSync(path.join(folder().uri.fsPath, 'infra', 'node_modules')); + +let api: RstackExtensionExports; + +suite('rstack.rstackCli.packagePath', () => { + suiteSetup(async () => { + const extension = + vscode.extensions.getExtension('rstack.rstack'); + assert.ok(extension, 'rstack.rstack is not installed in the test host'); + api = await extension.activate(); + // No `rstack` is reachable by walk-up from the sandbox copy; the precondition + // that makes every assertion below about the setting. + assert.equal( + findPackageJsonUncached('rstack', folder().uri.fsPath), + undefined, + 'a walk-up from the folder root reaches an rstack installation', + ); + }); + + test('formats through the configured rstack', async () => { + const exports = await api.whenStackActive('fmt'); + const folderStates = exports.folderStates as () => Record; + await eventually(() => { + assert.equal(folderStates()[folder().uri.fsPath], 'running'); + }, 'the rs fmt server to start'); + + const uri = vscode.Uri.joinPath(folder().uri, 'src', 'needs-format.ts'); + const document = await vscode.workspace.openTextDocument(uri); + // Retry: until the server's client registers its provider, VS Code's + // built-in TypeScript formatter answers with different text. + await eventually(async () => { + const edits = await vscode.commands.executeCommand( + 'vscode.executeFormatDocumentProvider', + uri, + { tabSize: 2, insertSpaces: true }, + ); + assert.ok(edits && edits.length > 0, 'expected formatting edits'); + // Prettier's quote normalization: VS Code's built-in formatter cannot + // produce it, so a fallback cannot satisfy this. + assert.equal( + applyEdits(document, edits), + 'const answer = { value: "42" };\n', + ); + }, 'rs fmt to format the document'); + }); + + test('lints through the configured rstack and its @rslint/core', async () => { + const document = await vscode.workspace.openTextDocument( + vscode.Uri.joinPath(folder().uri, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + await waitForRslintDiagnostics(document, (diagnostics) => + diagnostics.some((diagnostic) => + diagnosticRuleIdIncludes(diagnostic, 'no-debugger'), + ), + ); + + const lint = api.getStackExports('rslint'); + assert.ok(lint, 'the Rslint stack should be active'); + const runtimes = ( + lint.getRuntimeStates as () => ReadonlyMap + )(); + // The runtime key carries the core and shim paths: both come from the + // configured installation's store. + assert.ok( + [...runtimes.keys()].some((key) => key.includes(infraStore())), + `expected a runtime on ${infraStore()}, got: ${[...runtimes.keys()].join(' | ')}`, + ); + }); + + test('discovers and runs bridged tests through the configured rstack', async () => { + const { rstestInstance, item } = await waitFor( + () => { + const rstestInstance = currentRstestExports(); + return { + rstestInstance, + item: getTestItemByLabels(rstestInstance.testController.items, [ + 'tests', + 'basic.test.ts', + ]), + }; + }, + { timeoutMs: 60_000 }, + ); + + const sourceUri = vscode.Uri.joinPath( + folder().uri, + 'rstack.config.ts', + ).toString(); + const rstestPath = rstestInstance.getResolvedRstestPath(sourceUri); + assert.ok( + rstestPath?.startsWith(infraStore()), + `expected rstack's own @rstest/core under ${infraStore()}, got: ${rstestPath}`, + ); + + const collecting = createCollectingMockRun(); + void rstestInstance.startTestRun( + new vscode.TestRunRequest([item], undefined, rstestInstance.runProfile), + new vscode.CancellationTokenSource().token, + false, + collecting.createMockRun, + ); + await collecting.ended; + assert.equal(collecting.failedMessages.length, 0, collecting.output); + assert.deepEqual( + collecting.passedItems.map((passed) => passed.label).sort(), + ['basic.test.ts', 'trims a string'], + ); + }); + + test('falls back to not installed once the setting is cleared', async () => { + // The setting is a restart trigger; the rebuilt fmt stack walks up again + // and finds nothing. + await vscode.workspace + .getConfiguration(undefined, folder().uri) + .update( + RSTACK_PACKAGE_PATH_SETTING, + undefined, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + await eventually(async () => { + const exports = await api.whenStackActive('fmt'); + const folderStates = exports.folderStates as () => Record; + assert.equal(folderStates()[folder().uri.fsPath], 'disabled'); + }, 'the fmt stack to report rstack as not installed'); + }); +}); diff --git a/packages/vscode/e2e/suite/fmt.test.ts b/packages/vscode/e2e/suite/fmt.test.ts index fe0b006..e537ae4 100644 --- a/packages/vscode/e2e/suite/fmt.test.ts +++ b/packages/vscode/e2e/suite/fmt.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import * as vscode from 'vscode'; import type { RstackExtensionExports } from '../../src/types'; -import { eventually } from './helpers'; +import { applyEdits, eventually } from './helpers'; const EXTENSION_ID = 'rstack.rstack'; /** @@ -80,21 +80,12 @@ suite('fmt', () => { ); assert.ok(edits && edits.length > 0, 'the formatter returned no edits'); - let applied = document.getText(); - // The command post-processes our single minimal edit through VS Code's - // `computeMoreMinimalEdits`, so apply its result from the end backwards. - for (const edit of [...edits].sort( - (left, right) => - document.offsetAt(right.range.start) - - document.offsetAt(left.range.start), - )) { - const start = document.offsetAt(edit.range.start); - const end = document.offsetAt(edit.range.end); - applied = applied.slice(0, start) + edit.newText + applied.slice(end); - } // The quote normalization is Prettier-specific, so VS Code's built-in // TypeScript formatter cannot mask a failed Rstack provider via fallback. - assert.equal(applied, 'const answer = { value: "42" };\n'); + assert.equal( + applyEdits(document, edits), + 'const answer = { value: "42" };\n', + ); return document; }, 'the rs fmt language server to return a Prettier-formatted edit'); diff --git a/packages/vscode/e2e/suite/helpers.ts b/packages/vscode/e2e/suite/helpers.ts index 559582e..3786f65 100644 --- a/packages/vscode/e2e/suite/helpers.ts +++ b/packages/vscode/e2e/suite/helpers.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import type * as vscode from 'vscode'; export const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -35,3 +36,25 @@ export const eventually = async ( await delay(intervalMs); } }; + +/** + * Applies the edits a `vscode.executeFormatDocumentProvider` call returned. + * The command post-processes a provider's edits through VS Code's + * `computeMoreMinimalEdits`, so they are applied from the end backwards. + */ +export const applyEdits = ( + document: vscode.TextDocument, + edits: readonly vscode.TextEdit[], +): string => { + let applied = document.getText(); + for (const edit of [...edits].sort( + (left, right) => + document.offsetAt(right.range.start) - + document.offsetAt(left.range.start), + )) { + const start = document.offsetAt(edit.range.start); + const end = document.offsetAt(edit.range.end); + applied = applied.slice(0, start) + edit.newText + applied.slice(end); + } + return applied; +}; diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 9b46314..8ccd3b6 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -132,6 +132,19 @@ } } }, + { + "type": "object", + "title": "Rstack › Rstack CLI", + "properties": { + "rstack.rstackCli.packagePath": { + "order": 0, + "type": "string", + "default": "", + "scope": "resource", + "markdownDescription": "Path to an `rstack` installation, either its package directory or its `package.json` file (for example `infra/node_modules/rstack`). Relative paths are resolved from the workspace folder. Supports the `${workspaceFolder}` placeholder. It applies wherever the extension uses `rstack`: the `rs fmt` formatter, and Rslint and Rstest when a root `rstack.config.*` drives them, which then resolve `@rslint/core` and `@rstest/core` from this installation. Tools still run from the workspace folder, so its `rstack.config.*` applies. Use this override only when `rstack` is not installed in a `node_modules` above the workspace folder." + } + } + }, { "type": "object", "title": "Rstack › Rslint", diff --git a/packages/vscode/rslib.config.mts b/packages/vscode/rslib.config.mts index 038a7c6..dcdbc4d 100644 --- a/packages/vscode/rslib.config.mts +++ b/packages/vscode/rslib.config.mts @@ -72,6 +72,14 @@ const libs: LibConfig[] = [ from: yukuBindingPath, to: `${yukuBindingPackage}/${path.basename(yukuBindingPath)}`, }, + { + // Preloaded via `NODE_OPTIONS=--import`, so a file of its own. + from: path.join( + rootDir, + 'src/shared/rstackResolveHookPreload.mjs', + ), + to: 'rstack-resolve-hook.mjs', + }, { // The VSIX must carry a LICENSE (`.vscodeignore` keeps it), and // the workspace root LICENSE is the single source of truth. The diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index 96a5827..f71fd0f 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -1,4 +1,5 @@ import { MessageLatch } from './messageLatch'; +import { RSTACK_PACKAGE_PATH_SETTING } from './rstackPackage'; import { COMMAND_CATEGORY, STACK_LABELS, @@ -87,6 +88,8 @@ export class NotInstalledEpisode { * The output-channel line: where the stack looked, plus the stack's own * consequence — the same shape as the shared Node preflight message * (adaptation 6), where each caller appends what the state means for it. + * A missing `rstack` also names the override for an installation the walk-up + * cannot see (#103), once here for every stack that resolves it. */ export const formatNotInstalledLog = ( packageName: string, @@ -96,4 +99,8 @@ export const formatNotInstalledLog = ( ): string => `${packageName} is not installed in ${folderName} (node_modules missing); searched from ${searchedFrom}${ consequence ? `; ${consequence}` : '' + }${ + packageName === 'rstack' + ? `; if it is installed elsewhere, set "${RSTACK_PACKAGE_PATH_SETTING}" to it` + : '' }`; diff --git a/packages/vscode/src/shared/rstackPackage.ts b/packages/vscode/src/shared/rstackPackage.ts new file mode 100644 index 0000000..5aa1e29 --- /dev/null +++ b/packages/vscode/src/shared/rstackPackage.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { findPackageJsonUncached, readPackageJson } from './packageResolve'; + +/** + * Where an `rstack` (rstack-cli) installation lives that the default walk-up + * cannot see — e.g. a monorepo that installs its tooling under `infra/` while + * `rstack.config.*` sits at the root (#103). One setting for every consumer + * of the package: the `rs fmt` server, and the lint and test stacks when a + * root `rstack.config.*` bridges them. Detection does not read it — it only + * changes where resolution looks, like the per-tool `corePath` overrides. + */ +export const RSTACK_PACKAGE_PATH_SETTING = 'rstack.rstackCli.packagePath'; + +export type RstackPackageResolution = + | { readonly kind: 'found'; readonly packageJsonPath: string } + | { readonly kind: 'not-installed' } + | { readonly kind: 'invalid-setting'; readonly message: string }; + +export interface ResolveRstackPackageOptions { + /** + * The setting's value, `${workspaceFolder}` already expanded; unset or + * blank means "walk up from `searchFrom`". + */ + readonly configured: string | undefined; + /** The workspace folder root; relative settings resolve from it. */ + readonly folderRoot: string; + /** Where the default `node_modules` walk-up starts. */ + readonly searchFrom: string; +} + +const invalidSetting = ( + configuredPath: string, + problem: string, +): RstackPackageResolution => ({ + kind: 'invalid-setting', + message: `The "${RSTACK_PACKAGE_PATH_SETTING}" setting points at ${configuredPath}, which ${problem}. Update the setting to an installed "rstack" package directory or its package.json, or clear it to use the nearest installation.`, +}); + +/** + * Locates `rstack/package.json` for one consumer. A configured path wins and + * is never second-guessed by the walk-up: a setting that does not resolve is + * a mistake in the setting (`invalid-setting`), not a missing install, so it + * must not take the not-installed state (AGENTS.md). + * + * The returned path is a realpath, the same as `findPackageJsonUncached` + * returns: the lint and test stacks resolve their tool cores *from* the + * rstack directory, and under pnpm only the store path has rstack's own + * dependencies beside it. + */ +export const resolveRstackPackageJson = ({ + configured, + folderRoot, + searchFrom, +}: ResolveRstackPackageOptions): RstackPackageResolution => { + const value = configured?.trim(); + if (!value) { + const packageJsonPath = findPackageJsonUncached('rstack', searchFrom); + return packageJsonPath === undefined + ? { kind: 'not-installed' } + : { kind: 'found', packageJsonPath }; + } + + const resolved = path.resolve(folderRoot, value); + const configuredPath = value.endsWith('package.json') + ? resolved + : path.join(resolved, 'package.json'); + let packageJsonPath: string; + try { + if (!fs.statSync(configuredPath).isFile()) throw new Error('not a file'); + packageJsonPath = fs.realpathSync(configuredPath); + } catch { + return invalidSetting(configuredPath, 'does not exist'); + } + if (readPackageJson(packageJsonPath)?.name !== 'rstack') { + return invalidSetting(configuredPath, 'is not the "rstack" package'); + } + return { kind: 'found', packageJsonPath }; +}; diff --git a/packages/vscode/src/shared/rstackPackageSetting.ts b/packages/vscode/src/shared/rstackPackageSetting.ts new file mode 100644 index 0000000..49b9f1a --- /dev/null +++ b/packages/vscode/src/shared/rstackPackageSetting.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; +import vscode from 'vscode'; +import { expandWorkspaceFolder } from './nodeExecutableSetting'; +import { RSTACK_PACKAGE_PATH_SETTING } from './rstackPackage'; +import { withRstackResolveHook } from './rstackResolveHook'; + +/** + * Reads `rstack.rstackCli.packagePath` for a resource (a folder, or a document + * inside one — the setting is resource-scoped), `${workspaceFolder}` expanded. + * Returns `undefined` when unset or blank. Kept apart from `rstackPackage.ts` + * so the resolution itself stays testable without a `vscode` stub, the same + * split as `nodeExecutableSetting.ts` / `nodeResolution.ts`. + */ +export const getConfiguredRstackPackagePath = ( + folder: vscode.WorkspaceFolder, + resource: vscode.Uri = folder.uri, +): string | undefined => { + const value = vscode.workspace + .getConfiguration(undefined, resource) + .get(RSTACK_PACKAGE_PATH_SETTING); + if (typeof value !== 'string' || value.trim() === '') { + return undefined; + } + return expandWorkspaceFolder(value, folder); +}; + +/** + * The env for a spawned process that loads `rstack.config.*` with the rstack + * at `rstackDirectory`: `baseEnv` plus the resolve hook when the setting chose + * that rstack (`withRstackResolveHook`), otherwise `undefined`. + */ +export const rstackResolveHookEnv = ( + folder: vscode.WorkspaceFolder, + rstackDirectory: string | undefined, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv | undefined => + rstackDirectory === undefined || + getConfiguredRstackPackagePath(folder) === undefined + ? undefined + : withRstackResolveHook( + baseEnv, + path.join(rstackDirectory, 'package.json'), + // Copied beside the bundles by `rslib.config.mts`. + path.resolve(__dirname, 'rstack-resolve-hook.mjs'), + ); diff --git a/packages/vscode/src/shared/rstackResolveHook.ts b/packages/vscode/src/shared/rstackResolveHook.ts new file mode 100644 index 0000000..2c8cbac --- /dev/null +++ b/packages/vscode/src/shared/rstackResolveHook.ts @@ -0,0 +1,30 @@ +import { pathToFileURL } from 'node:url'; + +/** + * The spawn env of a process that loads `rstack.config.*` while + * `rstack.rstackCli.packagePath` selected the `rstack` it runs (#103). The + * config's own `import 'rstack'` resolves from the config file, where that + * installation is not visible; the preloaded hook + * (`rstackResolveHookPreload.mjs`) hands the specifier to Node's resolver + * from inside the configured package, so the config binds to the same + * installation. It bridges: nothing a package-manager shim does (`NODE_PATH`) + * and no file inside `rstack` is reproduced here. + * + * `NODE_OPTIONS` is appended to, so a user's own flags keep applying. The hook + * is passed as a `file:` URL because `NODE_OPTIONS` splits on spaces. + */ +export function withRstackResolveHook( + baseEnv: NodeJS.ProcessEnv, + packageJsonPath: string, + hookPath: string, +): NodeJS.ProcessEnv { + const importFlag = `--import ${pathToFileURL(hookPath).href}`; + return { + ...baseEnv, + NODE_OPTIONS: baseEnv.NODE_OPTIONS + ? `${baseEnv.NODE_OPTIONS} ${importFlag}` + : importFlag, + // Read by the hook. + RSTACK_EDITOR_RSTACK_PACKAGE_JSON: packageJsonPath, + }; +} diff --git a/packages/vscode/src/shared/rstackResolveHookPreload.mjs b/packages/vscode/src/shared/rstackResolveHookPreload.mjs new file mode 100644 index 0000000..a7f4c7b --- /dev/null +++ b/packages/vscode/src/shared/rstackResolveHookPreload.mjs @@ -0,0 +1,39 @@ +// Preloaded via `NODE_OPTIONS=--import` (why: `rstackResolveHook.ts`). +// Resolves `rstack` and `rstack/*` as if from inside the configured package, +// through its own `exports`; every other specifier passes through. Plain JS, +// copied to `dist/` by `rslib.config.mts`: Node loads it outside the bundle. +import nodeModule from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const packageJson = process.env.RSTACK_EDITOR_RSTACK_PACKAGE_JSON; + +if (packageJson) { + const anchorURL = pathToFileURL(packageJson).href; + const requireFromPackage = nodeModule.createRequire(packageJson); + let inside = false; + + nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if ( + inside || + !(specifier === 'rstack' || specifier.startsWith('rstack/')) + ) { + return nextResolve(specifier, context); + } + if (context.conditions?.includes('require')) { + // The CJS default resolver ignores a rewritten parentURL, so ask the + // package's own require() instead. On Node 22 and Node 24 below 24.15 + // `require.resolve` (jiti's path) never reaches this hook at all. + inside = true; + let filename; + try { + filename = requireFromPackage.resolve(specifier); + } finally { + inside = false; + } + return { url: pathToFileURL(filename).href, shortCircuit: true }; + } + return nextResolve(specifier, { ...context, parentURL: anchorURL }); + }, + }); +} diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index b2c9472..37ce5c1 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -28,10 +28,15 @@ import { NodePreflightError, resolveUserNodeOnce, } from '../../shared/nodeResolution'; +import { readPackageJson } from '../../shared/packageResolve'; import { - findPackageJsonUncached, - readPackageJson, -} from '../../shared/packageResolve'; + RSTACK_PACKAGE_PATH_SETTING, + resolveRstackPackageJson, +} from '../../shared/rstackPackage'; +import { + getConfiguredRstackPackagePath, + rstackResolveHookEnv, +} from '../../shared/rstackPackageSetting'; import { checkPackageVersion, formatVersionMismatch, @@ -363,8 +368,21 @@ class FmtFolderRuntime { this.#closing = false; this.setState('starting'); - const pkgJsonPath = findPackageJsonUncached('rstack', folderRoot); - if (!pkgJsonPath) { + const rstackPackage = resolveRstackPackageJson({ + configured: getConfiguredRstackPackagePath(this.folder), + folderRoot, + searchFrom: folderRoot, + }); + if (rstackPackage.kind === 'invalid-setting') { + // A wrong setting is fixed in the setting, not by an install, so it is + // `crashed` rather than the not-installed `disabled`. + this.setState('crashed', rstackPackage.message); + if (this.#packageEpisode.changed(rstackPackage.message)) { + context.output.error(rstackPackage.message); + } + return; + } + if (rstackPackage.kind === 'not-installed') { // The shell polls while this state remains disabled. The trailing restart // hint stays as the explicit fallback if recovery is delayed. this.setState('disabled', formatNotInstalledStatus('fmt', 'rstack')); @@ -376,6 +394,7 @@ class FmtFolderRuntime { return; } this.#packageEpisode.clear(); + const pkgJsonPath = rstackPackage.packageJsonPath; // One read for the version and the bin entry; `readPackageJson` re-reads // from disk by design, so a reinstall is picked up on the next start. @@ -403,6 +422,7 @@ class FmtFolderRuntime { nodeExecutable, [rsBinJs, 'fmt', '--lsp'], folderRoot, + rstackResolveHookEnv(this.folder, path.dirname(pkgJsonPath)), ); this.#owner = owner; const serverOptions: ServerOptions = async () => owner.start(); @@ -651,10 +671,14 @@ class FmtFolderRuntime { class FmtController implements StackController { readonly id = 'fmt' as const; /** - * The Node runtime is chosen once per server start, so a changed pin can only - * reach a live server through a rebuild of this controller. + * The Node runtime and the `rstack` package are chosen once per server + * start, so a changed pin can only reach a live server through a rebuild of + * this controller. */ - readonly restartOnSettings = [NODE_EXECUTABLE_SETTING]; + readonly restartOnSettings = [ + NODE_EXECUTABLE_SETTING, + RSTACK_PACKAGE_PATH_SETTING, + ]; #context: StackContext | undefined; /** The newest detection result, which `running` reports the reason from. */ diff --git a/packages/vscode/src/stacks/lint/CoreResolver.ts b/packages/vscode/src/stacks/lint/CoreResolver.ts index d499625..8665de5 100644 --- a/packages/vscode/src/stacks/lint/CoreResolver.ts +++ b/packages/vscode/src/stacks/lint/CoreResolver.ts @@ -68,6 +68,8 @@ export interface CoreResolutionRequest { readonly mode: RslintMode; /** `rstack.rslint.corePath`, read with the document's URI (resource scope). */ readonly corePath?: string; + /** `rstack.rstackCli.packagePath`, read the same way; bridged mode only. */ + readonly rstackPackagePath?: string; } function normalizeIdentity(filePath: string): string { @@ -140,6 +142,7 @@ export class CoreResolver { folderRoot: workspaceFolder.uri.fsPath, mode: request.mode, corePath: request.corePath, + rstackPackagePath: request.rstackPackagePath, documentDirectory: path.dirname(document.uri.fsPath), }); const installation = validateInstallation( diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 119ee55..70a8192 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -35,6 +35,7 @@ import { resolveUserNodeOnce, } from '../../shared/nodeResolution'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; +import { rstackResolveHookEnv } from '../../shared/rstackPackageSetting'; import type { StackState } from '../../types'; import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; @@ -490,6 +491,11 @@ export class Rslint implements Disposable { nodeExecutable, workerArgs, folderRoot, + // Adaptation 2: the bridged worker's shim loads `rstack.config.*`. + rstackResolveHookEnv( + this.workspaceFolder, + this.installation.rstackDirectory, + ), ); this.serverProcessOwner = serverProcessOwner; const serverOptions: ServerOptions = async () => serverProcessOwner.start(); diff --git a/packages/vscode/src/stacks/lint/RuntimeManager.ts b/packages/vscode/src/stacks/lint/RuntimeManager.ts index 5b55ce2..b1bee09 100644 --- a/packages/vscode/src/stacks/lint/RuntimeManager.ts +++ b/packages/vscode/src/stacks/lint/RuntimeManager.ts @@ -8,7 +8,8 @@ // - Detection is the gate. A document no detected config governs is never // linted, so the manager asks `documentMode` for the document's ownership // choice (ADR 0006) and detaches the document when it has none. -// - Settings live under `rstack.rslint.*`. +// - Settings live under `rstack.rslint.*`, plus the shared +// `rstack.rstackCli.packagePath`, which bridged mode reads to locate rstack. // - A failed resolution reports through the **folder status**, never a // `window.showWarningMessage`: this stack owns no UI chrome (adaptation 4). // The last-good rule is upstream's — the document keeps the runtime it @@ -20,6 +21,7 @@ // stopped same-key clients on reconcile — see AGENTS.md ("Ahead of upstream"). import { workspace, type TextDocument, type WorkspaceFolder } from 'vscode'; +import { getConfiguredRstackPackagePath } from '../../shared/rstackPackageSetting'; import type { CoreResolutionRequest, ResolvedCoreRuntime, @@ -243,6 +245,10 @@ export class RuntimeManager { const resolved = await this.resolver.resolve(document, workspaceFolder, { mode, corePath: configuration.get('corePath'), + rstackPackagePath: getConfiguredRstackPackagePath( + workspaceFolder, + document.uri, + ), }); return { action: 'bind', workspaceFolder, resolved }; } catch (error) { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 65fb8cc..11a9425 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -8,6 +8,7 @@ import type { } from '../../types'; import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; import { formatNotInstalledLog } from '../../shared/notInstalled'; +import { RSTACK_PACKAGE_PATH_SETTING } from '../../shared/rstackPackage'; import { CoreResolver, type ResolvedCoreRuntime } from './CoreResolver'; import { Logger } from './logger'; import { Rslint } from './Rslint'; @@ -59,7 +60,11 @@ class RslintController implements StackController { * full restart pass (a stack must never rebuild itself), which also clears * the shared User Node preflight memo — an in-place reconcile would keep it. */ - readonly restartOnSettings = [NODE_EXECUTABLE_SETTING, 'corePath']; + readonly restartOnSettings = [ + NODE_EXECUTABLE_SETTING, + RSTACK_PACKAGE_PATH_SETTING, + 'corePath', + ]; #context: StackContext | undefined; #logger: Logger | undefined; diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index cbfc233..f297090 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -4,6 +4,7 @@ import { findPackageJsonUncached, readPackageJson, } from '../../shared/packageResolve'; +import { resolveRstackPackageJson } from '../../shared/rstackPackage'; export type RslintMode = 'native' | 'bridged'; @@ -22,8 +23,8 @@ export type RslintResolutionErrorCode = export class RslintResolutionError extends Error { /** * The package whose absence caused this failure — set only by the - * not-installed throws (`resolveInstalledPackage`), the state the three - * stacks report uniformly (AGENTS.md). `missing-shim` and + * not-installed throws (`resolveInstalledPackage`, `resolveRstack`), the + * state the three stacks report uniformly (AGENTS.md). `missing-shim` and * `invalid-package` leave it unset on purpose: there the package is * present and the fix is an upgrade or the setting, not an install. */ @@ -188,6 +189,31 @@ function resolveConfiguredCore( return readPackageLocation('@rslint/core', fs.realpathSync(packageJsonPath)); } +function resolveRstack( + folderRoot: string, + configured: string | undefined, +): PackageLocation { + const resolution = resolveRstackPackageJson({ + configured, + folderRoot, + searchFrom: folderRoot, + }); + switch (resolution.kind) { + case 'found': + return readPackageLocation('rstack', resolution.packageJsonPath); + case 'not-installed': + throw new RslintResolutionError( + 'missing-rstack', + `Could not resolve rstack from ${folderRoot}`, + { missingPackage: 'rstack' }, + ); + case 'invalid-setting': + // Not `missing-rstack`: like a wrong `corePath`, the fix is the + // setting, so it must not take the not-installed state. + throw new RslintResolutionError('invalid-package', resolution.message); + } +} + function resolveInstalledPackage( packageName: 'rstack' | '@rslint/core', searchRoot: string, @@ -208,6 +234,12 @@ export interface ResolveRslintOptions { readonly folderRoot: string; readonly mode: RslintMode; readonly corePath?: string; + /** + * `rstack.rstackCli.packagePath`, `${workspaceFolder}` expanded. Bridged mode + * only: it replaces the folder-root walk-up for `rstack`, and the core hop + * still starts from the directory it selects. + */ + readonly rstackPackagePath?: string; /** * Where the native walk-up starts (per-document resolution, rslint #1617); * defaults to the folder root. Bridged mode starts at rstack's directory @@ -221,12 +253,13 @@ export function resolveRslint({ folderRoot, mode, corePath, + rstackPackagePath, documentDirectory, }: ResolveRslintOptions): RslintResolution { let rstack: PackageLocation | undefined; let shimPath: string | undefined; if (mode === 'bridged') { - rstack = resolveInstalledPackage('rstack', folderRoot, 'missing-rstack'); + rstack = resolveRstack(folderRoot, rstackPackagePath); shimPath = path.join(rstack.directory, 'dist', 'rslintConfig.js'); try { if (!fs.statSync(shimPath).isFile()) throw new Error('not a file'); diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index 66e457e..72494c6 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; -import { findPackageJsonUncached } from '../../shared/packageResolve'; +import { resolveRstackPackageJson } from '../../shared/rstackPackage'; import { checkPackageVersion, formatVersionMismatch, @@ -74,15 +74,41 @@ export type RstackShim = { * * Resolution is anchored on the config directory rather than the workspace * folder so a monorepo package with its own `rstack` install wins over the root. + * A configured `rstack.rstackCli.packagePath` replaces that walk-up for every + * bridge in the folder, the way `rstack.rstest.corePath` overrides every + * project's core. */ export function resolveRstackShim( configDir: string, - // The caller re-resolves on every tree refresh (a fresh `pnpm install` has to - // be picked up without a reload), so a persistent failure must not re-log. - { silent = false }: { silent?: boolean } = {}, + { + folderRoot = configDir, + configured, + // The caller re-resolves on every tree refresh (a fresh `pnpm install` has + // to be picked up without a reload), so a persistent failure must not + // re-log. + silent = false, + }: { + /** The workspace folder root; a relative `configured` resolves from it. */ + folderRoot?: string; + /** `rstack.rstackCli.packagePath`, `${workspaceFolder}` expanded. */ + configured?: string; + silent?: boolean; + } = {}, ): RstackShim | undefined { - const packageJsonPath = findPackageJsonUncached('rstack', configDir); - if (packageJsonPath === undefined) { + const resolution = resolveRstackPackageJson({ + configured, + folderRoot, + searchFrom: configDir, + }); + if (resolution.kind === 'invalid-setting') { + // Not the not-installed state: the fix is the setting, not an install. + if (!silent) { + logger.error(resolution.message); + } + status.crashed(resolution.message, configDir); + return undefined; + } + if (resolution.kind === 'not-installed') { if (!silent) { logger.warn( formatNotInstalledLog( @@ -100,6 +126,7 @@ export function resolveRstackShim( status.notInstalled(RSTACK_NOT_INSTALLED_STATUS, configDir); return undefined; } + const packageJsonPath = resolution.packageJsonPath; const packageDirectory = path.dirname(packageJsonPath); const configFilePath = path.join(packageDirectory, SHIM_RELATIVE_PATH); diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index f185926..318f60d 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -15,6 +15,7 @@ import { } from './master'; import { quoteFilter } from './vendored/coreInternals'; import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; +import { RSTACK_PACKAGE_PATH_SETTING } from '../../shared/rstackPackage'; import { Project, WorkspaceManager } from './project'; import { routeToOwners } from './runRouting'; import { status } from './status'; @@ -560,7 +561,12 @@ class RstestController implements StackController { // qualifies: installing a newer Node on the machine is not observable by the // extension and stays a manual restart, the deliberate half of the deal (see // `docs/adr/0001-node-runtime-selection.md`). - readonly restartOnSettings = [NODE_EXECUTABLE_SETTING]; + // `rstack.rstackCli.packagePath` moves every bridge's shim and core anchor, + // which a live project keeps until it is rebuilt. + readonly restartOnSettings = [ + NODE_EXECUTABLE_SETTING, + RSTACK_PACKAGE_PATH_SETTING, + ]; #rstest: Rstest | undefined; diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 433b324..260ef70 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -20,6 +20,7 @@ import { import { CONFIG_SECTION, getConfigValue } from './config'; import { createDebugWorker } from './debugWorker'; import { MessageLatch } from '../../shared/messageLatch'; +import { rstackResolveHookEnv } from '../../shared/rstackPackageSetting'; import { formatNotInstalledLog, formatNotInstalledStatus, @@ -778,7 +779,7 @@ export class RstestApi { nodeExecutable, nodeExecArgs, }); - const workerEnv: NodeJS.ProcessEnv = { + const composedEnv: NodeJS.ProcessEnv = { // same as packages/core/src/cli/prepare.ts // if (!process.env.NODE_ENV) process.env.NODE_ENV = 'test' NODE_ENV: 'test', @@ -788,6 +789,15 @@ export class RstestApi { // process.env.RSTEST = 'true'; RSTEST: 'true', }; + // Adaptation 2: the bridge's shim loads `rstack.config.*` in the worker. + // Composed after the env settings so a user's own `NODE_OPTIONS` there + // keeps applying. + const workerEnv = + rstackResolveHookEnv( + this.workspace, + this.project.isBridge ? this.rstestResolutionDir : undefined, + composedEnv, + ) ?? composedEnv; // Upstream sets FORCE_COLOR: '1' unconditionally; ours is conditional and // the worker retracts it when the config disables color (adaptation #9, // shared/colorEnv.ts). diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 00c70dc..f13a0e2 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -5,6 +5,7 @@ import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; import vscode from 'vscode'; import { RSTACK_CONFIG_NAMES } from '../../detection'; +import { getConfiguredRstackPackagePath } from '../../shared/rstackPackageSetting'; import { resolveRstackShim } from './bridge'; import { watchConfigValue } from './config'; import { @@ -336,11 +337,16 @@ export class WorkspaceManager implements vscode.Disposable { const candidateDirs = new Set(); const wanted = new Map(); + const configuredRstackPackage = getConfiguredRstackPackagePath( + this.workspaceFolder, + ); for (const [cwd, rstackConfig] of winnerByDir) { const key = rstackConfig.toString(); if (nativeProjectDirs.has(cwd)) continue; candidateDirs.add(cwd); const shim = resolveRstackShim(cwd, { + folderRoot: this.workspaceFolder.uri.fsPath, + configured: configuredRstackPackage, silent: this.reportedShimFailures.has(cwd), }); if (!shim) { diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index 2342c32..333a6c9 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -549,6 +549,7 @@ describe('the extension manifest', () => { expect(settings['rstack.fmt.trace.server']?.scope).toBe('window'); expect(settings['rstack.rslint.corePath']?.scope).toBe('resource'); expect(settings['rstack.rstest.corePath']?.scope).toBe('resource'); + expect(settings['rstack.rstackCli.packagePath']?.scope).toBe('resource'); }); }); diff --git a/packages/vscode/tests/shared/notInstalled.test.ts b/packages/vscode/tests/shared/notInstalled.test.ts index bd974c0..4b735f0 100644 --- a/packages/vscode/tests/shared/notInstalled.test.ts +++ b/packages/vscode/tests/shared/notInstalled.test.ts @@ -35,7 +35,11 @@ describe('not-installed wording', () => { it('logs where the stack looked', () => { expect(formatNotInstalledLog('rstack', 'app', '/repo/app')).toBe( - 'rstack is not installed in app (node_modules missing); searched from /repo/app', + 'rstack is not installed in app (node_modules missing); searched from /repo/app; if it is installed elsewhere, set "rstack.rstackCli.packagePath" to it', + ); + // The override is an `rstack` setting; other packages have their own. + expect(formatNotInstalledLog('@rstest/core', 'app', '/repo/app')).toBe( + '@rstest/core is not installed in app (node_modules missing); searched from /repo/app', ); }); diff --git a/packages/vscode/tests/shared/rstackPackage.test.ts b/packages/vscode/tests/shared/rstackPackage.test.ts new file mode 100644 index 0000000..1791b88 --- /dev/null +++ b/packages/vscode/tests/shared/rstackPackage.test.ts @@ -0,0 +1,131 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from '@rstest/core'; +import { resolveRstackPackageJson } from '../../src/shared/rstackPackage'; +import { + installPackage, + removeTemporaryDirectories, + temporaryDirectory, + writePackage, +} from '../stacks/lint/packageFixtures'; + +afterEach(removeTemporaryDirectories); + +describe('resolveRstackPackageJson', () => { + it.each([ + 'infra/node_modules/rstack', + 'infra/node_modules/rstack/package.json', + ])( + 'resolves a relative setting from the folder root, outside the walk-up (%s)', + (configured) => { + // The #103 layout: rstack only under `infra/`, invisible to a walk-up + // from the folder root. + const root = temporaryDirectory(); + const rstackDir = installPackage( + path.join(root, 'infra'), + 'rstack', + '0.8.4', + ); + + expect( + resolveRstackPackageJson({ + configured, + folderRoot: root, + searchFrom: root, + }), + ).toEqual({ + kind: 'found', + packageJsonPath: path.join(rstackDir, 'package.json'), + }); + }, + ); + + it('wins over an installation the walk-up would find', () => { + const root = temporaryDirectory(); + installPackage(root, 'rstack', '0.8.3'); + const pinned = writePackage( + path.join(root, 'tools', 'rstack'), + 'rstack', + '0.8.4', + ); + + expect( + resolveRstackPackageJson({ + configured: pinned, + folderRoot: root, + searchFrom: root, + }), + ).toEqual({ + kind: 'found', + packageJsonPath: path.join(pinned, 'package.json'), + }); + }); + + it('returns the realpath, so a pnpm store layout keeps its siblings', () => { + // Lint and test resolve their cores from the rstack directory; through + // the `node_modules/rstack` symlink they would miss rstack's dependencies, + // which pnpm places beside the store path. + const root = temporaryDirectory(); + const storeDir = writePackage( + path.join( + root, + 'node_modules', + '.pnpm', + 'rstack@0.8.4', + 'node_modules', + 'rstack', + ), + 'rstack', + '0.8.4', + ); + const link = path.join(root, 'infra', 'node_modules', 'rstack'); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(storeDir, link, 'junction'); + + expect( + resolveRstackPackageJson({ + configured: 'infra/node_modules/rstack', + folderRoot: root, + searchFrom: root, + }), + ).toEqual({ + kind: 'found', + packageJsonPath: path.join(storeDir, 'package.json'), + }); + }); + + it('treats a missing path as a setting error, not a missing install', () => { + const root = temporaryDirectory(); + // An installation the walk-up would find must not mask the mistake. + installPackage(root, 'rstack', '0.8.4'); + + const resolution = resolveRstackPackageJson({ + configured: 'infra/node_modules/rstack', + folderRoot: root, + searchFrom: root, + }); + + expect(resolution.kind).toBe('invalid-setting'); + const message = resolution.kind === 'invalid-setting' && resolution.message; + expect(message).toContain('rstack.rstackCli.packagePath'); + expect(message).toContain( + path.join(root, 'infra', 'node_modules', 'rstack', 'package.json'), + ); + }); + + it('rejects a package that is not rstack', () => { + const root = temporaryDirectory(); + const coreDir = installPackage(root, '@rslint/core', '0.9.5'); + + const resolution = resolveRstackPackageJson({ + configured: coreDir, + folderRoot: root, + searchFrom: root, + }); + + expect(resolution.kind).toBe('invalid-setting'); + expect( + resolution.kind === 'invalid-setting' && resolution.message, + ).toContain('is not the "rstack" package'); + }); +}); diff --git a/packages/vscode/tests/shared/rstackResolveHook.test.ts b/packages/vscode/tests/shared/rstackResolveHook.test.ts new file mode 100644 index 0000000..42ed43d --- /dev/null +++ b/packages/vscode/tests/shared/rstackResolveHook.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from '@rstest/core'; +import { withRstackResolveHook } from '../../src/shared/rstackResolveHook'; + +it("appends the hook to the user's NODE_OPTIONS as a file: URL", () => { + // A plain path with a space would split inside NODE_OPTIONS. + const env = withRstackResolveHook( + { NODE_OPTIONS: '--max-old-space-size=4096' }, + '/ws/infra/node_modules/rstack/package.json', + '/opt/my extension/dist/rstack-resolve-hook.mjs', + ); + + expect(env.NODE_OPTIONS).toBe( + '--max-old-space-size=4096 --import file:///opt/my%20extension/dist/rstack-resolve-hook.mjs', + ); +}); diff --git a/packages/vscode/tests/stacks/fmt/runtime.test.ts b/packages/vscode/tests/stacks/fmt/runtime.test.ts index faf1b58..0f292e2 100644 --- a/packages/vscode/tests/stacks/fmt/runtime.test.ts +++ b/packages/vscode/tests/stacks/fmt/runtime.test.ts @@ -22,6 +22,7 @@ rs.mock('vscode', () => ({ showInformationMessage() {}, }, workspace: { + getConfiguration: () => ({ get: () => undefined }), createFileSystemWatcher: () => ({ onDidCreate: () => ({ dispose() {} }), onDidChange: () => ({ dispose() {} }), diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts index a1635ad..b77c2b4 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -68,6 +68,74 @@ describe('resolveRslint', () => { }, ); + it('follows a configured rstack outside the walk-up for a bridged runtime', () => { + // The #103 layout: rstack only under `infra/`. The core hop starts from + // the configured rstack directory, so rstack's own `@rslint/core` wins. + const root = temporaryDirectory(); + const rstackDir = installPackage( + path.join(root, 'infra'), + 'rstack', + '0.8.4', + ); + const coreDir = installPackage(rstackDir, '@rslint/core', '0.9.5'); + const shimPath = installShim(rstackDir); + + expect(() => resolveRslint({ folderRoot: root, mode: 'bridged' })).toThrow( + 'Could not resolve rstack', + ); + expect( + resolveRslint({ + folderRoot: root, + mode: 'bridged', + rstackPackagePath: 'infra/node_modules/rstack', + }), + ).toEqual({ + mode: 'bridged', + coreDir, + coreVersion: '0.9.5', + rstackDir, + rstackVersion: '0.8.4', + shimPath, + }); + }); + + it('reports a configured rstack that does not resolve as a setting error', () => { + const root = temporaryDirectory(); + // A rstack the walk-up would find must not mask the wrong setting. + installShim(installPackage(root, 'rstack', '0.8.4')); + + let thrown: unknown; + try { + resolveRslint({ + folderRoot: root, + mode: 'bridged', + rstackPackagePath: 'infra/node_modules/rstack', + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(RslintResolutionError); + // `invalid-package` without `missingPackage`: not the not-installed state. + expect(thrown).toMatchObject({ + code: 'invalid-package', + missingPackage: undefined, + }); + expect((thrown as Error).message).toContain('rstack.rstackCli.packagePath'); + }); + + it('ignores the rstack setting in native mode', () => { + const root = temporaryDirectory(); + const coreDir = installPackage(root, '@rslint/core', '0.8.0'); + + expect( + resolveRslint({ + folderRoot: root, + mode: 'native', + rstackPackagePath: 'missing', + }).coreDir, + ).toBe(coreDir); + }); + it('reports an invalid configured path', () => { const corePath = 'missing-core'; const root = temporaryDirectory(); diff --git a/packages/vscode/tests/stacks/test/bridge.test.ts b/packages/vscode/tests/stacks/test/bridge.test.ts index ce7c666..7b1b4dd 100644 --- a/packages/vscode/tests/stacks/test/bridge.test.ts +++ b/packages/vscode/tests/stacks/test/bridge.test.ts @@ -152,6 +152,19 @@ describe('resolveRstackShim', () => { expect(logged).toEqual([]); }); + it('reports a configured path that does not resolve as a crash, not a missing install', () => { + const root = makeTmpDir(); + + expect( + resolveRstackShim(root, { + folderRoot: root, + configured: 'infra/node_modules/rstack', + }), + ).toBeUndefined(); + expect(logged.join('\n')).toContain('rstack.rstackCli.packagePath'); + expect(reported.map((state) => state.kind)).toEqual(['crashed']); + }); + it('refuses an rstack install that ships no Rstest shim', () => { const configDir = createWorkspace({ shim: false }); From 8b5cb1700fc27d837c6716690e619ddb93511220 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 16 Oct 2026 16:42:45 +0800 Subject: [PATCH 2/5] fix(vscode): pass the rstack resolve hook as a node --import argument Deliver the resolve hook as an explicit --import node arg, with the rstack package.json path in the hook URL's query, instead of NODE_OPTIONS plus an env var. The arg reaches exactly the spawned process and also fits Rstest's Run in Terminal command line, which previously ran without the hook. --- packages/vscode/AGENTS.md | 2 +- packages/vscode/rslib.config.mts | 2 +- .../vscode/src/shared/rstackPackageSetting.ts | 21 +++++++------ .../vscode/src/shared/rstackResolveHook.ts | 26 +++++++--------- .../src/shared/rstackResolveHookPreload.mjs | 5 ++-- packages/vscode/src/stacks/fmt/index.ts | 10 +++++-- packages/vscode/src/stacks/lint/Rslint.ts | 19 +++++++----- packages/vscode/src/stacks/test/master.ts | 30 +++++++++---------- .../tests/shared/rstackResolveHook.test.ts | 23 +++++++------- 9 files changed, 71 insertions(+), 67 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 209f155..944d52d 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -12,7 +12,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## The twelve 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.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing. Every consumer of the `rstack` package (fmt, bridged lint, bridged test) also reads the shared `rstack.rstackCli.packagePath` (`shared/rstackPackage.ts`), which replaces the walk-up for `rstack` itself; the tool core is still resolved from the directory it selects unless `corePath` is set. Like `corePath`, it changes resolution only, never detection. While it is set, every process that loads `rstack.config.*` (fmt server, bridged lint and Rstest workers) also preloads a resolve hook so the config's own `import 'rstack'` reaches the same package (`shared/rstackResolveHook.ts`). The hook bridges to Node's resolver; never replace it with `NODE_PATH`, `JITI_ALIAS` or anything else that imitates a package-manager shim or names files inside `rstack`. +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). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing. Every consumer of the `rstack` package (fmt, bridged lint, bridged test) also reads the shared `rstack.rstackCli.packagePath` (`shared/rstackPackage.ts`), which replaces the walk-up for `rstack` itself; the tool core is still resolved from the directory it selects unless `corePath` is set. Like `corePath`, it changes resolution only, never detection. While it is set, every process that loads `rstack.config.*` (fmt server, bridged lint and Rstest workers, Rstest's Run in Terminal) also gets a resolve hook as a `--import` node arg so the config's own `import 'rstack'` reaches the same package (`shared/rstackResolveHook.ts`). The hook bridges to Node's resolver; never replace it with `NODE_PATH`, `JITI_ALIAS` or anything else that imitates a package-manager shim or names files inside `rstack`. 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. Upstream's plugin-host failure toast becomes a status verdict (adaptation 7). Rslint LSP tracing shares the **Rstack: Rslint** Output channel to preserve the four-channel cap; see `stacks/lint/index.ts`. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. diff --git a/packages/vscode/rslib.config.mts b/packages/vscode/rslib.config.mts index dcdbc4d..5f6465b 100644 --- a/packages/vscode/rslib.config.mts +++ b/packages/vscode/rslib.config.mts @@ -73,7 +73,7 @@ const libs: LibConfig[] = [ to: `${yukuBindingPackage}/${path.basename(yukuBindingPath)}`, }, { - // Preloaded via `NODE_OPTIONS=--import`, so a file of its own. + // Preloaded via a `--import` node arg, so a file of its own. from: path.join( rootDir, 'src/shared/rstackResolveHookPreload.mjs', diff --git a/packages/vscode/src/shared/rstackPackageSetting.ts b/packages/vscode/src/shared/rstackPackageSetting.ts index 49b9f1a..3775a41 100644 --- a/packages/vscode/src/shared/rstackPackageSetting.ts +++ b/packages/vscode/src/shared/rstackPackageSetting.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import vscode from 'vscode'; import { expandWorkspaceFolder } from './nodeExecutableSetting'; import { RSTACK_PACKAGE_PATH_SETTING } from './rstackPackage'; -import { withRstackResolveHook } from './rstackResolveHook'; +import { rstackResolveHookImportArgs } from './rstackResolveHook'; /** * Reads `rstack.rstackCli.packagePath` for a resource (a folder, or a document @@ -25,21 +25,20 @@ export const getConfiguredRstackPackagePath = ( }; /** - * The env for a spawned process that loads `rstack.config.*` with the rstack - * at `rstackDirectory`: `baseEnv` plus the resolve hook when the setting chose - * that rstack (`withRstackResolveHook`), otherwise `undefined`. + * The node args (before the script path) for a spawned process that loads + * `rstack.config.*` with the rstack at `rstackDirectory`: the resolve hook + * when the setting chose that rstack (`rstackResolveHookImportArgs`), + * otherwise none. */ -export const rstackResolveHookEnv = ( +export const rstackResolveHookArgs = ( folder: vscode.WorkspaceFolder, rstackDirectory: string | undefined, - baseEnv: NodeJS.ProcessEnv = process.env, -): NodeJS.ProcessEnv | undefined => +): string[] => rstackDirectory === undefined || getConfiguredRstackPackagePath(folder) === undefined - ? undefined - : withRstackResolveHook( - baseEnv, - path.join(rstackDirectory, 'package.json'), + ? [] + : rstackResolveHookImportArgs( // Copied beside the bundles by `rslib.config.mts`. path.resolve(__dirname, 'rstack-resolve-hook.mjs'), + path.join(rstackDirectory, 'package.json'), ); diff --git a/packages/vscode/src/shared/rstackResolveHook.ts b/packages/vscode/src/shared/rstackResolveHook.ts index 2c8cbac..6c1f021 100644 --- a/packages/vscode/src/shared/rstackResolveHook.ts +++ b/packages/vscode/src/shared/rstackResolveHook.ts @@ -1,7 +1,7 @@ import { pathToFileURL } from 'node:url'; /** - * The spawn env of a process that loads `rstack.config.*` while + * The node args for a process that loads `rstack.config.*` while * `rstack.rstackCli.packagePath` selected the `rstack` it runs (#103). The * config's own `import 'rstack'` resolves from the config file, where that * installation is not visible; the preloaded hook @@ -10,21 +10,15 @@ import { pathToFileURL } from 'node:url'; * installation. It bridges: nothing a package-manager shim does (`NODE_PATH`) * and no file inside `rstack` is reproduced here. * - * `NODE_OPTIONS` is appended to, so a user's own flags keep applying. The hook - * is passed as a `file:` URL because `NODE_OPTIONS` splits on spaces. + * An explicit node arg rather than env, so it reaches exactly the spawned + * process and also fits a terminal command line. The package.json path rides + * in the hook URL's `pkg` query, keeping the flag a single argv element. */ -export function withRstackResolveHook( - baseEnv: NodeJS.ProcessEnv, - packageJsonPath: string, +export function rstackResolveHookImportArgs( hookPath: string, -): NodeJS.ProcessEnv { - const importFlag = `--import ${pathToFileURL(hookPath).href}`; - return { - ...baseEnv, - NODE_OPTIONS: baseEnv.NODE_OPTIONS - ? `${baseEnv.NODE_OPTIONS} ${importFlag}` - : importFlag, - // Read by the hook. - RSTACK_EDITOR_RSTACK_PACKAGE_JSON: packageJsonPath, - }; + packageJsonPath: string, +): string[] { + const url = pathToFileURL(hookPath); + url.searchParams.set('pkg', packageJsonPath); + return ['--import', url.href]; } diff --git a/packages/vscode/src/shared/rstackResolveHookPreload.mjs b/packages/vscode/src/shared/rstackResolveHookPreload.mjs index a7f4c7b..ebfbb8c 100644 --- a/packages/vscode/src/shared/rstackResolveHookPreload.mjs +++ b/packages/vscode/src/shared/rstackResolveHookPreload.mjs @@ -1,11 +1,12 @@ -// Preloaded via `NODE_OPTIONS=--import` (why: `rstackResolveHook.ts`). +// Preloaded via a `--import` node arg (why: `rstackResolveHook.ts`), which +// carries the configured package.json path in its `pkg` query. // Resolves `rstack` and `rstack/*` as if from inside the configured package, // through its own `exports`; every other specifier passes through. Plain JS, // copied to `dist/` by `rslib.config.mts`: Node loads it outside the bundle. import nodeModule from 'node:module'; import { pathToFileURL } from 'node:url'; -const packageJson = process.env.RSTACK_EDITOR_RSTACK_PACKAGE_JSON; +const packageJson = new URL(import.meta.url).searchParams.get('pkg'); if (packageJson) { const anchorURL = pathToFileURL(packageJson).href; diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 37ce5c1..74b992a 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -35,7 +35,7 @@ import { } from '../../shared/rstackPackage'; import { getConfiguredRstackPackagePath, - rstackResolveHookEnv, + rstackResolveHookArgs, } from '../../shared/rstackPackageSetting'; import { checkPackageVersion, @@ -420,9 +420,13 @@ class FmtFolderRuntime { const owner = new LanguageServerProcessOwner( nodeExecutable, - [rsBinJs, 'fmt', '--lsp'], + [ + ...rstackResolveHookArgs(this.folder, path.dirname(pkgJsonPath)), + rsBinJs, + 'fmt', + '--lsp', + ], folderRoot, - rstackResolveHookEnv(this.folder, path.dirname(pkgJsonPath)), ); this.#owner = owner; const serverOptions: ServerOptions = async () => owner.start(); diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 70a8192..361b539 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -35,7 +35,7 @@ import { resolveUserNodeOnce, } from '../../shared/nodeResolution'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; -import { rstackResolveHookEnv } from '../../shared/rstackPackageSetting'; +import { rstackResolveHookArgs } from '../../shared/rstackPackageSetting'; import type { StackState } from '../../types'; import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; @@ -483,7 +483,17 @@ export class Rslint implements Disposable { const nodeExecutable = await this.resolveNodeExecutable(); this.assertStartCurrent(epoch, signal); const workerPath = path.resolve(__dirname, 'lint-worker.js'); - const workerArgs = [workerPath, '--lsp', '--core', packageDirectory]; + const workerArgs = [ + // Adaptation 2: the bridged worker's shim loads `rstack.config.*`. + ...rstackResolveHookArgs( + this.workspaceFolder, + this.installation.rstackDirectory, + ), + workerPath, + '--lsp', + '--core', + packageDirectory, + ]; if (shimPath !== undefined) { workerArgs.push('--config', shimPath); } @@ -491,11 +501,6 @@ export class Rslint implements Disposable { nodeExecutable, workerArgs, folderRoot, - // Adaptation 2: the bridged worker's shim loads `rstack.config.*`. - rstackResolveHookEnv( - this.workspaceFolder, - this.installation.rstackDirectory, - ), ); this.serverProcessOwner = serverProcessOwner; const serverOptions: ServerOptions = async () => serverProcessOwner.start(); diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 260ef70..e713132 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -20,7 +20,7 @@ import { import { CONFIG_SECTION, getConfigValue } from './config'; import { createDebugWorker } from './debugWorker'; import { MessageLatch } from '../../shared/messageLatch'; -import { rstackResolveHookEnv } from '../../shared/rstackPackageSetting'; +import { rstackResolveHookArgs } from '../../shared/rstackPackageSetting'; import { formatNotInstalledLog, formatNotInstalledStatus, @@ -224,7 +224,8 @@ export class RstestApi { } // The node executable + exec args honoring the shared `rstack.nodeExecutable` - // and the stack's `nodeExecArgs` settings (`${workspaceFolder}` expanded). + // and the stack's `nodeExecArgs` settings (`${workspaceFolder}` expanded), + // plus the bridge's rstack resolve hook. // Used verbatim by the terminal CLI, which deliberately skips the worker // preflight below: the command runs inside the user's own shell, which is the // very thing the preflight exists to emulate. `configured` reports whether @@ -239,9 +240,17 @@ export class RstestApi { return { nodeExecutable: configuredExecutable ?? 'node', configured: configuredExecutable !== undefined, - nodeExecArgs: getConfigValue('nodeExecArgs', this.workspace).map((arg) => - this.expandWorkspaceFolder(arg), - ), + nodeExecArgs: [ + ...getConfigValue('nodeExecArgs', this.workspace).map((arg) => + this.expandWorkspaceFolder(arg), + ), + // Adaptation 2: the bridge's shim loads `rstack.config.*`, in the + // worker (spawned or debugged) and in the terminal CLI alike. + ...rstackResolveHookArgs( + this.workspace, + this.project.isBridge ? this.rstestResolutionDir : undefined, + ), + ], }; } @@ -779,7 +788,7 @@ export class RstestApi { nodeExecutable, nodeExecArgs, }); - const composedEnv: NodeJS.ProcessEnv = { + const workerEnv: NodeJS.ProcessEnv = { // same as packages/core/src/cli/prepare.ts // if (!process.env.NODE_ENV) process.env.NODE_ENV = 'test' NODE_ENV: 'test', @@ -789,15 +798,6 @@ export class RstestApi { // process.env.RSTEST = 'true'; RSTEST: 'true', }; - // Adaptation 2: the bridge's shim loads `rstack.config.*` in the worker. - // Composed after the env settings so a user's own `NODE_OPTIONS` there - // keeps applying. - const workerEnv = - rstackResolveHookEnv( - this.workspace, - this.project.isBridge ? this.rstestResolutionDir : undefined, - composedEnv, - ) ?? composedEnv; // Upstream sets FORCE_COLOR: '1' unconditionally; ours is conditional and // the worker retracts it when the config disables color (adaptation #9, // shared/colorEnv.ts). diff --git a/packages/vscode/tests/shared/rstackResolveHook.test.ts b/packages/vscode/tests/shared/rstackResolveHook.test.ts index 42ed43d..c453395 100644 --- a/packages/vscode/tests/shared/rstackResolveHook.test.ts +++ b/packages/vscode/tests/shared/rstackResolveHook.test.ts @@ -1,15 +1,16 @@ +import { fileURLToPath } from 'node:url'; import { expect, it } from '@rstest/core'; -import { withRstackResolveHook } from '../../src/shared/rstackResolveHook'; +import { rstackResolveHookImportArgs } from '../../src/shared/rstackResolveHook'; -it("appends the hook to the user's NODE_OPTIONS as a file: URL", () => { - // A plain path with a space would split inside NODE_OPTIONS. - const env = withRstackResolveHook( - { NODE_OPTIONS: '--max-old-space-size=4096' }, - '/ws/infra/node_modules/rstack/package.json', - '/opt/my extension/dist/rstack-resolve-hook.mjs', - ); +it('carries both paths through one --import URL intact', () => { + const hookPath = '/opt/my extension/dist/rstack-resolve-hook.mjs'; + const packageJsonPath = '/ws/my infra/node_modules/rstack/package.json'; - expect(env.NODE_OPTIONS).toBe( - '--max-old-space-size=4096 --import file:///opt/my%20extension/dist/rstack-resolve-hook.mjs', - ); + const [flag, href] = rstackResolveHookImportArgs(hookPath, packageJsonPath); + + expect(flag).toBe('--import'); + // What the preload reads from `import.meta.url`. + const url = new URL(href); + expect(url.searchParams.get('pkg')).toBe(packageJsonPath); + expect(fileURLToPath(url)).toBe(hookPath); }); From 83c056e87830692ff765215d12d4599ff7cb11af Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 16 Oct 2026 17:00:51 +0800 Subject: [PATCH 3/5] refactor(vscode): carry the rstack resolution source to the resolve hook The rstack package resolution now records whether the setting chose it and carries the parsed package.json, so the hook gate no longer re-reads the setting and no consumer reads the same package.json twice. Fold the hook helper into rstackPackage.ts, share one path-setting reader with rstack.nodeExecutable, and pass the not-installed hint from the rstack callers instead of special-casing the package name. --- packages/vscode/AGENTS.md | 2 +- .../src/shared/nodeExecutableSetting.ts | 17 +++- packages/vscode/src/shared/notInstalled.ts | 12 +-- packages/vscode/src/shared/rstackPackage.ts | 80 ++++++++++++++++--- .../vscode/src/shared/rstackPackageSetting.ts | 46 +++-------- .../vscode/src/shared/rstackResolveHook.ts | 24 ------ .../src/shared/rstackResolveHookPreload.mjs | 5 +- packages/vscode/src/stacks/fmt/index.ts | 30 ++++--- .../vscode/src/stacks/lint/CoreResolver.ts | 4 + packages/vscode/src/stacks/lint/Rslint.ts | 4 +- packages/vscode/src/stacks/lint/index.ts | 6 +- packages/vscode/src/stacks/lint/resolution.ts | 50 ++++++------ packages/vscode/src/stacks/test/bridge.ts | 43 ++++++---- packages/vscode/src/stacks/test/master.ts | 6 +- packages/vscode/src/stacks/test/project.ts | 23 ++++-- .../vscode/tests/shared/notInstalled.test.ts | 16 +++- .../vscode/tests/shared/rstackPackage.test.ts | 32 +++++--- .../tests/shared/rstackResolveHook.test.ts | 16 ---- .../vscode/tests/stacks/fmt/runtime.test.ts | 1 + .../tests/stacks/lint/resolution.test.ts | 3 + .../vscode/tests/stacks/test/bridge.test.ts | 7 +- .../vscode/tests/stacks/test/master.test.ts | 5 +- 22 files changed, 249 insertions(+), 183 deletions(-) delete mode 100644 packages/vscode/src/shared/rstackResolveHook.ts delete mode 100644 packages/vscode/tests/shared/rstackResolveHook.test.ts diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 944d52d..095dd02 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -12,7 +12,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## The twelve 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.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing. Every consumer of the `rstack` package (fmt, bridged lint, bridged test) also reads the shared `rstack.rstackCli.packagePath` (`shared/rstackPackage.ts`), which replaces the walk-up for `rstack` itself; the tool core is still resolved from the directory it selects unless `corePath` is set. Like `corePath`, it changes resolution only, never detection. While it is set, every process that loads `rstack.config.*` (fmt server, bridged lint and Rstest workers, Rstest's Run in Terminal) also gets a resolve hook as a `--import` node arg so the config's own `import 'rstack'` reaches the same package (`shared/rstackResolveHook.ts`). The hook bridges to Node's resolver; never replace it with `NODE_PATH`, `JITI_ALIAS` or anything else that imitates a package-manager shim or names files inside `rstack`. +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). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing. Every consumer of the `rstack` package (fmt, bridged lint, bridged test) also reads the shared `rstack.rstackCli.packagePath` (`shared/rstackPackage.ts`), which replaces the walk-up for `rstack` itself; the tool core is still resolved from the directory it selects unless `corePath` is set. Like `corePath`, it changes resolution only, never detection. While it is set, every process that loads `rstack.config.*` (fmt server, bridged lint and Rstest workers, Rstest's Run in Terminal) also gets a resolve hook as a `--import` node arg so the config's own `import 'rstack'` reaches the same package (`rstackResolveHookArgs` in `shared/rstackPackage.ts`). The hook bridges to Node's resolver; never replace it with `NODE_PATH`, `JITI_ALIAS` or anything else that imitates a package-manager shim or names files inside `rstack`. 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. Upstream's plugin-host failure toast becomes a status verdict (adaptation 7). Rslint LSP tracing shares the **Rstack: Rslint** Output channel to preserve the four-channel cap; see `stacks/lint/index.ts`. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. diff --git a/packages/vscode/src/shared/nodeExecutableSetting.ts b/packages/vscode/src/shared/nodeExecutableSetting.ts index 67140ac..ea231cd 100644 --- a/packages/vscode/src/shared/nodeExecutableSetting.ts +++ b/packages/vscode/src/shared/nodeExecutableSetting.ts @@ -25,10 +25,23 @@ export const expandWorkspaceFolder = ( */ export const getConfiguredNodeExecutable = ( folder: vscode.WorkspaceFolder, +): string | undefined => + getConfiguredPathSetting(NODE_EXECUTABLE_SETTING, folder); + +/** + * The one reading rule for a resource-scoped path setting: `undefined` when + * unset or empty, else the value with `${workspaceFolder}` expanded. + * `resource` defaults to the folder; pass a document to honour a narrower + * scope. + */ +export const getConfiguredPathSetting = ( + setting: string, + folder: vscode.WorkspaceFolder, + resource: vscode.Uri = folder.uri, ): string | undefined => { const value = vscode.workspace - .getConfiguration(undefined, folder.uri) - .get(NODE_EXECUTABLE_SETTING); + .getConfiguration(undefined, resource) + .get(setting); if (typeof value !== 'string' || value === '') { return undefined; } diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index f71fd0f..044c6a8 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -1,5 +1,4 @@ import { MessageLatch } from './messageLatch'; -import { RSTACK_PACKAGE_PATH_SETTING } from './rstackPackage'; import { COMMAND_CATEGORY, STACK_LABELS, @@ -88,19 +87,16 @@ export class NotInstalledEpisode { * The output-channel line: where the stack looked, plus the stack's own * consequence — the same shape as the shared Node preflight message * (adaptation 6), where each caller appends what the state means for it. - * A missing `rstack` also names the override for an installation the walk-up - * cannot see (#103), once here for every stack that resolves it. + * `hint` names a way out the walk-up cannot see (an rstack caller passes + * `RSTACK_PACKAGE_PATH_HINT`, #103). */ export const formatNotInstalledLog = ( packageName: string, folderName: string, searchedFrom: string, consequence?: string, + hint?: string, ): string => `${packageName} is not installed in ${folderName} (node_modules missing); searched from ${searchedFrom}${ consequence ? `; ${consequence}` : '' - }${ - packageName === 'rstack' - ? `; if it is installed elsewhere, set "${RSTACK_PACKAGE_PATH_SETTING}" to it` - : '' - }`; + }${hint ? `; ${hint}` : ''}`; diff --git a/packages/vscode/src/shared/rstackPackage.ts b/packages/vscode/src/shared/rstackPackage.ts index 5aa1e29..e5d5508 100644 --- a/packages/vscode/src/shared/rstackPackage.ts +++ b/packages/vscode/src/shared/rstackPackage.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { findPackageJsonUncached, readPackageJson } from './packageResolve'; /** @@ -12,21 +13,33 @@ import { findPackageJsonUncached, readPackageJson } from './packageResolve'; */ export const RSTACK_PACKAGE_PATH_SETTING = 'rstack.rstackCli.packagePath'; +/** The `not-installed` log's pointer to the setting, for every rstack caller. */ +export const RSTACK_PACKAGE_PATH_HINT = `if it is installed elsewhere, set "${RSTACK_PACKAGE_PATH_SETTING}" to it`; + +/** Whether the setting chose the package, which decides the resolve hook. */ +export type RstackPackageSource = 'setting' | 'walk-up'; + export type RstackPackageResolution = - | { readonly kind: 'found'; readonly packageJsonPath: string } + | { + readonly kind: 'found'; + readonly packageJsonPath: string; + /** The parsed package.json, read once for every consumer. */ + readonly pkg: Record | undefined; + readonly source: RstackPackageSource; + } | { readonly kind: 'not-installed' } | { readonly kind: 'invalid-setting'; readonly message: string }; export interface ResolveRstackPackageOptions { /** - * The setting's value, `${workspaceFolder}` already expanded; unset or - * blank means "walk up from `searchFrom`". + * The setting's value, `${workspaceFolder}` already expanded; unset means + * "walk up from `searchFrom`". */ readonly configured: string | undefined; /** The workspace folder root; relative settings resolve from it. */ readonly folderRoot: string; - /** Where the default `node_modules` walk-up starts. */ - readonly searchFrom: string; + /** Where the default `node_modules` walk-up starts; defaults to `folderRoot`. */ + readonly searchFrom?: string; } const invalidSetting = ( @@ -51,18 +64,22 @@ const invalidSetting = ( export const resolveRstackPackageJson = ({ configured, folderRoot, - searchFrom, + searchFrom = folderRoot, }: ResolveRstackPackageOptions): RstackPackageResolution => { - const value = configured?.trim(); - if (!value) { + if (configured === undefined) { const packageJsonPath = findPackageJsonUncached('rstack', searchFrom); return packageJsonPath === undefined ? { kind: 'not-installed' } - : { kind: 'found', packageJsonPath }; + : { + kind: 'found', + packageJsonPath, + pkg: readPackageJson(packageJsonPath), + source: 'walk-up', + }; } - const resolved = path.resolve(folderRoot, value); - const configuredPath = value.endsWith('package.json') + const resolved = path.resolve(folderRoot, configured); + const configuredPath = configured.endsWith('package.json') ? resolved : path.join(resolved, 'package.json'); let packageJsonPath: string; @@ -72,8 +89,45 @@ export const resolveRstackPackageJson = ({ } catch { return invalidSetting(configuredPath, 'does not exist'); } - if (readPackageJson(packageJsonPath)?.name !== 'rstack') { + const pkg = readPackageJson(packageJsonPath); + if (pkg?.name !== 'rstack') { return invalidSetting(configuredPath, 'is not the "rstack" package'); } - return { kind: 'found', packageJsonPath }; + return { kind: 'found', packageJsonPath, pkg, source: 'setting' }; }; + +/** + * The resolve hook as node args, for a process that loads `rstack.config.*` + * while the setting chose its `rstack`: the config's own `import 'rstack'` + * resolves from the config file, where that installation is not visible. The + * preloaded hook (`rstackResolveHookPreload.mjs`) hands the specifier to + * Node's resolver from inside the configured package; nothing a + * package-manager shim does (`NODE_PATH`) and no file inside `rstack` is + * reproduced. A node arg rather than env, so it reaches exactly the spawned + * process and fits a terminal command line; the package.json path rides in + * the URL's `pkg` query, keeping the flag one argv element. + */ +export function rstackResolveHookImportArgs( + hookPath: string, + packageJsonPath: string, +): string[] { + const url = pathToFileURL(hookPath); + url.searchParams.set('pkg', packageJsonPath); + return ['--import', url.href]; +} + +/** + * The hook args (before the script path) for the rstack at `rstackDirectory`: + * none unless the setting chose it. + */ +export const rstackResolveHookArgs = ( + rstackDirectory: string | undefined, + source: RstackPackageSource | undefined, +): string[] => + rstackDirectory === undefined || source !== 'setting' + ? [] + : rstackResolveHookImportArgs( + // Copied beside the bundles by `rslib.config.mts`. + path.resolve(__dirname, 'rstack-resolve-hook.mjs'), + path.join(rstackDirectory, 'package.json'), + ); diff --git a/packages/vscode/src/shared/rstackPackageSetting.ts b/packages/vscode/src/shared/rstackPackageSetting.ts index 3775a41..8abcbca 100644 --- a/packages/vscode/src/shared/rstackPackageSetting.ts +++ b/packages/vscode/src/shared/rstackPackageSetting.ts @@ -1,44 +1,16 @@ -import path from 'node:path'; -import vscode from 'vscode'; -import { expandWorkspaceFolder } from './nodeExecutableSetting'; +import type vscode from 'vscode'; +import { getConfiguredPathSetting } from './nodeExecutableSetting'; import { RSTACK_PACKAGE_PATH_SETTING } from './rstackPackage'; -import { rstackResolveHookImportArgs } from './rstackResolveHook'; /** * Reads `rstack.rstackCli.packagePath` for a resource (a folder, or a document - * inside one — the setting is resource-scoped), `${workspaceFolder}` expanded. - * Returns `undefined` when unset or blank. Kept apart from `rstackPackage.ts` - * so the resolution itself stays testable without a `vscode` stub, the same - * split as `nodeExecutableSetting.ts` / `nodeResolution.ts`. + * inside one — the setting is resource-scoped). Kept apart from + * `rstackPackage.ts` so the resolution itself stays testable without a + * `vscode` stub, the same split as `nodeExecutableSetting.ts` / + * `nodeResolution.ts`. */ export const getConfiguredRstackPackagePath = ( folder: vscode.WorkspaceFolder, - resource: vscode.Uri = folder.uri, -): string | undefined => { - const value = vscode.workspace - .getConfiguration(undefined, resource) - .get(RSTACK_PACKAGE_PATH_SETTING); - if (typeof value !== 'string' || value.trim() === '') { - return undefined; - } - return expandWorkspaceFolder(value, folder); -}; - -/** - * The node args (before the script path) for a spawned process that loads - * `rstack.config.*` with the rstack at `rstackDirectory`: the resolve hook - * when the setting chose that rstack (`rstackResolveHookImportArgs`), - * otherwise none. - */ -export const rstackResolveHookArgs = ( - folder: vscode.WorkspaceFolder, - rstackDirectory: string | undefined, -): string[] => - rstackDirectory === undefined || - getConfiguredRstackPackagePath(folder) === undefined - ? [] - : rstackResolveHookImportArgs( - // Copied beside the bundles by `rslib.config.mts`. - path.resolve(__dirname, 'rstack-resolve-hook.mjs'), - path.join(rstackDirectory, 'package.json'), - ); + resource?: vscode.Uri, +): string | undefined => + getConfiguredPathSetting(RSTACK_PACKAGE_PATH_SETTING, folder, resource); diff --git a/packages/vscode/src/shared/rstackResolveHook.ts b/packages/vscode/src/shared/rstackResolveHook.ts deleted file mode 100644 index 6c1f021..0000000 --- a/packages/vscode/src/shared/rstackResolveHook.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { pathToFileURL } from 'node:url'; - -/** - * The node args for a process that loads `rstack.config.*` while - * `rstack.rstackCli.packagePath` selected the `rstack` it runs (#103). The - * config's own `import 'rstack'` resolves from the config file, where that - * installation is not visible; the preloaded hook - * (`rstackResolveHookPreload.mjs`) hands the specifier to Node's resolver - * from inside the configured package, so the config binds to the same - * installation. It bridges: nothing a package-manager shim does (`NODE_PATH`) - * and no file inside `rstack` is reproduced here. - * - * An explicit node arg rather than env, so it reaches exactly the spawned - * process and also fits a terminal command line. The package.json path rides - * in the hook URL's `pkg` query, keeping the flag a single argv element. - */ -export function rstackResolveHookImportArgs( - hookPath: string, - packageJsonPath: string, -): string[] { - const url = pathToFileURL(hookPath); - url.searchParams.set('pkg', packageJsonPath); - return ['--import', url.href]; -} diff --git a/packages/vscode/src/shared/rstackResolveHookPreload.mjs b/packages/vscode/src/shared/rstackResolveHookPreload.mjs index ebfbb8c..41554fd 100644 --- a/packages/vscode/src/shared/rstackResolveHookPreload.mjs +++ b/packages/vscode/src/shared/rstackResolveHookPreload.mjs @@ -1,5 +1,6 @@ -// Preloaded via a `--import` node arg (why: `rstackResolveHook.ts`), which -// carries the configured package.json path in its `pkg` query. +// Preloaded via a `--import` node arg whose `pkg` query carries the +// configured package.json path (why: `rstackResolveHookImportArgs` in +// `rstackPackage.ts`). // Resolves `rstack` and `rstack/*` as if from inside the configured package, // through its own `exports`; every other specifier passes through. Plain JS, // copied to `dist/` by `rslib.config.mts`: Node loads it outside the bundle. diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 74b992a..79fe6d7 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -28,15 +28,13 @@ import { NodePreflightError, resolveUserNodeOnce, } from '../../shared/nodeResolution'; -import { readPackageJson } from '../../shared/packageResolve'; import { + RSTACK_PACKAGE_PATH_HINT, RSTACK_PACKAGE_PATH_SETTING, resolveRstackPackageJson, -} from '../../shared/rstackPackage'; -import { - getConfiguredRstackPackagePath, rstackResolveHookArgs, -} from '../../shared/rstackPackageSetting'; +} from '../../shared/rstackPackage'; +import { getConfiguredRstackPackagePath } from '../../shared/rstackPackageSetting'; import { checkPackageVersion, formatVersionMismatch, @@ -371,7 +369,6 @@ class FmtFolderRuntime { const rstackPackage = resolveRstackPackageJson({ configured: getConfiguredRstackPackagePath(this.folder), folderRoot, - searchFrom: folderRoot, }); if (rstackPackage.kind === 'invalid-setting') { // A wrong setting is fixed in the setting, not by an install, so it is @@ -388,17 +385,23 @@ class FmtFolderRuntime { this.setState('disabled', formatNotInstalledStatus('fmt', 'rstack')); if (this.#packageEpisode.changed(folderRoot)) { context.output.warn( - formatNotInstalledLog('rstack', this.folder.name, folderRoot), + formatNotInstalledLog( + 'rstack', + this.folder.name, + folderRoot, + undefined, + RSTACK_PACKAGE_PATH_HINT, + ), ); } return; } this.#packageEpisode.clear(); - const pkgJsonPath = rstackPackage.packageJsonPath; + const { packageJsonPath: pkgJsonPath, pkg } = rstackPackage; - // One read for the version and the bin entry; `readPackageJson` re-reads - // from disk by design, so a reinstall is picked up on the next start. - const pkg = readPackageJson(pkgJsonPath); + // One read for the version and the bin entry, made by the resolution + // above; it re-reads from disk by design, so a reinstall is picked up on + // the next start. const version = typeof pkg?.version === 'string' ? pkg.version : undefined; const versionCheck = checkPackageVersion('rstack', version); if (versionCheck.kind === 'mismatch') { @@ -421,7 +424,10 @@ class FmtFolderRuntime { const owner = new LanguageServerProcessOwner( nodeExecutable, [ - ...rstackResolveHookArgs(this.folder, path.dirname(pkgJsonPath)), + ...rstackResolveHookArgs( + path.dirname(pkgJsonPath), + rstackPackage.source, + ), rsBinJs, 'fmt', '--lsp', diff --git a/packages/vscode/src/stacks/lint/CoreResolver.ts b/packages/vscode/src/stacks/lint/CoreResolver.ts index 8665de5..71bca71 100644 --- a/packages/vscode/src/stacks/lint/CoreResolver.ts +++ b/packages/vscode/src/stacks/lint/CoreResolver.ts @@ -34,6 +34,7 @@ import { type RslintMode, type RslintResolution, } from './resolution'; +import type { RstackPackageSource } from '../../shared/rstackPackage'; import { RslintVersionMismatchError } from './status'; /** @@ -55,6 +56,8 @@ export interface CoreInstallation { readonly shimPath?: string; readonly rstackDirectory?: string; readonly rstackVersion?: string; + /** Whether `rstack.rstackCli.packagePath` chose rstack (the resolve hook). */ + readonly rstackSource?: RstackPackageSource; } export interface ResolvedCoreRuntime { @@ -121,6 +124,7 @@ function validateInstallation( shimPath: resolution.shimPath, rstackDirectory: resolution.rstackDir, rstackVersion: resolution.rstackVersion, + rstackSource: resolution.rstackSource, }), }; } diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 361b539..a1053a5 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -35,7 +35,7 @@ import { resolveUserNodeOnce, } from '../../shared/nodeResolution'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; -import { rstackResolveHookArgs } from '../../shared/rstackPackageSetting'; +import { rstackResolveHookArgs } from '../../shared/rstackPackage'; import type { StackState } from '../../types'; import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; @@ -486,8 +486,8 @@ export class Rslint implements Disposable { const workerArgs = [ // Adaptation 2: the bridged worker's shim loads `rstack.config.*`. ...rstackResolveHookArgs( - this.workspaceFolder, this.installation.rstackDirectory, + this.installation.rstackSource, ), workerPath, '--lsp', diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 11a9425..a060f4a 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -8,7 +8,10 @@ import type { } from '../../types'; import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; import { formatNotInstalledLog } from '../../shared/notInstalled'; -import { RSTACK_PACKAGE_PATH_SETTING } from '../../shared/rstackPackage'; +import { + RSTACK_PACKAGE_PATH_HINT, + RSTACK_PACKAGE_PATH_SETTING, +} from '../../shared/rstackPackage'; import { CoreResolver, type ResolvedCoreRuntime } from './CoreResolver'; import { Logger } from './logger'; import { Rslint } from './Rslint'; @@ -221,6 +224,7 @@ class RslintController implements StackController { workspaceFolder.name, workspaceFolder.uri.fsPath, `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, + missing === 'rstack' ? RSTACK_PACKAGE_PATH_HINT : undefined, ); logger.warn(warning); } else { diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index f297090..f42925a 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -4,7 +4,10 @@ import { findPackageJsonUncached, readPackageJson, } from '../../shared/packageResolve'; -import { resolveRstackPackageJson } from '../../shared/rstackPackage'; +import { + type RstackPackageSource, + resolveRstackPackageJson, +} from '../../shared/rstackPackage'; export type RslintMode = 'native' | 'bridged'; @@ -14,6 +17,7 @@ export interface RslintResolution { readonly coreVersion: string | undefined; readonly rstackDir?: string; readonly rstackVersion?: string; + readonly rstackSource?: RstackPackageSource; readonly shimPath?: string; } @@ -23,7 +27,7 @@ export type RslintResolutionErrorCode = export class RslintResolutionError extends Error { /** * The package whose absence caused this failure — set only by the - * not-installed throws (`resolveInstalledPackage`, `resolveRstack`), the + * not-installed throws (`resolveInstalledCore`, `resolveRstack`), the * state the three stacks report uniformly (AGENTS.md). `missing-shim` and * `invalid-package` leave it unset on purpose: there the package is * present and the fix is an upgrade or the setting, not an install. @@ -148,8 +152,8 @@ interface PackageLocation { function readPackageLocation( packageName: 'rstack' | '@rslint/core', packageJsonPath: string, + pkg = readPackageJson(packageJsonPath), ): PackageLocation { - const pkg = readPackageJson(packageJsonPath); if (pkg?.name !== packageName) { throw new RslintResolutionError( 'invalid-package', @@ -192,15 +196,18 @@ function resolveConfiguredCore( function resolveRstack( folderRoot: string, configured: string | undefined, -): PackageLocation { - const resolution = resolveRstackPackageJson({ - configured, - folderRoot, - searchFrom: folderRoot, - }); +): PackageLocation & { readonly source: RstackPackageSource } { + const resolution = resolveRstackPackageJson({ configured, folderRoot }); switch (resolution.kind) { case 'found': - return readPackageLocation('rstack', resolution.packageJsonPath); + return { + ...readPackageLocation( + 'rstack', + resolution.packageJsonPath, + resolution.pkg, + ), + source: resolution.source, + }; case 'not-installed': throw new RslintResolutionError( 'missing-rstack', @@ -214,20 +221,16 @@ function resolveRstack( } } -function resolveInstalledPackage( - packageName: 'rstack' | '@rslint/core', - searchRoot: string, - code: Extract, -): PackageLocation { - const packageJsonPath = findPackageJsonUncached(packageName, searchRoot); +function resolveInstalledCore(searchRoot: string): PackageLocation { + const packageJsonPath = findPackageJsonUncached('@rslint/core', searchRoot); if (packageJsonPath === undefined) { throw new RslintResolutionError( - code, - `Could not resolve ${packageName} from ${searchRoot}`, - { missingPackage: packageName }, + 'missing-core', + `Could not resolve @rslint/core from ${searchRoot}`, + { missingPackage: '@rslint/core' }, ); } - return readPackageLocation(packageName, packageJsonPath); + return readPackageLocation('@rslint/core', packageJsonPath); } export interface ResolveRslintOptions { @@ -256,7 +259,7 @@ export function resolveRslint({ rstackPackagePath, documentDirectory, }: ResolveRslintOptions): RslintResolution { - let rstack: PackageLocation | undefined; + let rstack: ReturnType | undefined; let shimPath: string | undefined; if (mode === 'bridged') { rstack = resolveRstack(folderRoot, rstackPackagePath); @@ -275,10 +278,8 @@ export function resolveRslint({ const configuredCorePath = corePath?.trim(); const core = configuredCorePath ? resolveConfiguredCore(folderRoot, configuredCorePath) - : resolveInstalledPackage( - '@rslint/core', + : resolveInstalledCore( rstack?.directory ?? documentDirectory ?? folderRoot, - 'missing-core', ); return { @@ -290,6 +291,7 @@ export function resolveRslint({ : { rstackDir: rstack.directory, rstackVersion: rstack.version, + rstackSource: rstack.source, shimPath, }), }; diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index 72494c6..1752c0b 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -1,10 +1,14 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; -import { resolveRstackPackageJson } from '../../shared/rstackPackage'; +import { + RSTACK_PACKAGE_PATH_HINT, + type RstackPackageResolution, + type RstackPackageSource, + resolveRstackPackageJson, +} from '../../shared/rstackPackage'; import { checkPackageVersion, formatVersionMismatch, - readPackageVersion, } from '../../shared/versionCheck'; import { formatNotInstalledLog, @@ -58,6 +62,8 @@ export type RstackShim = { readonly packageDirectory: string; /** The installed `rstack` version, when it could be read. */ readonly version?: string; + /** Whether `rstack.rstackCli.packagePath` chose it (the resolve hook). */ + readonly source: RstackPackageSource; }; /** @@ -81,25 +87,24 @@ export type RstackShim = { export function resolveRstackShim( configDir: string, { - folderRoot = configDir, configured, // The caller re-resolves on every tree refresh (a fresh `pnpm install` has // to be picked up without a reload), so a persistent failure must not // re-log. silent = false, }: { - /** The workspace folder root; a relative `configured` resolves from it. */ - folderRoot?: string; - /** `rstack.rstackCli.packagePath`, `${workspaceFolder}` expanded. */ - configured?: string; + /** + * The resolution of a set `rstack.rstackCli.packagePath`, made once by the + * caller for every bridge in the folder; absent, rstack is walked up to + * from `configDir`. + */ + configured?: RstackPackageResolution; silent?: boolean; } = {}, ): RstackShim | undefined { - const resolution = resolveRstackPackageJson({ - configured, - folderRoot, - searchFrom: configDir, - }); + const resolution = + configured ?? + resolveRstackPackageJson({ configured: undefined, folderRoot: configDir }); if (resolution.kind === 'invalid-setting') { // Not the not-installed state: the fix is the setting, not an install. if (!silent) { @@ -116,6 +121,7 @@ export function resolveRstackShim( path.basename(configDir), configDir, 'Rstest cannot be driven by "rstack.config.*" until it is installed', + RSTACK_PACKAGE_PATH_HINT, ), ); } @@ -126,7 +132,7 @@ export function resolveRstackShim( status.notInstalled(RSTACK_NOT_INSTALLED_STATUS, configDir); return undefined; } - const packageJsonPath = resolution.packageJsonPath; + const { packageJsonPath, source } = resolution; const packageDirectory = path.dirname(packageJsonPath); const configFilePath = path.join(packageDirectory, SHIM_RELATIVE_PATH); @@ -142,10 +148,13 @@ export function resolveRstackShim( return undefined; } - // An uncached filesystem read: `nodeRequire` would pin the version seen by - // the first resolution for the lifetime of the extension host, defeating + // Read uncached by the resolution: `nodeRequire` would pin the version seen + // by the first resolution for the lifetime of the extension host, defeating // the re-resolution that every `syncBridgeProjects` pass performs. - const version = readPackageVersion(packageJsonPath); + const version = + typeof resolution.pkg?.version === 'string' + ? resolution.pkg.version + : undefined; const result = checkPackageVersion('rstack', version); if (result.kind === 'mismatch') { @@ -166,5 +175,5 @@ export function resolveRstackShim( // installed, so `versionOk` retires whichever of the two latches the // previous pass left. status.versionOk(configDir); - return { configFilePath, packageDirectory, version }; + return { configFilePath, packageDirectory, version, source }; } diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index e713132..e6a63d5 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -20,7 +20,7 @@ import { import { CONFIG_SECTION, getConfigValue } from './config'; import { createDebugWorker } from './debugWorker'; import { MessageLatch } from '../../shared/messageLatch'; -import { rstackResolveHookArgs } from '../../shared/rstackPackageSetting'; +import { rstackResolveHookArgs } from '../../shared/rstackPackage'; import { formatNotInstalledLog, formatNotInstalledStatus, @@ -247,8 +247,8 @@ export class RstestApi { // Adaptation 2: the bridge's shim loads `rstack.config.*`, in the // worker (spawned or debugged) and in the terminal CLI alike. ...rstackResolveHookArgs( - this.workspace, - this.project.isBridge ? this.rstestResolutionDir : undefined, + this.rstestResolutionDir, + this.project.source.rstackSource, ), ], }; diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index f13a0e2..83f6642 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -5,6 +5,10 @@ import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; import vscode from 'vscode'; import { RSTACK_CONFIG_NAMES } from '../../detection'; +import { + type RstackPackageSource, + resolveRstackPackageJson, +} from '../../shared/rstackPackage'; import { getConfiguredRstackPackagePath } from '../../shared/rstackPackageSetting'; import { resolveRstackShim } from './bridge'; import { watchConfigValue } from './config'; @@ -76,6 +80,8 @@ export type ProjectSource = { readonly rstestResolutionDir?: string; /** True for a `Project` synthesized by the rstack bridge. */ readonly isBridge?: boolean; + /** How the bridge found rstack; `setting` adds the resolve hook. */ + readonly rstackSource?: RstackPackageSource; }; export class WorkspaceManager implements vscode.Disposable { @@ -337,16 +343,22 @@ export class WorkspaceManager implements vscode.Disposable { const candidateDirs = new Set(); const wanted = new Map(); - const configuredRstackPackage = getConfiguredRstackPackagePath( - this.workspaceFolder, - ); + // A set `rstack.rstackCli.packagePath` names one package for every + // bridge in the folder, so it is resolved once, not per directory. + const configuredPath = getConfiguredRstackPackagePath(this.workspaceFolder); + const configuredRstack = + configuredPath === undefined + ? undefined + : resolveRstackPackageJson({ + configured: configuredPath, + folderRoot: this.workspaceFolder.uri.fsPath, + }); for (const [cwd, rstackConfig] of winnerByDir) { const key = rstackConfig.toString(); if (nativeProjectDirs.has(cwd)) continue; candidateDirs.add(cwd); const shim = resolveRstackShim(cwd, { - folderRoot: this.workspaceFolder.uri.fsPath, - configured: configuredRstackPackage, + configured: configuredRstack, silent: this.reportedShimFailures.has(cwd), }); if (!shim) { @@ -377,6 +389,7 @@ export class WorkspaceManager implements vscode.Disposable { cwd, rstestResolutionDir: shim.packageDirectory, isBridge: true, + rstackSource: shim.source, }); logger.info( `Driving Rstest from ${rstackConfig.fsPath} through the rstack config shim`, diff --git a/packages/vscode/tests/shared/notInstalled.test.ts b/packages/vscode/tests/shared/notInstalled.test.ts index 4b735f0..62f292b 100644 --- a/packages/vscode/tests/shared/notInstalled.test.ts +++ b/packages/vscode/tests/shared/notInstalled.test.ts @@ -5,6 +5,7 @@ import { formatNotInstalledLog, formatNotInstalledStatus, } from '../../src/shared/notInstalled'; +import { RSTACK_PACKAGE_PATH_HINT } from '../../src/shared/rstackPackage'; // One wording for the three stacks: the restart hint names the stack's own // command exactly as the Command Palette shows it (`category: title` in @@ -35,11 +36,18 @@ describe('not-installed wording', () => { it('logs where the stack looked', () => { expect(formatNotInstalledLog('rstack', 'app', '/repo/app')).toBe( - 'rstack is not installed in app (node_modules missing); searched from /repo/app; if it is installed elsewhere, set "rstack.rstackCli.packagePath" to it', + 'rstack is not installed in app (node_modules missing); searched from /repo/app', ); - // The override is an `rstack` setting; other packages have their own. - expect(formatNotInstalledLog('@rstest/core', 'app', '/repo/app')).toBe( - '@rstest/core is not installed in app (node_modules missing); searched from /repo/app', + expect( + formatNotInstalledLog( + 'rstack', + 'app', + '/repo/app', + undefined, + RSTACK_PACKAGE_PATH_HINT, + ), + ).toBe( + 'rstack is not installed in app (node_modules missing); searched from /repo/app; if it is installed elsewhere, set "rstack.rstackCli.packagePath" to it', ); }); diff --git a/packages/vscode/tests/shared/rstackPackage.test.ts b/packages/vscode/tests/shared/rstackPackage.test.ts index 1791b88..a1354df 100644 --- a/packages/vscode/tests/shared/rstackPackage.test.ts +++ b/packages/vscode/tests/shared/rstackPackage.test.ts @@ -1,7 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from '@rstest/core'; -import { resolveRstackPackageJson } from '../../src/shared/rstackPackage'; +import { + resolveRstackPackageJson, + rstackResolveHookImportArgs, +} from '../../src/shared/rstackPackage'; import { installPackage, removeTemporaryDirectories, @@ -31,11 +35,11 @@ describe('resolveRstackPackageJson', () => { resolveRstackPackageJson({ configured, folderRoot: root, - searchFrom: root, }), - ).toEqual({ + ).toMatchObject({ kind: 'found', packageJsonPath: path.join(rstackDir, 'package.json'), + source: 'setting', }); }, ); @@ -53,11 +57,11 @@ describe('resolveRstackPackageJson', () => { resolveRstackPackageJson({ configured: pinned, folderRoot: root, - searchFrom: root, }), - ).toEqual({ + ).toMatchObject({ kind: 'found', packageJsonPath: path.join(pinned, 'package.json'), + source: 'setting', }); }); @@ -86,9 +90,8 @@ describe('resolveRstackPackageJson', () => { resolveRstackPackageJson({ configured: 'infra/node_modules/rstack', folderRoot: root, - searchFrom: root, }), - ).toEqual({ + ).toMatchObject({ kind: 'found', packageJsonPath: path.join(storeDir, 'package.json'), }); @@ -102,7 +105,6 @@ describe('resolveRstackPackageJson', () => { const resolution = resolveRstackPackageJson({ configured: 'infra/node_modules/rstack', folderRoot: root, - searchFrom: root, }); expect(resolution.kind).toBe('invalid-setting'); @@ -120,7 +122,6 @@ describe('resolveRstackPackageJson', () => { const resolution = resolveRstackPackageJson({ configured: coreDir, folderRoot: root, - searchFrom: root, }); expect(resolution.kind).toBe('invalid-setting'); @@ -129,3 +130,16 @@ describe('resolveRstackPackageJson', () => { ).toContain('is not the "rstack" package'); }); }); + +it('carries both paths through one --import URL intact', () => { + const hookPath = '/opt/my extension/dist/rstack-resolve-hook.mjs'; + const packageJsonPath = '/ws/my infra/node_modules/rstack/package.json'; + + const [flag, href] = rstackResolveHookImportArgs(hookPath, packageJsonPath); + + expect(flag).toBe('--import'); + // What the preload reads from `import.meta.url`. + const url = new URL(href); + expect(url.searchParams.get('pkg')).toBe(packageJsonPath); + expect(fileURLToPath(url)).toBe(hookPath); +}); diff --git a/packages/vscode/tests/shared/rstackResolveHook.test.ts b/packages/vscode/tests/shared/rstackResolveHook.test.ts deleted file mode 100644 index c453395..0000000 --- a/packages/vscode/tests/shared/rstackResolveHook.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { expect, it } from '@rstest/core'; -import { rstackResolveHookImportArgs } from '../../src/shared/rstackResolveHook'; - -it('carries both paths through one --import URL intact', () => { - const hookPath = '/opt/my extension/dist/rstack-resolve-hook.mjs'; - const packageJsonPath = '/ws/my infra/node_modules/rstack/package.json'; - - const [flag, href] = rstackResolveHookImportArgs(hookPath, packageJsonPath); - - expect(flag).toBe('--import'); - // What the preload reads from `import.meta.url`. - const url = new URL(href); - expect(url.searchParams.get('pkg')).toBe(packageJsonPath); - expect(fileURLToPath(url)).toBe(hookPath); -}); diff --git a/packages/vscode/tests/stacks/fmt/runtime.test.ts b/packages/vscode/tests/stacks/fmt/runtime.test.ts index 0f292e2..f4cac53 100644 --- a/packages/vscode/tests/stacks/fmt/runtime.test.ts +++ b/packages/vscode/tests/stacks/fmt/runtime.test.ts @@ -37,6 +37,7 @@ rs.mock('../../../src/detection', () => ({ })); rs.mock('../../../src/shared/nodeExecutableSetting', () => ({ getConfiguredNodeExecutable: () => undefined, + getConfiguredPathSetting: () => undefined, })); rs.mock('../../../src/shared/nodeResolution', () => ({ resolveUserNodeOnce: async () => ({ executable: 'node' }), diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts index b77c2b4..4f8aa39 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -41,6 +41,7 @@ describe('resolveRslint', () => { coreVersion: '0.8.0', rstackDir, rstackVersion: '0.6.1', + rstackSource: 'walk-up', shimPath, }); }); @@ -95,6 +96,7 @@ describe('resolveRslint', () => { coreVersion: '0.9.5', rstackDir, rstackVersion: '0.8.4', + rstackSource: 'setting', shimPath, }); }); @@ -191,6 +193,7 @@ describe('resolveRslint', () => { coreVersion: '0.8.0', rstackDir, rstackVersion: '0.6.1', + rstackSource: 'walk-up', shimPath, }); }); diff --git a/packages/vscode/tests/stacks/test/bridge.test.ts b/packages/vscode/tests/stacks/test/bridge.test.ts index 7b1b4dd..0bf31e0 100644 --- a/packages/vscode/tests/stacks/test/bridge.test.ts +++ b/packages/vscode/tests/stacks/test/bridge.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; +import { resolveRstackPackageJson } from '../../../src/shared/rstackPackage'; import { resolveRstackShim } from '../../../src/stacks/test/bridge'; import { logger } from '../../../src/stacks/test/logger'; import { status } from '../../../src/stacks/test/status'; @@ -157,8 +158,10 @@ describe('resolveRstackShim', () => { expect( resolveRstackShim(root, { - folderRoot: root, - configured: 'infra/node_modules/rstack', + configured: resolveRstackPackageJson({ + configured: 'infra/node_modules/rstack', + folderRoot: root, + }), }), ).toBeUndefined(); expect(logged.join('\n')).toContain('rstack.rstackCli.packagePath'); diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 00a098e..06bd95f 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -193,7 +193,10 @@ const createApi = (cwd = noCoreDir, rstestResolutionDir = cwd) => { const workspace = { uri: { fsPath: cwd } }; // `sourceUri` backs the per-project status latch key, which the version // check on the spawn path reads before anything can fail. - const project = { sourceUri: { toString: () => `test://${cwd}` } }; + const project = { + sourceUri: { toString: () => `test://${cwd}` }, + source: {}, + }; return new RstestApi( workspace as any, cwd, From b5efbe985647210d4b515f943eda34bf76f980dd Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 16 Oct 2026 17:32:48 +0800 Subject: [PATCH 4/5] refactor(vscode): trim the rstack package path plumbing Drop the abstractions the setting did not need: the source union, the carried package.json, the options object, the one-line setting reader module, the hint parameter and the split hook helper. Each stack now derives "chosen by the setting" from its own single read of it. Remove the unit test that only restated the hook URL composition. --- .../src/shared/nodeExecutableSetting.ts | 10 +- packages/vscode/src/shared/notInstalled.ts | 5 +- packages/vscode/src/shared/rstackPackage.ts | 113 +++++------------- .../vscode/src/shared/rstackPackageSetting.ts | 16 --- .../src/shared/rstackResolveHookPreload.mjs | 2 +- packages/vscode/src/stacks/fmt/index.ts | 27 +++-- .../vscode/src/stacks/lint/CoreResolver.ts | 11 +- packages/vscode/src/stacks/lint/Rslint.ts | 2 +- .../vscode/src/stacks/lint/RuntimeManager.ts | 7 +- packages/vscode/src/stacks/lint/index.ts | 5 +- packages/vscode/src/stacks/lint/resolution.ts | 30 ++--- packages/vscode/src/stacks/test/bridge.ts | 34 ++---- packages/vscode/src/stacks/test/index.ts | 2 - packages/vscode/src/stacks/test/master.ts | 5 +- packages/vscode/src/stacks/test/project.ts | 26 ++-- .../vscode/tests/shared/notInstalled.test.ts | 12 -- .../vscode/tests/shared/rstackPackage.test.ts | 63 +++------- .../vscode/tests/stacks/fmt/runtime.test.ts | 1 - .../tests/stacks/lint/resolution.test.ts | 3 - .../vscode/tests/stacks/test/bridge.test.ts | 7 +- 20 files changed, 113 insertions(+), 268 deletions(-) delete mode 100644 packages/vscode/src/shared/rstackPackageSetting.ts diff --git a/packages/vscode/src/shared/nodeExecutableSetting.ts b/packages/vscode/src/shared/nodeExecutableSetting.ts index ea231cd..9526ce0 100644 --- a/packages/vscode/src/shared/nodeExecutableSetting.ts +++ b/packages/vscode/src/shared/nodeExecutableSetting.ts @@ -28,19 +28,13 @@ export const getConfiguredNodeExecutable = ( ): string | undefined => getConfiguredPathSetting(NODE_EXECUTABLE_SETTING, folder); -/** - * The one reading rule for a resource-scoped path setting: `undefined` when - * unset or empty, else the value with `${workspaceFolder}` expanded. - * `resource` defaults to the folder; pass a document to honour a narrower - * scope. - */ +/** A folder's path setting: unset or empty is `undefined`, else expanded. */ export const getConfiguredPathSetting = ( setting: string, folder: vscode.WorkspaceFolder, - resource: vscode.Uri = folder.uri, ): string | undefined => { const value = vscode.workspace - .getConfiguration(undefined, resource) + .getConfiguration(undefined, folder.uri) .get(setting); if (typeof value !== 'string' || value === '') { return undefined; diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index 044c6a8..96a5827 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -87,16 +87,13 @@ export class NotInstalledEpisode { * The output-channel line: where the stack looked, plus the stack's own * consequence — the same shape as the shared Node preflight message * (adaptation 6), where each caller appends what the state means for it. - * `hint` names a way out the walk-up cannot see (an rstack caller passes - * `RSTACK_PACKAGE_PATH_HINT`, #103). */ export const formatNotInstalledLog = ( packageName: string, folderName: string, searchedFrom: string, consequence?: string, - hint?: string, ): string => `${packageName} is not installed in ${folderName} (node_modules missing); searched from ${searchedFrom}${ consequence ? `; ${consequence}` : '' - }${hint ? `; ${hint}` : ''}`; + }`; diff --git a/packages/vscode/src/shared/rstackPackage.ts b/packages/vscode/src/shared/rstackPackage.ts index e5d5508..ef3c094 100644 --- a/packages/vscode/src/shared/rstackPackage.ts +++ b/packages/vscode/src/shared/rstackPackage.ts @@ -4,44 +4,20 @@ import { pathToFileURL } from 'node:url'; import { findPackageJsonUncached, readPackageJson } from './packageResolve'; /** - * Where an `rstack` (rstack-cli) installation lives that the default walk-up - * cannot see — e.g. a monorepo that installs its tooling under `infra/` while - * `rstack.config.*` sits at the root (#103). One setting for every consumer - * of the package: the `rs fmt` server, and the lint and test stacks when a - * root `rstack.config.*` bridges them. Detection does not read it — it only - * changes where resolution looks, like the per-tool `corePath` overrides. + * Where an `rstack` installation lives that the walk-up cannot see (#103). + * Read by fmt and by bridged lint and test; like `corePath`, it changes + * resolution only, never detection. */ export const RSTACK_PACKAGE_PATH_SETTING = 'rstack.rstackCli.packagePath'; /** The `not-installed` log's pointer to the setting, for every rstack caller. */ export const RSTACK_PACKAGE_PATH_HINT = `if it is installed elsewhere, set "${RSTACK_PACKAGE_PATH_SETTING}" to it`; -/** Whether the setting chose the package, which decides the resolve hook. */ -export type RstackPackageSource = 'setting' | 'walk-up'; - export type RstackPackageResolution = - | { - readonly kind: 'found'; - readonly packageJsonPath: string; - /** The parsed package.json, read once for every consumer. */ - readonly pkg: Record | undefined; - readonly source: RstackPackageSource; - } + | { readonly kind: 'found'; readonly packageJsonPath: string } | { readonly kind: 'not-installed' } | { readonly kind: 'invalid-setting'; readonly message: string }; -export interface ResolveRstackPackageOptions { - /** - * The setting's value, `${workspaceFolder}` already expanded; unset means - * "walk up from `searchFrom`". - */ - readonly configured: string | undefined; - /** The workspace folder root; relative settings resolve from it. */ - readonly folderRoot: string; - /** Where the default `node_modules` walk-up starts; defaults to `folderRoot`. */ - readonly searchFrom?: string; -} - const invalidSetting = ( configuredPath: string, problem: string, @@ -51,31 +27,21 @@ const invalidSetting = ( }); /** - * Locates `rstack/package.json` for one consumer. A configured path wins and - * is never second-guessed by the walk-up: a setting that does not resolve is - * a mistake in the setting (`invalid-setting`), not a missing install, so it - * must not take the not-installed state (AGENTS.md). - * - * The returned path is a realpath, the same as `findPackageJsonUncached` - * returns: the lint and test stacks resolve their tool cores *from* the - * rstack directory, and under pnpm only the store path has rstack's own - * dependencies beside it. + * Locates `rstack/package.json`: the setting's value (`configured`, relative + * to `folderRoot`) when set, else the walk-up from `folderRoot`. A setting + * that does not resolve is `invalid-setting`, never the not-installed state. + * The path is a realpath: under pnpm only the store path has rstack's own + * dependencies beside it, which lint and test resolve their cores from. */ -export const resolveRstackPackageJson = ({ - configured, - folderRoot, - searchFrom = folderRoot, -}: ResolveRstackPackageOptions): RstackPackageResolution => { +export const resolveRstackPackageJson = ( + folderRoot: string, + configured?: string, +): RstackPackageResolution => { if (configured === undefined) { - const packageJsonPath = findPackageJsonUncached('rstack', searchFrom); + const packageJsonPath = findPackageJsonUncached('rstack', folderRoot); return packageJsonPath === undefined ? { kind: 'not-installed' } - : { - kind: 'found', - packageJsonPath, - pkg: readPackageJson(packageJsonPath), - source: 'walk-up', - }; + : { kind: 'found', packageJsonPath }; } const resolved = path.resolve(folderRoot, configured); @@ -89,45 +55,26 @@ export const resolveRstackPackageJson = ({ } catch { return invalidSetting(configuredPath, 'does not exist'); } - const pkg = readPackageJson(packageJsonPath); - if (pkg?.name !== 'rstack') { + if (readPackageJson(packageJsonPath)?.name !== 'rstack') { return invalidSetting(configuredPath, 'is not the "rstack" package'); } - return { kind: 'found', packageJsonPath, pkg, source: 'setting' }; + return { kind: 'found', packageJsonPath }; }; /** - * The resolve hook as node args, for a process that loads `rstack.config.*` - * while the setting chose its `rstack`: the config's own `import 'rstack'` - * resolves from the config file, where that installation is not visible. The - * preloaded hook (`rstackResolveHookPreload.mjs`) hands the specifier to - * Node's resolver from inside the configured package; nothing a - * package-manager shim does (`NODE_PATH`) and no file inside `rstack` is - * reproduced. A node arg rather than env, so it reaches exactly the spawned - * process and fits a terminal command line; the package.json path rides in - * the URL's `pkg` query, keeping the flag one argv element. - */ -export function rstackResolveHookImportArgs( - hookPath: string, - packageJsonPath: string, -): string[] { - const url = pathToFileURL(hookPath); - url.searchParams.set('pkg', packageJsonPath); - return ['--import', url.href]; -} - -/** - * The hook args (before the script path) for the rstack at `rstackDirectory`: - * none unless the setting chose it. + * The node args (before the script path) that preload the resolve hook + * (`rstackResolveHookPreload.mjs`) when the setting chose the rstack at + * `rstackDirectory`, so the config's own `import 'rstack'` reaches that + * package. A node arg, not env, so a terminal command line can carry it; the + * package.json path rides in the URL's `pkg` query. */ export const rstackResolveHookArgs = ( rstackDirectory: string | undefined, - source: RstackPackageSource | undefined, -): string[] => - rstackDirectory === undefined || source !== 'setting' - ? [] - : rstackResolveHookImportArgs( - // Copied beside the bundles by `rslib.config.mts`. - path.resolve(__dirname, 'rstack-resolve-hook.mjs'), - path.join(rstackDirectory, 'package.json'), - ); + fromSetting: boolean | undefined, +): string[] => { + if (rstackDirectory === undefined || !fromSetting) return []; + // Copied beside the bundles by `rslib.config.mts`. + const url = pathToFileURL(path.resolve(__dirname, 'rstack-resolve-hook.mjs')); + url.searchParams.set('pkg', path.join(rstackDirectory, 'package.json')); + return ['--import', url.href]; +}; diff --git a/packages/vscode/src/shared/rstackPackageSetting.ts b/packages/vscode/src/shared/rstackPackageSetting.ts deleted file mode 100644 index 8abcbca..0000000 --- a/packages/vscode/src/shared/rstackPackageSetting.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type vscode from 'vscode'; -import { getConfiguredPathSetting } from './nodeExecutableSetting'; -import { RSTACK_PACKAGE_PATH_SETTING } from './rstackPackage'; - -/** - * Reads `rstack.rstackCli.packagePath` for a resource (a folder, or a document - * inside one — the setting is resource-scoped). Kept apart from - * `rstackPackage.ts` so the resolution itself stays testable without a - * `vscode` stub, the same split as `nodeExecutableSetting.ts` / - * `nodeResolution.ts`. - */ -export const getConfiguredRstackPackagePath = ( - folder: vscode.WorkspaceFolder, - resource?: vscode.Uri, -): string | undefined => - getConfiguredPathSetting(RSTACK_PACKAGE_PATH_SETTING, folder, resource); diff --git a/packages/vscode/src/shared/rstackResolveHookPreload.mjs b/packages/vscode/src/shared/rstackResolveHookPreload.mjs index 41554fd..8e49ab4 100644 --- a/packages/vscode/src/shared/rstackResolveHookPreload.mjs +++ b/packages/vscode/src/shared/rstackResolveHookPreload.mjs @@ -1,5 +1,5 @@ // Preloaded via a `--import` node arg whose `pkg` query carries the -// configured package.json path (why: `rstackResolveHookImportArgs` in +// configured package.json path (why: `rstackResolveHookArgs` in // `rstackPackage.ts`). // Resolves `rstack` and `rstack/*` as if from inside the configured package, // through its own `exports`; every other specifier passes through. Plain JS, diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 79fe6d7..6fa0a76 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -16,7 +16,10 @@ import { RSTACK_CONFIG_GLOB } from '../../detection'; import { MessageLatch } from '../../shared/messageLatch'; import { displayPath } from '../../shared/displayPath'; import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; -import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; +import { + getConfiguredNodeExecutable, + getConfiguredPathSetting, +} from '../../shared/nodeExecutableSetting'; import { NotInstalledEpisode, formatNotInstalledLog, @@ -28,13 +31,13 @@ import { NodePreflightError, resolveUserNodeOnce, } from '../../shared/nodeResolution'; +import { readPackageJson } from '../../shared/packageResolve'; import { RSTACK_PACKAGE_PATH_HINT, RSTACK_PACKAGE_PATH_SETTING, resolveRstackPackageJson, rstackResolveHookArgs, } from '../../shared/rstackPackage'; -import { getConfiguredRstackPackagePath } from '../../shared/rstackPackageSetting'; import { checkPackageVersion, formatVersionMismatch, @@ -366,10 +369,11 @@ class FmtFolderRuntime { this.#closing = false; this.setState('starting'); - const rstackPackage = resolveRstackPackageJson({ - configured: getConfiguredRstackPackagePath(this.folder), - folderRoot, - }); + const configured = getConfiguredPathSetting( + RSTACK_PACKAGE_PATH_SETTING, + this.folder, + ); + const rstackPackage = resolveRstackPackageJson(folderRoot, configured); if (rstackPackage.kind === 'invalid-setting') { // A wrong setting is fixed in the setting, not by an install, so it is // `crashed` rather than the not-installed `disabled`. @@ -389,7 +393,6 @@ class FmtFolderRuntime { 'rstack', this.folder.name, folderRoot, - undefined, RSTACK_PACKAGE_PATH_HINT, ), ); @@ -397,11 +400,11 @@ class FmtFolderRuntime { return; } this.#packageEpisode.clear(); - const { packageJsonPath: pkgJsonPath, pkg } = rstackPackage; + const pkgJsonPath = rstackPackage.packageJsonPath; - // One read for the version and the bin entry, made by the resolution - // above; it re-reads from disk by design, so a reinstall is picked up on - // the next start. + // One read for the version and the bin entry; `readPackageJson` re-reads + // from disk by design, so a reinstall is picked up on the next start. + const pkg = readPackageJson(pkgJsonPath); const version = typeof pkg?.version === 'string' ? pkg.version : undefined; const versionCheck = checkPackageVersion('rstack', version); if (versionCheck.kind === 'mismatch') { @@ -426,7 +429,7 @@ class FmtFolderRuntime { [ ...rstackResolveHookArgs( path.dirname(pkgJsonPath), - rstackPackage.source, + configured !== undefined, ), rsBinJs, 'fmt', diff --git a/packages/vscode/src/stacks/lint/CoreResolver.ts b/packages/vscode/src/stacks/lint/CoreResolver.ts index 71bca71..151f867 100644 --- a/packages/vscode/src/stacks/lint/CoreResolver.ts +++ b/packages/vscode/src/stacks/lint/CoreResolver.ts @@ -34,7 +34,6 @@ import { type RslintMode, type RslintResolution, } from './resolution'; -import type { RstackPackageSource } from '../../shared/rstackPackage'; import { RslintVersionMismatchError } from './status'; /** @@ -56,8 +55,8 @@ export interface CoreInstallation { readonly shimPath?: string; readonly rstackDirectory?: string; readonly rstackVersion?: string; - /** Whether `rstack.rstackCli.packagePath` chose rstack (the resolve hook). */ - readonly rstackSource?: RstackPackageSource; + /** `rstack.rstackCli.packagePath` chose rstack, so the worker needs the hook. */ + readonly rstackFromSetting?: boolean; } export interface ResolvedCoreRuntime { @@ -71,7 +70,7 @@ export interface CoreResolutionRequest { readonly mode: RslintMode; /** `rstack.rslint.corePath`, read with the document's URI (resource scope). */ readonly corePath?: string; - /** `rstack.rstackCli.packagePath`, read the same way; bridged mode only. */ + /** `rstack.rstackCli.packagePath`; bridged mode only. */ readonly rstackPackagePath?: string; } @@ -98,6 +97,7 @@ export function runtimeKey( function validateInstallation( identity: string, resolution: RslintResolution, + rstackFromSetting: boolean, ): CoreInstallation { if (resolution.mode === 'bridged') { const rstackCheck = checkPackageVersion('rstack', resolution.rstackVersion); @@ -124,7 +124,7 @@ function validateInstallation( shimPath: resolution.shimPath, rstackDirectory: resolution.rstackDir, rstackVersion: resolution.rstackVersion, - rstackSource: resolution.rstackSource, + rstackFromSetting, }), }; } @@ -152,6 +152,7 @@ export class CoreResolver { const installation = validateInstallation( normalizeIdentity(resolution.coreDir), resolution, + request.rstackPackagePath !== undefined, ); return { key: runtimeKey(workspaceFolder, installation), diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index a1053a5..025a319 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -487,7 +487,7 @@ export class Rslint implements Disposable { // Adaptation 2: the bridged worker's shim loads `rstack.config.*`. ...rstackResolveHookArgs( this.installation.rstackDirectory, - this.installation.rstackSource, + this.installation.rstackFromSetting, ), workerPath, '--lsp', diff --git a/packages/vscode/src/stacks/lint/RuntimeManager.ts b/packages/vscode/src/stacks/lint/RuntimeManager.ts index b1bee09..864357e 100644 --- a/packages/vscode/src/stacks/lint/RuntimeManager.ts +++ b/packages/vscode/src/stacks/lint/RuntimeManager.ts @@ -21,7 +21,8 @@ // stopped same-key clients on reconcile — see AGENTS.md ("Ahead of upstream"). import { workspace, type TextDocument, type WorkspaceFolder } from 'vscode'; -import { getConfiguredRstackPackagePath } from '../../shared/rstackPackageSetting'; +import { getConfiguredPathSetting } from '../../shared/nodeExecutableSetting'; +import { RSTACK_PACKAGE_PATH_SETTING } from '../../shared/rstackPackage'; import type { CoreResolutionRequest, ResolvedCoreRuntime, @@ -245,9 +246,9 @@ export class RuntimeManager { const resolved = await this.resolver.resolve(document, workspaceFolder, { mode, corePath: configuration.get('corePath'), - rstackPackagePath: getConfiguredRstackPackagePath( + rstackPackagePath: getConfiguredPathSetting( + RSTACK_PACKAGE_PATH_SETTING, workspaceFolder, - document.uri, ), }); return { action: 'bind', workspaceFolder, resolved }; diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index a060f4a..ead524a 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -223,8 +223,9 @@ class RslintController implements StackController { missing, workspaceFolder.name, workspaceFolder.uri.fsPath, - `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, - missing === 'rstack' ? RSTACK_PACKAGE_PATH_HINT : undefined, + `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed${ + missing === 'rstack' ? `; ${RSTACK_PACKAGE_PATH_HINT}` : '' + }`, ); logger.warn(warning); } else { diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index f42925a..83eb36c 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -4,10 +4,7 @@ import { findPackageJsonUncached, readPackageJson, } from '../../shared/packageResolve'; -import { - type RstackPackageSource, - resolveRstackPackageJson, -} from '../../shared/rstackPackage'; +import { resolveRstackPackageJson } from '../../shared/rstackPackage'; export type RslintMode = 'native' | 'bridged'; @@ -17,7 +14,6 @@ export interface RslintResolution { readonly coreVersion: string | undefined; readonly rstackDir?: string; readonly rstackVersion?: string; - readonly rstackSource?: RstackPackageSource; readonly shimPath?: string; } @@ -152,8 +148,8 @@ interface PackageLocation { function readPackageLocation( packageName: 'rstack' | '@rslint/core', packageJsonPath: string, - pkg = readPackageJson(packageJsonPath), ): PackageLocation { + const pkg = readPackageJson(packageJsonPath); if (pkg?.name !== packageName) { throw new RslintResolutionError( 'invalid-package', @@ -196,18 +192,11 @@ function resolveConfiguredCore( function resolveRstack( folderRoot: string, configured: string | undefined, -): PackageLocation & { readonly source: RstackPackageSource } { - const resolution = resolveRstackPackageJson({ configured, folderRoot }); +): PackageLocation { + const resolution = resolveRstackPackageJson(folderRoot, configured); switch (resolution.kind) { case 'found': - return { - ...readPackageLocation( - 'rstack', - resolution.packageJsonPath, - resolution.pkg, - ), - source: resolution.source, - }; + return readPackageLocation('rstack', resolution.packageJsonPath); case 'not-installed': throw new RslintResolutionError( 'missing-rstack', @@ -237,11 +226,7 @@ export interface ResolveRslintOptions { readonly folderRoot: string; readonly mode: RslintMode; readonly corePath?: string; - /** - * `rstack.rstackCli.packagePath`, `${workspaceFolder}` expanded. Bridged mode - * only: it replaces the folder-root walk-up for `rstack`, and the core hop - * still starts from the directory it selects. - */ + /** `rstack.rstackCli.packagePath`; replaces bridged mode's rstack walk-up. */ readonly rstackPackagePath?: string; /** * Where the native walk-up starts (per-document resolution, rslint #1617); @@ -259,7 +244,7 @@ export function resolveRslint({ rstackPackagePath, documentDirectory, }: ResolveRslintOptions): RslintResolution { - let rstack: ReturnType | undefined; + let rstack: PackageLocation | undefined; let shimPath: string | undefined; if (mode === 'bridged') { rstack = resolveRstack(folderRoot, rstackPackagePath); @@ -291,7 +276,6 @@ export function resolveRslint({ : { rstackDir: rstack.directory, rstackVersion: rstack.version, - rstackSource: rstack.source, shimPath, }), }; diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index 1752c0b..879af06 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -3,12 +3,12 @@ import path from 'node:path'; import { RSTACK_PACKAGE_PATH_HINT, type RstackPackageResolution, - type RstackPackageSource, resolveRstackPackageJson, } from '../../shared/rstackPackage'; import { checkPackageVersion, formatVersionMismatch, + readPackageVersion, } from '../../shared/versionCheck'; import { formatNotInstalledLog, @@ -62,8 +62,6 @@ export type RstackShim = { readonly packageDirectory: string; /** The installed `rstack` version, when it could be read. */ readonly version?: string; - /** Whether `rstack.rstackCli.packagePath` chose it (the resolve hook). */ - readonly source: RstackPackageSource; }; /** @@ -80,9 +78,7 @@ export type RstackShim = { * * Resolution is anchored on the config directory rather than the workspace * folder so a monorepo package with its own `rstack` install wins over the root. - * A configured `rstack.rstackCli.packagePath` replaces that walk-up for every - * bridge in the folder, the way `rstack.rstest.corePath` overrides every - * project's core. + * A set `rstack.rstackCli.packagePath` replaces that walk-up. */ export function resolveRstackShim( configDir: string, @@ -93,18 +89,12 @@ export function resolveRstackShim( // re-log. silent = false, }: { - /** - * The resolution of a set `rstack.rstackCli.packagePath`, made once by the - * caller for every bridge in the folder; absent, rstack is walked up to - * from `configDir`. - */ + /** The resolution of a set `rstack.rstackCli.packagePath`. */ configured?: RstackPackageResolution; silent?: boolean; } = {}, ): RstackShim | undefined { - const resolution = - configured ?? - resolveRstackPackageJson({ configured: undefined, folderRoot: configDir }); + const resolution = configured ?? resolveRstackPackageJson(configDir); if (resolution.kind === 'invalid-setting') { // Not the not-installed state: the fix is the setting, not an install. if (!silent) { @@ -120,8 +110,7 @@ export function resolveRstackShim( 'rstack', path.basename(configDir), configDir, - 'Rstest cannot be driven by "rstack.config.*" until it is installed', - RSTACK_PACKAGE_PATH_HINT, + `Rstest cannot be driven by "rstack.config.*" until it is installed; ${RSTACK_PACKAGE_PATH_HINT}`, ), ); } @@ -132,7 +121,7 @@ export function resolveRstackShim( status.notInstalled(RSTACK_NOT_INSTALLED_STATUS, configDir); return undefined; } - const { packageJsonPath, source } = resolution; + const packageJsonPath = resolution.packageJsonPath; const packageDirectory = path.dirname(packageJsonPath); const configFilePath = path.join(packageDirectory, SHIM_RELATIVE_PATH); @@ -148,13 +137,10 @@ export function resolveRstackShim( return undefined; } - // Read uncached by the resolution: `nodeRequire` would pin the version seen - // by the first resolution for the lifetime of the extension host, defeating + // An uncached filesystem read: `nodeRequire` would pin the version seen by + // the first resolution for the lifetime of the extension host, defeating // the re-resolution that every `syncBridgeProjects` pass performs. - const version = - typeof resolution.pkg?.version === 'string' - ? resolution.pkg.version - : undefined; + const version = readPackageVersion(packageJsonPath); const result = checkPackageVersion('rstack', version); if (result.kind === 'mismatch') { @@ -175,5 +161,5 @@ export function resolveRstackShim( // installed, so `versionOk` retires whichever of the two latches the // previous pass left. status.versionOk(configDir); - return { configFilePath, packageDirectory, version, source }; + return { configFilePath, packageDirectory, version }; } diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index 318f60d..e5c2254 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -561,8 +561,6 @@ class RstestController implements StackController { // qualifies: installing a newer Node on the machine is not observable by the // extension and stays a manual restart, the deliberate half of the deal (see // `docs/adr/0001-node-runtime-selection.md`). - // `rstack.rstackCli.packagePath` moves every bridge's shim and core anchor, - // which a live project keeps until it is rebuilt. readonly restartOnSettings = [ NODE_EXECUTABLE_SETTING, RSTACK_PACKAGE_PATH_SETTING, diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index e6a63d5..4c2e576 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -244,11 +244,10 @@ export class RstestApi { ...getConfigValue('nodeExecArgs', this.workspace).map((arg) => this.expandWorkspaceFolder(arg), ), - // Adaptation 2: the bridge's shim loads `rstack.config.*`, in the - // worker (spawned or debugged) and in the terminal CLI alike. + // Adaptation 2: the bridge's shim loads `rstack.config.*`. ...rstackResolveHookArgs( this.rstestResolutionDir, - this.project.source.rstackSource, + this.project.source.rstackFromSetting, ), ], }; diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 83f6642..2428582 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -5,11 +5,11 @@ import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; import vscode from 'vscode'; import { RSTACK_CONFIG_NAMES } from '../../detection'; +import { getConfiguredPathSetting } from '../../shared/nodeExecutableSetting'; import { - type RstackPackageSource, + RSTACK_PACKAGE_PATH_SETTING, resolveRstackPackageJson, } from '../../shared/rstackPackage'; -import { getConfiguredRstackPackagePath } from '../../shared/rstackPackageSetting'; import { resolveRstackShim } from './bridge'; import { watchConfigValue } from './config'; import { @@ -80,8 +80,8 @@ export type ProjectSource = { readonly rstestResolutionDir?: string; /** True for a `Project` synthesized by the rstack bridge. */ readonly isBridge?: boolean; - /** How the bridge found rstack; `setting` adds the resolve hook. */ - readonly rstackSource?: RstackPackageSource; + /** `rstack.rstackCli.packagePath` chose rstack, so workers need the hook. */ + readonly rstackFromSetting?: boolean; }; export class WorkspaceManager implements vscode.Disposable { @@ -343,16 +343,18 @@ export class WorkspaceManager implements vscode.Disposable { const candidateDirs = new Set(); const wanted = new Map(); - // A set `rstack.rstackCli.packagePath` names one package for every - // bridge in the folder, so it is resolved once, not per directory. - const configuredPath = getConfiguredRstackPackagePath(this.workspaceFolder); + // A set `rstack.rstackCli.packagePath` is one package for every bridge. + const configuredPath = getConfiguredPathSetting( + RSTACK_PACKAGE_PATH_SETTING, + this.workspaceFolder, + ); const configuredRstack = configuredPath === undefined ? undefined - : resolveRstackPackageJson({ - configured: configuredPath, - folderRoot: this.workspaceFolder.uri.fsPath, - }); + : resolveRstackPackageJson( + this.workspaceFolder.uri.fsPath, + configuredPath, + ); for (const [cwd, rstackConfig] of winnerByDir) { const key = rstackConfig.toString(); if (nativeProjectDirs.has(cwd)) continue; @@ -389,7 +391,7 @@ export class WorkspaceManager implements vscode.Disposable { cwd, rstestResolutionDir: shim.packageDirectory, isBridge: true, - rstackSource: shim.source, + rstackFromSetting: configuredRstack !== undefined, }); logger.info( `Driving Rstest from ${rstackConfig.fsPath} through the rstack config shim`, diff --git a/packages/vscode/tests/shared/notInstalled.test.ts b/packages/vscode/tests/shared/notInstalled.test.ts index 62f292b..bd974c0 100644 --- a/packages/vscode/tests/shared/notInstalled.test.ts +++ b/packages/vscode/tests/shared/notInstalled.test.ts @@ -5,7 +5,6 @@ import { formatNotInstalledLog, formatNotInstalledStatus, } from '../../src/shared/notInstalled'; -import { RSTACK_PACKAGE_PATH_HINT } from '../../src/shared/rstackPackage'; // One wording for the three stacks: the restart hint names the stack's own // command exactly as the Command Palette shows it (`category: title` in @@ -38,17 +37,6 @@ describe('not-installed wording', () => { expect(formatNotInstalledLog('rstack', 'app', '/repo/app')).toBe( 'rstack is not installed in app (node_modules missing); searched from /repo/app', ); - expect( - formatNotInstalledLog( - 'rstack', - 'app', - '/repo/app', - undefined, - RSTACK_PACKAGE_PATH_HINT, - ), - ).toBe( - 'rstack is not installed in app (node_modules missing); searched from /repo/app; if it is installed elsewhere, set "rstack.rstackCli.packagePath" to it', - ); }); it("logs the config, the loader's own words and the way out", () => { diff --git a/packages/vscode/tests/shared/rstackPackage.test.ts b/packages/vscode/tests/shared/rstackPackage.test.ts index a1354df..8f73688 100644 --- a/packages/vscode/tests/shared/rstackPackage.test.ts +++ b/packages/vscode/tests/shared/rstackPackage.test.ts @@ -1,11 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from '@rstest/core'; -import { - resolveRstackPackageJson, - rstackResolveHookImportArgs, -} from '../../src/shared/rstackPackage'; +import { resolveRstackPackageJson } from '../../src/shared/rstackPackage'; import { installPackage, removeTemporaryDirectories, @@ -31,15 +27,9 @@ describe('resolveRstackPackageJson', () => { '0.8.4', ); - expect( - resolveRstackPackageJson({ - configured, - folderRoot: root, - }), - ).toMatchObject({ + expect(resolveRstackPackageJson(root, configured)).toEqual({ kind: 'found', packageJsonPath: path.join(rstackDir, 'package.json'), - source: 'setting', }); }, ); @@ -53,15 +43,9 @@ describe('resolveRstackPackageJson', () => { '0.8.4', ); - expect( - resolveRstackPackageJson({ - configured: pinned, - folderRoot: root, - }), - ).toMatchObject({ + expect(resolveRstackPackageJson(root, pinned)).toEqual({ kind: 'found', packageJsonPath: path.join(pinned, 'package.json'), - source: 'setting', }); }); @@ -86,15 +70,12 @@ describe('resolveRstackPackageJson', () => { fs.mkdirSync(path.dirname(link), { recursive: true }); fs.symlinkSync(storeDir, link, 'junction'); - expect( - resolveRstackPackageJson({ - configured: 'infra/node_modules/rstack', - folderRoot: root, - }), - ).toMatchObject({ - kind: 'found', - packageJsonPath: path.join(storeDir, 'package.json'), - }); + expect(resolveRstackPackageJson(root, 'infra/node_modules/rstack')).toEqual( + { + kind: 'found', + packageJsonPath: path.join(storeDir, 'package.json'), + }, + ); }); it('treats a missing path as a setting error, not a missing install', () => { @@ -102,10 +83,10 @@ describe('resolveRstackPackageJson', () => { // An installation the walk-up would find must not mask the mistake. installPackage(root, 'rstack', '0.8.4'); - const resolution = resolveRstackPackageJson({ - configured: 'infra/node_modules/rstack', - folderRoot: root, - }); + const resolution = resolveRstackPackageJson( + root, + 'infra/node_modules/rstack', + ); expect(resolution.kind).toBe('invalid-setting'); const message = resolution.kind === 'invalid-setting' && resolution.message; @@ -119,10 +100,7 @@ describe('resolveRstackPackageJson', () => { const root = temporaryDirectory(); const coreDir = installPackage(root, '@rslint/core', '0.9.5'); - const resolution = resolveRstackPackageJson({ - configured: coreDir, - folderRoot: root, - }); + const resolution = resolveRstackPackageJson(root, coreDir); expect(resolution.kind).toBe('invalid-setting'); expect( @@ -130,16 +108,3 @@ describe('resolveRstackPackageJson', () => { ).toContain('is not the "rstack" package'); }); }); - -it('carries both paths through one --import URL intact', () => { - const hookPath = '/opt/my extension/dist/rstack-resolve-hook.mjs'; - const packageJsonPath = '/ws/my infra/node_modules/rstack/package.json'; - - const [flag, href] = rstackResolveHookImportArgs(hookPath, packageJsonPath); - - expect(flag).toBe('--import'); - // What the preload reads from `import.meta.url`. - const url = new URL(href); - expect(url.searchParams.get('pkg')).toBe(packageJsonPath); - expect(fileURLToPath(url)).toBe(hookPath); -}); diff --git a/packages/vscode/tests/stacks/fmt/runtime.test.ts b/packages/vscode/tests/stacks/fmt/runtime.test.ts index f4cac53..29bbeb6 100644 --- a/packages/vscode/tests/stacks/fmt/runtime.test.ts +++ b/packages/vscode/tests/stacks/fmt/runtime.test.ts @@ -22,7 +22,6 @@ rs.mock('vscode', () => ({ showInformationMessage() {}, }, workspace: { - getConfiguration: () => ({ get: () => undefined }), createFileSystemWatcher: () => ({ onDidCreate: () => ({ dispose() {} }), onDidChange: () => ({ dispose() {} }), diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts index 4f8aa39..b77c2b4 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -41,7 +41,6 @@ describe('resolveRslint', () => { coreVersion: '0.8.0', rstackDir, rstackVersion: '0.6.1', - rstackSource: 'walk-up', shimPath, }); }); @@ -96,7 +95,6 @@ describe('resolveRslint', () => { coreVersion: '0.9.5', rstackDir, rstackVersion: '0.8.4', - rstackSource: 'setting', shimPath, }); }); @@ -193,7 +191,6 @@ describe('resolveRslint', () => { coreVersion: '0.8.0', rstackDir, rstackVersion: '0.6.1', - rstackSource: 'walk-up', shimPath, }); }); diff --git a/packages/vscode/tests/stacks/test/bridge.test.ts b/packages/vscode/tests/stacks/test/bridge.test.ts index 0bf31e0..e853369 100644 --- a/packages/vscode/tests/stacks/test/bridge.test.ts +++ b/packages/vscode/tests/stacks/test/bridge.test.ts @@ -119,6 +119,8 @@ describe('resolveRstackShim', () => { expect(resolveRstackShim(root)).toBeUndefined(); // The shared not-installed wording, with the bridge's own consequence. expect(logged.join('\n')).toContain('rstack is not installed'); + // The way out for an install the walk-up cannot see (#103). + expect(logged.join('\n')).toContain('set "rstack.rstackCli.packagePath"'); expect(logged.join('\n')).toContain('Rstest cannot be driven'); // The uniform not-installed policy: the same `disabled` shape fmt and // lint report, with the restart command as the way out. @@ -158,10 +160,7 @@ describe('resolveRstackShim', () => { expect( resolveRstackShim(root, { - configured: resolveRstackPackageJson({ - configured: 'infra/node_modules/rstack', - folderRoot: root, - }), + configured: resolveRstackPackageJson(root, 'infra/node_modules/rstack'), }), ).toBeUndefined(); expect(logged.join('\n')).toContain('rstack.rstackCli.packagePath'); From 7d97e99e91b8b412ba9b5cbc1a4680eda5ad96fd Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 16 Oct 2026 17:44:56 +0800 Subject: [PATCH 5/5] fix(vscode): retire the Rstest invalid-setting crash once rstack resolves A missing rstack.rstackCli.packagePath latched a crash under the bridge's config directory, and the successful resolution only retired the not-installed and mismatch latches, so Rstest stayed crashed after the path appeared (e.g. after an install) until a restart. --- packages/vscode/src/stacks/test/bridge.ts | 8 +++--- packages/vscode/src/stacks/test/status.ts | 15 +++++++++-- .../vscode/tests/stacks/test/bridge.test.ts | 27 +++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index 879af06..11051e9 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -157,9 +157,9 @@ export function resolveRstackShim( packageDirectory, version, }); - // One recovery observation: a passed version check proves the package is - // installed, so `versionOk` retires whichever of the two latches the - // previous pass left. - status.versionOk(configDir); + // One recovery observation: the shim resolved completely, so `resolved` + // retires whatever latch the previous pass left here, an invalid-setting + // crash included. + status.resolved(configDir); return { configFilePath, packageDirectory, version }; } diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 5d54db5..c84e857 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -39,8 +39,9 @@ class StatusHolder implements StatusReporter { // A recovery observed under one key must not clear another key's // live failure. An entry is cleared by the code path that observes the // corresponding recovery (a worker that actually spawned, a version check - // that passed) or by `forget` when its reporter goes away — neither of which - // reaches a host-scoped entry, since both are per-resolution-root. A + // that passed, a bridge resolution that succeeded) or by `forget` when its + // reporter goes away — neither of which reaches a host-scoped entry, since + // both are per-resolution-root. A // host-scoped latch is cleared only by `bind`, i.e. by re-registering the // stack, which is exactly the lifetime of the resolution it reports on. #crashes = new Map(); @@ -190,6 +191,16 @@ class StatusHolder implements StatusReporter { if (hadMismatch || hadNotInstalled) this.#paintOrRun(); } + /** + * A bridge shim resolved completely under its config directory: every latch + * there is over, a crash included. Workers report under their project URI, + * so the only crash under that key is the resolution's own invalid + * `rstack.rstackCli.packagePath`, a package-state fact like the other two. + */ + resolved(source: string): void { + this.forget(source); + } + /** A root's dependencies are not installed: `disabled`, with the way out. */ notInstalled(reason: string, source = ''): void { this.#observePackageState(this.#notInstalled, reason, source); diff --git a/packages/vscode/tests/stacks/test/bridge.test.ts b/packages/vscode/tests/stacks/test/bridge.test.ts index e853369..4b7ec53 100644 --- a/packages/vscode/tests/stacks/test/bridge.test.ts +++ b/packages/vscode/tests/stacks/test/bridge.test.ts @@ -167,6 +167,33 @@ describe('resolveRstackShim', () => { expect(reported.map((state) => state.kind)).toEqual(['crashed']); }); + it('retires the setting crash once the configured path resolves', () => { + const root = makeTmpDir(); + const configured = 'infra/node_modules/rstack'; + resolveRstackShim(root, { + configured: resolveRstackPackageJson(root, configured), + }); + expect(status.hasFailed(root)).toBe(true); + + // The path appears without a settings edit (e.g. `pnpm install`); the + // next sync pass re-resolves it. + const rstackDir = path.join(root, configured); + fs.mkdirSync(path.join(rstackDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(rstackDir, 'package.json'), + JSON.stringify({ name: 'rstack', version: '0.7.6' }), + ); + fs.writeFileSync(path.join(rstackDir, 'dist', 'rstestConfig.js'), ''); + + expect( + resolveRstackShim(root, { + configured: resolveRstackPackageJson(root, configured), + }), + ).toBeDefined(); + expect(status.hasFailed(root)).toBe(false); + expect(reported.map((state) => state.kind)).toEqual(['crashed', 'running']); + }); + it('refuses an rstack install that ships no Rstest shim', () => { const configDir = createWorkspace({ shim: false });