Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

- **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. No settings migration exists either — not for earlier states of this extension, and not for the two retired standalone extensions (removed in #15; users re-enter their settings under `rstack.*`). Testing and fixtures track only the latest published releases, pinned exactly and bumped by Renovate; a green E2E run speaks only for those releases. `SUPPORT_MATRIX` floors are the minimum versions the extension accepts: each entry is the lowest release evidence shows works with the current code, and its comment records that evidence. Move a floor only when a change makes older releases stop working, never because a devDependency or fixture moved. Raising a floor needs no transition story; the status names the required version.
- **The three tools are treated uniformly by default.** Detection, dependency-change retry, restart semantics, version gating and status reporting follow one shared pattern across the lint/test/fmt stacks; a stack diverges only when its tool forces it, and the divergence is recorded here as a gotcha. When adding behavior to one stack, first ask whether it belongs to all three. This is about behavior, not code — the upstream copies still must not be deduplicated.
- **Not installed is a state, not an error — uniformly.** A folder or project whose dependencies are not installed (no `rstack`, no `@rstest/core`, no `@rslint/core`, a config importing a package that is not there) is the normal state of a fresh clone and of scaffolded templates beside their generator (`create-rstack`'s `template-*`, which declare their own dependencies and are never installed). Every stack reports it the same way: a `disabled` status whose reason keeps the restart command as an explicit fallback, one `warn` line per unresolved episode in the output channel without a stack trace, never a `crashed` status and never a notification. The shell owns one 60-second recursive poll while any controller's raw folder/project/runtime state is disabled, crashed or version-mismatched; it enters the existing serialized queue, forces the same detection notification as a lockfile event, and stops when no failed state remains (ADR 0005). A mid-install retry can read half-written `node_modules` and produce a real syntax error; continuing through failed states makes that transient harmless without a provisional-error heuristic. Real errors remain visible in status and Output, deduplicated by message rather than logged every minute. The words come from one place, `shared/notInstalled.ts` (the `formatVersionMismatch` precedent) — each stack keeps its own status machinery, none its own wording; the restart hint is derived from `stackCommandTitle`, which `tests/extension.test.ts` checks against the manifest. Rstest classifies config-import failures in its worker (`missingDependencyCauseOf`: Node's `code`, a bare package specifier, and for a subpath a walk-up proving the package really is absent) because IPC drops the `code`; Rslint makes the same code-gated decision where its worker still has structured loader results and sends a dedicated verdict to the editor; fmt intercepts only the exact `rs fmt cannot format this workspace:` Error notification and applies the shared message classifier. A typo'd relative import or a missing subpath of an installed package stays a real error in all three.
- **Not installed is a state, not an error — uniformly.** A folder or project whose dependencies are not installed (no `rstack`, no `@rstest/core`, no `@rslint/core`, a config importing a package that is not there) is the normal state of a fresh clone and of scaffolded templates beside their generator (`create-rstack`'s `template-*`, which declare their own dependencies and are never installed). Every stack reports it the same way: a `disabled` status whose reason keeps the restart command as an explicit fallback, one `warn` line per unresolved episode in the output channel without a stack trace, never a `crashed` status and never a notification. The shell owns one 60-second recursive poll while any controller's raw folder/project/runtime state is disabled, crashed or version-mismatched; it enters the existing serialized queue, forces the same detection notification as a lockfile event, and stops when no failed state remains (ADR 0005). A mid-install retry can read half-written `node_modules` and produce a real syntax error; continuing through failed states makes that transient harmless without a provisional-error heuristic. Real errors remain visible in status and Output, deduplicated by message rather than logged every minute. The words come from one place, `shared/notInstalled.ts` (the `formatVersionMismatch` precedent) — each stack keeps its own status machinery, none its own wording; the restart hint is derived from `stackCommandTitle`, which `tests/extension.test.ts` checks against the manifest. Rstest classifies config-import failures in its worker because IPC drops the `code`; Rslint makes the same code-gated decision where its worker still has structured loader results and sends a dedicated verdict to the editor; fmt intercepts only the exact `rs fmt cannot format this workspace:` Error notification and applies the shared message classifier. Any Node module-not-found on a bare specifier is the not-installed state, subpath included; the classifier never touches the filesystem (`shared/missingDependency.ts`, #52). Only relative, absolute and `file:` specifiers stay real errors.
- One stack failing to register or crashing must never take another stack (or the shell) down.
- The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. The per-stack enable settings are coarse kill switches only.
- Reconciles and restarts share one serialized queue (`enqueue`); a reconcile leaves a live stack alone, so the restart path — the commands, and the full pass any relevant settings change triggers — is the only thing that rebuilds one. Do not add a second queue.
Expand Down
43 changes: 13 additions & 30 deletions packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,13 @@ function loadRequest(transactionId = 'tx-1'): LoadConfigsRequest {
}

suite('LSP config discovery transactions', () => {
const rejectsConfigDependencies: ConstructorParameters<
typeof LspConfigTransactionAdapter
>[4] = {
report: () => assert.fail('unexpected missing dependency'),
reportError: () => assert.fail('unexpected config error'),
};

test('the extension watcher leaves gitignore ownership to Go', () => {
assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.js/);
assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.mjs/);
Expand Down Expand Up @@ -282,11 +289,7 @@ suite('LSP config discovery transactions', () => {
pool,
() => 'fingerprint-1',
CONFIG_DISCOVERY_PROTOCOL_VERSION,
{
resolveFrom: (candidate) => candidate.configDirectory,
report: () => assert.fail('unexpected missing dependency'),
reportError: () => assert.fail('unexpected config error'),
},
rejectsConfigDependencies,
);

const loaded = await adapter.loadConfigs(loadRequest());
Expand Down Expand Up @@ -332,11 +335,7 @@ suite('LSP config discovery transactions', () => {
pool,
() => 'fingerprint-1',
CONFIG_DISCOVERY_PROTOCOL_VERSION,
{
resolveFrom: (candidate) => candidate.configDirectory,
report: () => assert.fail('unexpected missing dependency'),
reportError: () => assert.fail('unexpected config error'),
},
rejectsConfigDependencies,
);

await adapter.loadConfigs(loadRequest('tx-abort'));
Expand Down Expand Up @@ -399,11 +398,7 @@ suite('LSP config discovery transactions', () => {
pool,
() => 'fingerprint-degraded',
CONFIG_DISCOVERY_PROTOCOL_VERSION,
{
resolveFrom: (candidate) => candidate.configDirectory,
report: () => assert.fail('unexpected missing dependency'),
reportError: () => assert.fail('unexpected config error'),
},
rejectsConfigDependencies,
);

await adapter.loadConfigs(loadRequest('tx-degraded'));
Expand Down Expand Up @@ -439,11 +434,7 @@ suite('LSP config discovery transactions', () => {
pool,
() => 'fingerprint-before-prepare',
CONFIG_DISCOVERY_PROTOCOL_VERSION,
{
resolveFrom: (candidate) => candidate.configDirectory,
report: () => assert.fail('unexpected missing dependency'),
reportError: () => assert.fail('unexpected config error'),
},
rejectsConfigDependencies,
);

await adapter.loadConfigs(loadRequest('tx-prepare-race'));
Expand All @@ -468,11 +459,7 @@ suite('LSP config discovery transactions', () => {
pool,
() => 'fingerprint-1',
CONFIG_DISCOVERY_PROTOCOL_VERSION,
{
resolveFrom: (candidate) => candidate.configDirectory,
report: () => assert.fail('unexpected missing dependency'),
reportError: () => assert.fail('unexpected config error'),
},
rejectsConfigDependencies,
);

await adapter.loadConfigs(loadRequest('tx-response-lost'));
Expand Down Expand Up @@ -510,11 +497,7 @@ suite('LSP config discovery transactions', () => {
new TestPluginPool(),
() => 'fingerprint-1',
CONFIG_DISCOVERY_PROTOCOL_VERSION,
{
resolveFrom: (candidate) => candidate.configDirectory,
report: () => assert.fail('unexpected missing dependency'),
reportError: () => assert.fail('unexpected config error'),
},
rejectsConfigDependencies,
);

await assert.rejects(adapter.loadConfigs(loadRequest()), /load failed/);
Expand Down
61 changes: 21 additions & 40 deletions packages/vscode/src/shared/missingDependency.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
import path from 'node:path';
import { findPackageJsonUncached } from './packageResolve';

export function isMissingDependencyCode(
function isMissingDependencyCode(
code: unknown,
): code is 'ERR_MODULE_NOT_FOUND' | 'MODULE_NOT_FOUND' {
return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
}

/**
* The classifier behind the "config imports a package that is not installed"
* verdict of the uniform not-installed policy (AGENTS.md). Nothing in it is
* Rstest-specific — it reads Node's loader errors — and lint/fmt will need
* the same verdict where their configs load (#30), which is why it lives in
* `shared/` beside the walk-up it uses rather than in one stack.
*
* Returns the one-line cause when a config evaluation failed on a package
* that is not installed, or `undefined` for a real error. Only a bare
* specifier — a package name, read from the message since CJS carries no
* structured one — counts, and anything unrecognized fails towards the full
* error report. Only the first line comes back: the rest of a CJS message is
* the require stack, and the not-installed state is one warn line without one.
* The "config import cannot be resolved" verdict of the not-installed policy
* (AGENTS.md); in `shared/` because lint/fmt need it too (#30). Returns the
* loader's first line for a bare specifier — the policy's one warn line, with
* the CJS require stack dropped — or `undefined` for a real error. A bare
* specifier failed in the dependency graph, where an install, a lockfile
* event or the poll can change the answer; a relative, absolute or `file:`
* specifier failed inside the user's own source, where nothing external will.
* #52 removed a filesystem walk-up: it could not see a pnpm-isolated private
* dependency.
*/
export function classifyMissingDependencyMessage(
message: string,
resolveFrom: string,
): string | undefined {
const [firstLine] = message.split('\n', 1);
const specifier = /^Cannot find (?:package|module) '([^']+)'/.exec(
Expand All @@ -37,35 +32,21 @@ export function classifyMissingDependencyMessage(
) {
return undefined;
}
// `installed-package/missing-subpath` wears the same bare shape, but the
// package itself is there — installing dependencies cannot fix it either,
// so a subpath is checked against the physical `node_modules` with the
// same uncached walk-up every stack resolves packages with.
const packageName = specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/')
: specifier.split('/', 1)[0];
if (
packageName !== specifier &&
findPackageJsonUncached(packageName, resolveFrom) !== undefined
) {
return undefined;
}
return firstLine;
}

/**
* Error-object entry point used where Node's loader code survives. The code is
* still required there: arbitrary user errors may contain loader-like prose.
* Worker/protocol boundaries that already carry a separately checked code use
* `classifyMissingDependencyMessage` directly because serialization can drop
* custom Error fields.
*/
export function missingDependencyCauseOf(
error: unknown,
resolveFrom: string,
/** Use for a (code, message) pair; bare messages (fmt) use the classifier directly. */
export function missingDependencyCause(
code: unknown,
message: string,
): string | undefined {
if (!isMissingDependencyCode(code)) return undefined;
return classifyMissingDependencyMessage(message);
}

/** Use when the caller holds an Error rather than a (code, message) pair. */
export function missingDependencyCauseOf(error: unknown): string | undefined {
if (!(error instanceof Error)) return undefined;
const { code } = error as NodeJS.ErrnoException;
if (!isMissingDependencyCode(code)) return undefined;
return classifyMissingDependencyMessage(error.message, resolveFrom);
return missingDependencyCause(code, error.message);
}
12 changes: 5 additions & 7 deletions packages/vscode/src/shared/notInstalled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,18 @@ export const formatNotInstalledStatus = (
`${packageName} is not installed (node_modules missing) — install it, ${restartHint(stack)}`;

/**
* The `disabled` reason for a config that evaluates but imports a package
* that is not there. `configPath` is workspace-relative: the status has no
* room for more.
* The `disabled` reason for a config with an import Node cannot resolve.
* `configPath` is workspace-relative: the status has no room for more.
*/
export const formatConfigDependencyMissingStatus = (
stack: StackId,
configPath: string,
): string =>
`${configPath} imports a package that is not installed — install the project dependencies, ${restartHint(stack)}`;
`${configPath} has an import Node cannot resolve — install the project dependencies, ${restartHint(stack)}`;

/**
* The output-channel line for a config that imports a package that is not
* installed. `cause` is the loader's own first line, which names the
* specifier and the importer.
* The output-channel line for the same verdict. `cause` is the loader's own
* first line, which names the specifier and the importer.
*/
export const formatConfigDependencyMissingLog = (
stack: StackId,
Expand Down
1 change: 0 additions & 1 deletion packages/vscode/src/stacks/fmt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,6 @@ class FmtFolderRuntime {
if (configPath !== undefined) {
const cause = classifyMissingDependencyMessage(
firstLine.replace(/^Error(?: \[[A-Z_]+\])?: /, ''),
this.folderPath,
);
if (cause !== undefined) {
this.#sessionError.clear();
Expand Down
19 changes: 5 additions & 14 deletions packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,15 @@ import type {
ActivateConfigsRequest,
ActivateConfigsResponse,
ConfigModuleActivationPlan,
ConfigModuleCandidate,
ConfigModuleEslintPluginEntry,
ConfigModulePluginDescriptor,
LoadConfigsRequest,
LoadConfigsResponse,
} from '@rslint/core/config-loader';
import {
classifyMissingDependencyMessage,
isMissingDependencyCode,
} from '../../../shared/missingDependency';
import { missingDependencyCause } from '../../../shared/missingDependency';
import type { ConfigDependencyFailure } from '../../../shared/notInstalled';

interface ConfigDependencyObserver {
resolveFrom(candidate: ConfigModuleCandidate): string;
report(failure: ConfigDependencyFailure): void;
reportError(message: string): void;
}
Expand Down Expand Up @@ -128,14 +123,10 @@ export class LspConfigTransactionAdapter {
results: response.results.map((result, index) => {
if (result.status !== 'failed') return result;
const candidate = request.candidates[index];
const cause =
candidate !== undefined &&
isMissingDependencyCode(result.error.code)
? classifyMissingDependencyMessage(
result.error.message,
this.configDependencyObserver.resolveFrom(candidate),
)
: undefined;
const cause = missingDependencyCause(
result.error.code,
result.error.message,
);
// Scan every failure: a later real error must not be hidden by the
// first missing dependency, even though only that result is rewritten.
if (cause === undefined || candidate === undefined) {
Expand Down
4 changes: 0 additions & 4 deletions packages/vscode/src/stacks/lint/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,6 @@ export async function runLintWorker(
(activation) => fingerprinter.compute(activation),
installation.protocolVersion,
{
resolveFrom: (candidate) =>
candidate.configPath === options.configPath
? process.cwd()
: candidate.configDirectory,
report: (failure) => {
configDependencyFailure ??= failure;
},
Expand Down
5 changes: 3 additions & 2 deletions packages/vscode/src/stacks/test/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,8 +691,9 @@ export class Project implements vscode.Disposable {
return `config-deps:${this.sourceUri.toString()}`;
}

// The config imports a package that is not installed: the not-installed
// state (AGENTS.md), one step past a missing `@rstest/core` — some install
// The config imports a package Node cannot resolve — a missing package
// or a missing subpath of an installed one: the not-installed state
// (AGENTS.md), one step past a missing `@rstest/core` — some install
// *above* the project satisfied the shim, so the config itself is what
// failed. A scaffolded template beside its generator is the usual shape.
// Latched under this project's key, which `dispose` forgets.
Expand Down
6 changes: 3 additions & 3 deletions packages/vscode/src/stacks/test/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ export type WorkerInitOptions = RstestConfig & {
};

/**
* What the worker answers `getNormalizedConfig` with. A config that fails to
* evaluate because a dependency is not installed is a result, not a rejection:
* the IPC channel would strip the error's `code` (see
* What the worker answers `getNormalizedConfig` with. A config that fails
* to evaluate because Node cannot resolve one of its imports is a result,
* not a rejection: the IPC channel would strip the error's `code` (see
* `missingDependencyCauseOf`), so the worker classifies it and reports the
* loader's own first line as data.
*/
Expand Down
4 changes: 1 addition & 3 deletions packages/vscode/src/stacks/test/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,7 @@ export class Worker {
// Classified here and not in the master: `code` does not survive the
// IPC round-trip. Only this unprompted, per-config evaluation gets the
// treatment — a run or list the user asked for reports its failure.
// The worker's spawn cwd is the project root (adaptation #5), which is
// where the config's dependencies are installed.
const cause = missingDependencyCauseOf(error, process.cwd());
const cause = missingDependencyCauseOf(error);
if (cause !== undefined) {
return { ok: false, message: cause };
}
Expand Down
Loading