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
1 change: 1 addition & 0 deletions packages/vscode/.vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
**
!dist/**/*.js
!dist/**/*.node
!dist/*.licenses.json
!icon.png
!LICENSE
!README.md
Expand Down
6 changes: 3 additions & 3 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

- `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 ten ways (the "adaptations" below). When syncing upstream, preserve them. An eleventh 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`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. `stacks/test` tracks web-infra-dev/rstest `packages/vscode` through **8f945491** (#1729 — public programmatic API), **d2812754** (#1804 — quoted exact file filters), **99be33e8** (#1805 — public test listing), **88cd5f6d** (#1806 — browser projects in watch), and **994b77e0** (#1807 — public watch API). A targeted port of web-infra-dev/rstest#1888 (not yet merged upstream at port time) switches the worker IPC from `serialization: 'advanced'` to default JSON — VS Code 1.139's Electron writes a V8 serializer format released Node cannot read — so values crossing the channel must survive a JSON round-trip, and thrown errors cross only because both `createBirpc` calls spread `rpcErrorCodec` (`stacks/test/shared/rpc.ts`). The earlier targeted lifecycle fix from **d82db4fc31a61ee74b2a74917f14a458e1bca419** is subsumed by this sync; our failed-project retry and worker-cleanup behavior remains ahead of upstream.
- **Tracked upstream state.** `stacks/lint` tracks web-infra-dev/rslint `packages/vscode-extension` at **e5d76242** (0.9.3); `stacks/test` tracks web-infra-dev/rstest `packages/vscode` at **41daaa1b** (0.12.2). Additional ports: Rslint **84f9c9b5** trace wording and **72cd2b1f** fixAll waits; Rstest **988f8e1d** per-bundle license notices, with explicit native-binding inclusion and no network license-text replenishment. We replace upstream's plugin-host failure toast with status (adaptations 4/7). Diff `CoreResolver.ts`, `RuntimeManager.ts`, `WorkspaceDocumentRouter.ts`, and `Rslint.ts` on future syncs. The rstest#1888 IPC port uses default JSON instead of advanced serialization: values must survive a JSON round-trip, and both `createBirpc` calls spread `rpcErrorCodec` (`stacks/test/shared/rpc.ts`) to preserve thrown errors. Failed-project retry and worker cleanup remain ahead of upstream.
- **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. (5) `RuntimeManager` retires a stopped client even when its resolved key is unchanged. The existing closing barrier and pending-use adoption share one replacement across documents; running and starting clients remain untouched (`tests/stacks/lint/runtimeManager.test.ts`).

## The ten 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).
3. **Resolve-from-project** — no tool binaries or tool packages in the VSIX; everything resolves from the user's project so the editor runs the CLI's exact versions. Version floors surface as a status, never a crash. All cooperating lint pieces (binary, config loader, plugin host) must come from one resolution root. Enforced by lint: `@typescript-eslint/no-restricted-imports` in the root `rstack.config.ts` rejects any non-type import of `@rslint/core`, `@rstest/core`, `rstack` or `jiti` under `src/` — types only at compile time, runtime modules through explicit project paths.
4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`.
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.
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 every supported config protocol 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`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager.
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 every supported config protocol 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`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. Plugin-host startup failures use a separate, unclassified `plugin` verdict on `rstack/rslintConfigDependency`, report `disabled` (a plugin could not be loaded), and recover through the dependency poll.
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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
crash
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import fs from 'node:fs';
import { isMainThread } from 'node:worker_threads';

// Config discovery must succeed; only the plugin-host worker import fails.
// A non-config marker keeps a file-watch refresh or restart from recovering
// the state early; the dependency poll retries the generation.
if (!isMainThread) {
const state = fs.readFileSync(new URL('./host-state.txt', import.meta.url), 'utf8').trim();
if (state === 'crash') throw new Error('fixture plugin import exploded');
}

export default {
rules: {
report: {
meta: { type: 'problem', schema: [], messages: { failure: 'Local plugin is running.' } },
create(context) {
return { Program(node) { context.report({ node, messageId: 'failure' }); } };
},
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import local from './local-plugin.mjs';

export default [{
files: ['**/*.ts'],
plugins: { local },
rules: { 'local/report': 'error', 'no-console': 'error' },
}];
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
console.log('native rule stays available');
export {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"compilerOptions": { "target": "esnext", "module": "esnext", "moduleResolution": "bundler", "noEmit": true },
"include": ["src"]
}
5 changes: 5 additions & 0 deletions packages/vscode/e2e/lint/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ async function main(): Promise<void> {
workspace: fixture('eslint-plugins'),
tests: suiteDir('suite-eslint-plugins'),
},
{
name: 'Plugin-host failure tests',
workspace: fixture('plugin-host-failure'),
tests: suiteDir('suite-plugin-host-failure'),
},
{
name: 'unicode-bom tests',
workspace: fixture('unicode-bom'),
Expand Down
3 changes: 3 additions & 0 deletions packages/vscode/e2e/lint/suite-plugin-host-failure/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createRun } from '../runSuite';

export const run = createRun();
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import * as vscode from 'vscode';
import type { StackState } from '../../../src/types';
import { extensionExports } from '../utils/extension';
import {
diagnosticRuleIdIncludes,
waitForRslintDiagnostics,
} from '../utils/diagnostics';

suite('Plugin-host status and polling recovery', function () {
this.timeout(120_000);

test('disables failed plugins while native diagnostics remain and recovers without restart', async () => {
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
assert.ok(root);
const api = extensionExports();
const lint = api.getStackExports('rslint') as {
getFolderStates(): ReadonlyMap<string, StackState>;
};
assert.ok(lint);
const waitForState = async (kind: StackState['kind']) => {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const state = [...lint.getFolderStates().values()].find(
(state) => state.kind === kind,
);
if (state) return state;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(
`Expected ${kind}; got ${JSON.stringify([...lint.getFolderStates().values()])}`,
);
};
const document = await vscode.workspace.openTextDocument(
path.join(root, 'src/index.ts'),
);
await vscode.window.showTextDocument(document);
const disabled = await waitForState('disabled');
assert.ok(disabled.kind === 'disabled');
assert.ok(disabled.reason);
assert.match(
disabled.reason,
/^ESLint plugins failed to load: .*fixture plugin import exploded/,
);
assert.ok(!disabled.reason.includes('\n'));
const warnings = api.getRecordedWarnings('rslint');
assert.equal(warnings.length, 1);
assert.match(
warnings[0],
/^\[[^\]]+\] ESLint plugins failed to load: .*fixture plugin import exploded$/,
);
await waitForRslintDiagnostics(document, (diagnostics) =>
diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-console')),
);

api.setDependencyPollIntervalForTest(250);
try {
fs.writeFileSync(path.join(root, 'host-state.txt'), 'ready');
await waitForState('running');
await waitForRslintDiagnostics(document, (diagnostics) =>
['local/report', 'no-console'].every((rule) =>
diagnostics.some((d) => diagnosticRuleIdIncludes(d, rule)),
),
);
} finally {
api.setDependencyPollIntervalForTest(60_000);
}
});
});
10 changes: 3 additions & 7 deletions packages/vscode/e2e/lint/suite/fixall-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// Ported from web-infra-dev/rslint (deviation: setup waits go through
// waitForDiagnosticsWithRuleIds -- see fixall-helpers.ts for why).
// Ported from web-infra-dev/rslint (typed setup waits from 72cd2b1f).
// `packages/vscode-extension/__tests__/suite/fixall-error.test.ts` (origin/main).
import * as assert from 'assert';
import * as vscode from 'vscode';
import {
waitForDiagnosticsWithRuleIds,
waitForTypeAssertionDiagnostics,
waitForContentChange,
diagnosticRuleIdIncludes,
findFixAllAction,
Expand Down Expand Up @@ -59,10 +58,7 @@ suite('rslint fixAll - error flows', function () {
editor,
"const pVal: string = 'x';\nconst pRes = (pVal as string).trim();\n",
);
const probeDiags = await waitForDiagnosticsWithRuleIds(
doc,
'no-unnecessary-type-assertion',
);
const probeDiags = await waitForTypeAssertionDiagnostics(doc);
assert.ok(
probeDiags.some((d) =>
diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion'),
Expand Down
20 changes: 13 additions & 7 deletions packages/vscode/e2e/lint/suite/fixall-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,10 @@ export const waitForDiagnostics = waitForRslintDiagnostics;
* Wait until the rslint diagnostics for `doc` include every given rule-id
* fragment.
*
* Deviation from the upstream suites, which assert on the first non-empty
* publish: since @rslint/core 0.8.1 (web-infra-dev/rslint#1790), a file
* created after its project was loaded is served by a type-info-less fallback
* Program until a watcher event admits it into the configured project, so the
* first non-empty publish may carry only non-type-aware rules on platforms
* with slow file watchers (macOS). Waiting for the expected diagnostics keeps
* the terminal assertion identical without depending on publish batching.
* Upstream also waits for the typed rule (72cd2b1f), since the first publish
* may contain only non-type-aware rules. Our only deviation is matching
* diagnostic.code, where our enrichment puts rule ids, instead of upstream's
* message prefix.
*/
export function waitForDiagnosticsWithRuleIds(
doc: vscode.TextDocument,
Expand All @@ -49,6 +46,15 @@ export function waitForDiagnosticsWithRuleIds(
export const waitForDiagnosticsCount = waitForRslintDiagnosticsCount;
export const waitForDiagnosticsToChange = waitForRslintDiagnosticsToChange;

export function waitForTypeAssertionDiagnostics(
document: vscode.TextDocument,
): Promise<vscode.Diagnostic[]> {
return waitForDiagnosticsWithRuleIds(
document,
'no-unnecessary-type-assertion',
);
}

export function getFixturesDir(): string {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder)
Expand Down
Loading
Loading