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
5 changes: 3 additions & 2 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
## The copies are intentional

- `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase.
- The copies diverge from upstream in exactly eight ways (the "adaptations" below). When syncing upstream, preserve them. A ninth divergence is either a bug or must be added to this list.
- The copies diverge from upstream in exactly nine ways (the "adaptations" below). When syncing upstream, preserve them. A tenth divergence is either a bug or must be added to this list.
- **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do.

## The eight adaptations
## The nine 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).
Expand All @@ -18,6 +18,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
6. **Node runtime selection** (lint, test, fmt) — the Node a project-loading child process runs on is a **User Node runtime** chosen by the extension against one uniform floor, never assumed from PATH; the recovery path is the user's own shell, and the dividing line is the **load bound** (terms in CONTEXT.md; the full rule and rationale in `docs/adr/0001-node-runtime-selection.md`). All three callers — the lint worker, the rstest worker and the `rs fmt --lsp` server — take the decision from the one shared module (`shared/nodeResolution.ts`) and share one escape hatch, the resource-scoped `rstack.nodeExecutable` (`shared/nodeExecutableSetting.ts`); each appends its own consequence to the shared preflight message.
7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — CONTEXT.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged folder passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because protocol 2 locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`.
8. **Self-documenting Rslint diagnostics** — client-side providers parse Inline directives into per-rule hover, DocumentLink and underline-decoration affordances (the hover renders `Rslint(rule-id)`, the shape VS Code gives the published diagnostics), and the router enriches today's `[rule-id] message` diagnostics with a derived Rule docs link. No rule metadata or network lookup is bundled (ADR 0004). The hover provider yields whenever the owning language client's resolved capabilities advertise `hoverProvider`; an optional `Rslint.onClosed` hook identity-safely prunes the controller's capability mirror; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively.
9. **Color env parity with the CLI** (test) — upstream hard-codes `FORCE_COLOR: '1'` into the worker's spawn env; ours mirrors the CLI's `getForceColorEnv` (rstest `packages/core/src/utils/logger.ts`) instead (`stacks/test/shared/colorEnv.ts`): the master injects `FORCE_COLOR=1` into the composed spawn env only when neither `FORCE_COLOR` nor `NO_COLOR` is already set (marking the injection with `RSTACK_FORCE_COLOR_INJECTED`), and the worker retracts the marked injection right after config load if the config set `NO_COLOR` — the CLI's own decision point. Otherwise a project whose config sets `process.env.NO_COLOR` (rstack-cli does) hits Node's "'NO_COLOR' env is ignored" warning in every pool process. A user-set `FORCE_COLOR` beside a config-set `NO_COLOR` still warns, exactly as the bare CLI does.

## Rules

Expand Down
60 changes: 60 additions & 0 deletions packages/vscode/src/shared/missingDependency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import path from 'node:path';
import { findPackageJsonUncached } from './packageResolve';

/**
* 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. Gated on the
* error's `code` — Node's own classification (`ERR_MODULE_NOT_FOUND` for
* ESM, `MODULE_NOT_FOUND` for CJS) — but the code alone is too broad: a
* typo'd relative import fails with the same codes, and installing
* dependencies cannot fix it, so 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. The check has
* to run in the process where the error is thrown: an IPC channel back to
* the extension host (`serialization: 'advanced'`) drops the `code`, so the
* verdict travels as data (e.g. `NormalizedConfigResult`). 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.
*/
export function missingDependencyCauseOf(
error: unknown,
resolveFrom: string,
): string | undefined {
if (!(error instanceof Error)) return undefined;
const { code } = error as NodeJS.ErrnoException;
if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') {
return undefined;
}
const [firstLine] = error.message.split('\n', 1);
const specifier = /^Cannot find (?:package|module) '([^']+)'/.exec(
firstLine,
)?.[1];
if (
specifier === undefined ||
specifier.startsWith('.') ||
specifier.startsWith('file:') ||
path.isAbsolute(specifier)
) {
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;
}
2 changes: 1 addition & 1 deletion packages/vscode/src/shared/notInstalled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
* where it has to be named (ADR 0002).
*/
const restartHint = (stack: StackId): string =>
`then run "${COMMAND_CATEGORY}: ${stackCommandTitle(stack, 'restart')}" if this status stays`;
`then run "${COMMAND_CATEGORY}: ${stackCommandTitle(stack)}" if this status stays`;

/** The `disabled` reason for a package the stack needs and cannot find. */
export const formatNotInstalledStatus = (
Expand Down
16 changes: 14 additions & 2 deletions packages/vscode/src/stacks/lint/RuntimeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ function documentKey(document: TextDocument): string {
return document.uri.toString();
}

/**
* The one wording for a document whose core selection failed, shared with
* the `onDocumentFailure` hook (`index.ts`), which owns the report but must
* not drift from the hook-less fallback below.
*/
export function formatCoreSelectionFailure(
documentUri: string,
keeping?: string,
): string {
const suffix = keeping ? ` (keeping ${keeping} active)` : '';
return `Could not select an Rslint core for ${documentUri}${suffix}`;
}

function cancellationError(key: string): Error {
const error = new Error(`Rslint runtime ${JSON.stringify(key)} was released`);
error.name = 'AbortError';
Expand Down Expand Up @@ -418,9 +431,8 @@ export class RuntimeManager {
});
return;
}
const suffix = keeping ? ` (keeping ${keeping} active)` : '';
this.logger.error(
`Could not select an Rslint core for ${document.uri}${suffix}`,
formatCoreSelectionFailure(document.uri.toString(), keeping),
error,
);
}
Expand Down
5 changes: 2 additions & 3 deletions packages/vscode/src/stacks/lint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Logger } from './logger';
import { Rslint } from './Rslint';
import type { RslintMode } from './resolution';
import { registerRuleDocumentationProviders } from './ruleDocumentationProviders';
import { RuntimeManager } from './RuntimeManager';
import { formatCoreSelectionFailure, RuntimeManager } from './RuntimeManager';
import {
aggregateFolderStates,
attributeToCore,
Expand Down Expand Up @@ -223,9 +223,8 @@ class RslintController implements StackController {
),
);
} else {
const suffix = keeping ? ` (keeping ${keeping} active)` : '';
logger.error(
`Could not select an Rslint core for ${document.uri}${suffix}`,
formatCoreSelectionFailure(document.uri.toString(), keeping),
error,
);
}
Expand Down
15 changes: 13 additions & 2 deletions packages/vscode/src/stacks/lint/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,23 @@ export type RslintResolutionErrorCode =
'missing-rstack' | 'missing-core' | 'invalid-package' | 'missing-shim';

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
* `invalid-package` leave it unset on purpose: there the package is
* present and the fix is an upgrade or the setting, not an install.
*/
readonly missingPackage?: 'rstack' | '@rslint/core';

constructor(
readonly code: RslintResolutionErrorCode,
message: string,
options?: { cause?: unknown },
options?: { cause?: unknown; missingPackage?: 'rstack' | '@rslint/core' },
) {
super(message, options);
super(message, { cause: options?.cause });
this.name = 'RslintResolutionError';
this.missingPackage = options?.missingPackage;
}
}

Expand Down Expand Up @@ -101,6 +111,7 @@ function resolveInstalledPackage(
throw new RslintResolutionError(
code,
`Could not resolve ${packageName} from ${searchRoot}`,
{ missingPackage: packageName },
);
}
return readPackageLocation(packageName, packageJsonPath);
Expand Down
27 changes: 6 additions & 21 deletions packages/vscode/src/stacks/lint/status.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { StackState } from '../../types';
import { formatNotInstalledStatus } from '../../shared/notInstalled';
import type { SupportedPackage } from '../../shared/versionCheck';
import {
RslintResolutionError,
type RslintResolutionErrorCode,
} from './resolution';
import { RslintResolutionError } from './resolution';

export class RslintVersionMismatchError extends Error {
constructor(message: string) {
Expand All @@ -16,27 +13,15 @@ export class RslintVersionMismatchError extends Error {
/**
* The package whose absence makes this failure the not-installed state the
* three stacks report uniformly (AGENTS.md) — a `disabled` status and a
* one-line warning, never a crash or a stack trace. `missing-rstack` is a
* bridged folder without `rstack`; `missing-core` is a native folder on a
* fresh clone (a bridged folder reaches it only when rstack's own dependency
* is gone — an interrupted install, where installing is still the fix).
* `missing-shim` stays an error: the package is there, its version is wrong.
* A misconfigured `corePath` is `invalid-package`, also an error: the fix is
* the setting, not an install.
* one-line warning, never a crash or a stack trace. The verdict is the throw
* site's, carried on the error (`RslintResolutionError.missingPackage`), so
* a new resolution failure cannot silently fall through to `crashed` here by
* missing a mapping.
*/
const NOT_INSTALLED_PACKAGE: Partial<
Record<RslintResolutionErrorCode, SupportedPackage>
> = {
'missing-rstack': 'rstack',
'missing-core': '@rslint/core',
};

export const missingPackageOf = (
error: unknown,
): SupportedPackage | undefined =>
error instanceof RslintResolutionError
? NOT_INSTALLED_PACKAGE[error.code]
: undefined;
error instanceof RslintResolutionError ? error.missingPackage : undefined;

export const statusForRslintStartFailure = (error: unknown): StackState => {
if (error instanceof RslintVersionMismatchError) {
Expand Down
20 changes: 12 additions & 8 deletions packages/vscode/src/stacks/test/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ import { status } from './status';
/** Relative to the `rstack` package root. Same file `rs test` injects. */
const SHIM_RELATIVE_PATH = path.join('dist', 'rstestConfig.js');

// Both arguments are literals (`CORE_NOT_INSTALLED_STATUS` in master.ts is
// the same shape), so the status is one string instead of one per refresh
// pass.
const RSTACK_NOT_INSTALLED_STATUS = formatNotInstalledStatus(
'rstest',
'rstack',
);

export type RstackShim = {
/** Absolute path of `<rstack>/dist/rstestConfig.js`. */
readonly configFilePath: string;
Expand Down Expand Up @@ -89,10 +97,7 @@ export function resolveRstackShim(
// status stays until this directory resolves or stops being a candidate.
// A stale mismatch from a previously-present install is retired by the
// latch itself (a package-state observation restates its root).
status.notInstalled(
formatNotInstalledStatus('rstest', 'rstack'),
configDir,
);
status.notInstalled(RSTACK_NOT_INSTALLED_STATUS, configDir);
return undefined;
}

Expand Down Expand Up @@ -130,10 +135,9 @@ export function resolveRstackShim(
packageDirectory,
version,
});
// At most one of the two latches can be live (a package-state observation
// restates its root), so this repaints once, whichever failure the
// previous pass observed.
// 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);
status.installed(configDir);
return { configFilePath, packageDirectory, version };
}
84 changes: 20 additions & 64 deletions packages/vscode/src/stacks/test/coreResolution.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,40 @@
/**
* Classifying and reporting failed Rstest resolutions — the host-side helpers
* for a `@rstest/core` that cannot be resolved, and the worker-side
* classifier for a config whose own import failed
* (`missingDependencyCauseOf`).
* Classifying and reporting failed Rstest worker setups — the host-side
* helpers for a `@rstest/core` that cannot be resolved, and the
* already-reported marker for any setup failure whose actionable state was
* logged where it was observed. (The worker-side classifier for a config
* whose own import failed is `shared/missingDependency.ts`.)
*
* Every message here replaces Node's own `MODULE_NOT_FOUND` text, which
* embeds a multi-line require stack and says nothing about what to do; the
* classifier also touches the filesystem once, to check whether a failing
* subpath's package really is absent. An
* embeds a multi-line require stack and says nothing about what to do. An
* uninstalled core is the normal state of a freshly cloned repository and is
* resolved for every config file without the user asking, so it is only
* logged; a `rstestPackagePath` that does not resolve is a setting the user
* has to fix, so it is notified.
*/

import path from 'node:path';
import { findPackageJsonUncached } from '../../shared/packageResolve';
import { logger } from './logger';

/**
* Resolution failed after the actionable error was already logged or shown.
* Callers still reject so project initialization stops, but must not report the
* same failure again.
* The worker could not be set up, and the actionable state was already logged
* or shown — a core that did not resolve, or a spawn refused because the
* project directory is gone. Callers still reject so the operation stops, but
* must not report the same failure again: catch sites log through
* `logUnlessReported` below instead of re-deciding.
*/
export class ReportedRstestResolutionError extends Error {
constructor() {
super('Failed to resolve rstest path');
constructor(message = 'Failed to resolve rstest path') {
super(message);
this.name = 'ReportedRstestResolutionError';
}
}

/** The catch-site half of the contract above. */
export function logUnlessReported(message: string, error: unknown): void {
if (error instanceof ReportedRstestResolutionError) return;
logger.error(message, error);
}

// Whether `specifier` itself is what could not be found. `MODULE_NOT_FOUND`
// alone is too broad: a package that is installed but whose entry file is gone
// (an interrupted install, or a workspace link that has not been built) throws
Expand All @@ -52,53 +58,3 @@ export function formatConfiguredCoreNotFoundMessage(
): string {
return `Cannot find "@rstest/core" at the configured "rstack.rstest.rstestPackagePath": ${configuredPackagePath}. Update the setting to point at an installed "@rstest/core" package.json.`;
}

// The one-line cause when a config evaluation failed on a package that is
// not installed, or `undefined` for a real error. Gated on the error's
// `code` — Node's own classification (`ERR_MODULE_NOT_FOUND` for ESM,
// `MODULE_NOT_FOUND` for CJS) — but the code alone is too broad: a typo'd
// relative import fails with the same codes, and installing dependencies
// cannot fix it, so 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. The check has to run in
// the worker, where the error is thrown: the IPC channel back to the
// extension host (`serialization: 'advanced'`) drops the `code`, so the
// verdict travels as data (`NormalizedConfigResult`). 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.
export function missingDependencyCauseOf(
error: unknown,
resolveFrom: string,
): string | undefined {
if (!(error instanceof Error)) return undefined;
const { code } = error as NodeJS.ErrnoException;
if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') {
return undefined;
}
const [firstLine] = error.message.split('\n', 1);
const specifier = /^Cannot find (?:package|module) '([^']+)'/.exec(
firstLine,
)?.[1];
if (
specifier === undefined ||
specifier.startsWith('.') ||
specifier.startsWith('file:') ||
path.isAbsolute(specifier)
) {
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;
}
Loading