diff --git a/.gitignore b/.gitignore index ec4cea7..2c7bbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,9 @@ vite.config.ts.timestamp-* tests-dist/ .rsdoctor/ +# F5 playground's materialized Node (see .vscode/tasks.json "playground node") +packages/vscode/.playground/ + # E2E fixtures install published npm versions on demand; only # their manifests and configs are tracked. packages/vscode/e2e/fixtures/*/node_modules/ diff --git a/.vscode/launch.json b/.vscode/launch.json index c656c1b..5ef38c0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -33,8 +33,20 @@ "--disable-updates", "${workspaceFolder}/packages/vscode/${input:playgroundTarget}" ], + // The "playground node" task materialized a floor-satisfying Node here; + // prepending it makes the extension's PATH probe find it, regardless of + // what node the desktop session carries (GUI launches never run the + // shell hooks that honor .nvmrc). + "env": { + "PATH": "${workspaceFolder}/packages/vscode/.playground/node-bin:${env:PATH}" + }, + "windows": { + "env": { + "PATH": "${workspaceFolder}\\packages\\vscode\\.playground\\node-bin;${env:PATH}" + } + }, "outFiles": ["${workspaceFolder}/packages/vscode/dist/**/*.js"], - "preLaunchTask": "extension watch", + "preLaunchTask": "playground", // Off by default: attaching to every spawned worker slows runs down and // child-process sourcemaps are unreliable (same setting upstream). "autoAttachChildProcesses": false diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c348576..bd52347 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,32 @@ { "version": "2.0.0", "tasks": [ + { + // Materializes a floor-satisfying Node into .playground/node-bin/ so + // launch.json can prepend it to the dev host's PATH. A GUI-launched + // VS Code skips the contributor's shell hooks (fnm/nvm on .nvmrc), so + // without this the dev host sees the desktop session's node — often + // below the extension's runtime floor, and Rslint reports + // "version mismatch" instead of linting. + "label": "playground node", + "type": "shell", + "command": "node", + "args": ["packages/vscode/scripts/playgroundNode.mjs"], + "group": "build", + "problemMatcher": [], + "presentation": { + "reveal": "silent", + "panel": "shared" + } + }, + { + // What F5 depends on: materialize the playground Node, then start the + // watch build. Kept separate from "extension watch" so a plain rebuild + // from the task palette does not run the playground helper. + "label": "playground", + "dependsOrder": "sequence", + "dependsOn": ["playground node", "extension watch"] + }, { // Background watch build the F5 launch depends on. `--env-mode dev` // (`watch:local`) so breakpoints in src/ bind inside the dev host. diff --git a/CONTEXT.md b/CONTEXT.md index 5a49c25..8d7480b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -34,6 +34,8 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **Lint worker** — the process the extension ships and runs for one lint server: it hosts Rslint's JS side (config evaluation, plugin rules) on a User Node runtime with its cwd at the workspace folder root, and fronts the Go `rslint --lsp` process it spawns, so the editor sees one language server. _Avoid_: lint host, lint proxy, lint server (that is what the worker presents, not what it is). - **Bridged folder** — a workspace folder whose lint runs against the Rstack config: no native `rslint.config.*` anywhere in the folder, a `rstack.config.*` at its root, and the lint worker pinned to rstack's shipped shim for its whole lifetime. _Avoid_: bridged workspace, rstack folder. - **Native folder** — a workspace folder whose lint runs against its own `rslint.config.*`, exactly as the standalone Rslint extension would. +- **Inline directive** — a source comment that toggles lint rules for a scope: `rslint-disable`, `rslint-enable`, `rslint-disable-line`, `rslint-disable-next-line`, with the `eslint-` prefix accepted as an exact equivalent. Rule ids are comma-separated; a bare directive applies to all rules. _Avoid_: disable comment, suppression comment. +- **Rule docs link** — the documentation URL derived from a rule id alone (one base URL plus the id, no per-rule data). Best-effort by design: a mistyped or brand-new rule id yields a dead link, never an error. _Avoid_: rule doc URL, docs href. ## fmt diff --git a/docs/adr/0004-no-bundled-rule-knowledge.md b/docs/adr/0004-no-bundled-rule-knowledge.md new file mode 100644 index 0000000..b530a4c --- /dev/null +++ b/docs/adr/0004-no-bundled-rule-knowledge.md @@ -0,0 +1,22 @@ +--- +status: accepted +--- + +# Self-documenting diagnostics carry no bundled rule knowledge + +Issue #27 wants Rslint diagnostics to explain themselves in the editor: hover over rule ids in inline directives, clickable rule ids in the Problems panel, faded/struck-through rendering for dead-code rules. The Go server today gives the client almost nothing to build on: `textDocument/publishDiagnostics` sets only range, severity, source and a message of the form `[rule-id] description` — no `code`, no `codeDescription`, no `tags` — the server advertises no `hoverProvider`, exposes no rule-metadata request, and the rule type itself has no description field. The tempting fix is to bundle what the server won't say: a rule list (there are ~500), per-rule descriptions scraped from the docs site, a hand-maintained set of "unused-variable-like" rules for `DiagnosticTag.Unnecessary`. + +**Decision.** The extension carries **no per-rule data of any kind**. Everything it shows is either **derived from the rule id by one formula** — `https://rslint.rs/rules//`, the same formula as upstream's `getRuleDocUrl` — or **parsed from server output**: the lint middleware reads the `[rule-id] ` prefix off each published diagnostic's message, synthesizes `code` + `codeDescription.href`, and strips the prefix (if the message doesn't match, the diagnostic passes through untouched). No network requests either: links are best-effort, so a mistyped or brand-new rule id yields a dead docs link, not a validation round-trip. + +## Considered options + +- **Bundle rule metadata** (scrape `rslint.rs/llms.txt` or vendor the per-rule `.md` files at build time) — rejected: a standing sync pipeline whose failure mode is showing _stale_ descriptions, worse than showing none; the docs link already lands on the authoritative text. +- **Validate links over the network** (HEAD-check with cache, suppress hover on 404) — rejected: makes an editor affordance depend on connectivity; offline/intranet kills the feature. + +## Consequences + +- Hover and Problems-panel entries show the rule id and its docs link, **no prose description**, until upstream exposes rule metadata. +- A mistyped rule id in an inline directive stays **silent** — the extension cannot know it is unknown without a rule list. The user's signal is the squiggle the directive failed to suppress. Proper reporting (unused/mistyped directive diagnostics) is upstream work. +- `DiagnosticTag` rendering is **not attempted client-side** — only rules know whether they are dead-code-like, and encoding that in the extension is exactly the bundled knowledge this ADR forbids. +- The message-prefix synthesis is **transitional by design**: once the Go server publishes `code`/`codeDescription` natively, the middleware synthesis is deleted, not kept as a fallback. If upstream changes the message format first, the guard makes the feature degrade to the status quo silently. +- The client-side hover provider registers only while the server does not advertise `hoverProvider`; the day it does, the client yields (the fmt precedent: never fight a server-registered capability). diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index a41aa20..0225be1 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -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 seven ways (the "adaptations" below). When syncing upstream, preserve them. An eighth divergence is either a bug or must be added to this list. +- 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. - **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 seven adaptations +## The eight 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). @@ -17,6 +17,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 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 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. ## Rules diff --git a/packages/vscode/e2e/fixtures/rslint/rslint.config.mjs b/packages/vscode/e2e/fixtures/rslint/rslint.config.mjs index 46c488e..39ed36a 100644 --- a/packages/vscode/e2e/fixtures/rslint/rslint.config.mjs +++ b/packages/vscode/e2e/fixtures/rslint/rslint.config.mjs @@ -4,6 +4,12 @@ import localPlugin from './local-plugin.mjs'; * A real Rslint flat config: it is loaded by the language server through the * project's own `@rslint/core` (this extension ships none), and by the * plugin-host regression smoke test through `createPluginLintHost`. + * + * Besides the local plugin rule the smoke test asserts on, two native rules + * are enabled so the F5 playground shows diagnostics whose derived docs links + * resolve to real pages on rslint.rs (a local plugin rule has no docs page). + * A config entry takes either community plugin instances or built-in plugin + * names, never both, so the two live in separate entries. */ export default [ { @@ -13,4 +19,12 @@ export default [ 'local/no-null': 'error', }, }, + { + files: ['src/**/*.ts'], + plugins: ['@typescript-eslint'], + rules: { + 'no-console': 'error', + '@typescript-eslint/no-explicit-any': 'error', + }, + }, ]; diff --git a/packages/vscode/e2e/fixtures/rslint/src/index.ts b/packages/vscode/e2e/fixtures/rslint/src/index.ts index 9ae7c54..114dcbf 100644 --- a/packages/vscode/e2e/fixtures/rslint/src/index.ts +++ b/packages/vscode/e2e/fixtures/rslint/src/index.ts @@ -1,6 +1,61 @@ -// The lintable issue this fixture exists for: the `null` literal below is -// reported by `local/no-null` (see `rslint.config.mjs`). Exactly one `null` -// literal — the smoke test asserts on the diagnostic count. +// This fixture doubles as the F5 playground: open this file in the dev host +// and every lint capability of the extension is observable directly below. +// Constraint: the smoke test asserts on `local/no-null` — keep exactly one +// `null` literal in this file. + +// #region Diagnostics — clickable rule docs in the Problems panel +// Every rslint diagnostic in the Problems panel (Cmd+Shift+M) shows its rule +// id as a clickable link, and the message carries no `[rule-id]` prefix — the +// extension lifts the id into the diagnostic's code. + +// `local/no-null` is this fixture's own plugin rule (see local-plugin.mjs). +// Its derived docs link is a deliberate 404: a user-local rule has no page on +// rslint.rs. This is the diagnostic the smoke test asserts on. export function getValue() { return null; } + +// Native rules link to real pages: `no-console` → /rules/eslint/no-console, +// `@typescript-eslint/no-explicit-any` → /rules/typescript-eslint/no-explicit-any. +export function debugValue(value: any) { + console.log(value); +} +// #endregion + +// #region Inline directives — hover, underline, Ctrl+click +// Rule ids inside a disable comment are underlined. Hovering one shows +// `Rslint(rule-id)` with the id linking to its docs page; Ctrl+click +// (Cmd+click on macOS) opens the page directly. The directive keyword itself +// has no hover — only the rule ids do. + +// rslint-disable-next-line no-console +console.log('suppressed — hover the underlined rule id above'); + +// Comma-separated ids are each their own hover target; the ` -- ` trailer is +// free-form description and is not parsed. +// rslint-disable-next-line no-console, @typescript-eslint/no-explicit-any -- demo: two rule ids and a trailer +export const logAny = (value: any) => console.log(value); +// #endregion + +// #region Directive forms — disable-line, eslint- prefix, wildcard +// `rslint-disable-line` suppresses its own line, and works from a trailing +// comment too. +console.log('suppressed inline'); // rslint-disable-line no-console + +// The `eslint-` prefix is an exact equivalent of `rslint-`. +// eslint-disable-next-line no-console +console.log('suppressed via the eslint- prefix'); + +// A bare directive suppresses every rule; with no rule id there is nothing to +// hover. +// rslint-disable-next-line +console.log('suppressed by the wildcard directive'); +// #endregion + +// #region Pitfall — a mistyped rule id +// A mistyped id suppresses nothing: the squiggle below survives, which is the +// signal the directive missed. Its hover link still derives (and 404s) — the +// extension deliberately validates nothing against a rule list (ADR 0004). +// rslint-disable-next-line no-consle +console.log('NOT suppressed — the rule id above is mistyped'); +// #endregion diff --git a/packages/vscode/e2e/lint/fixtures/hover/rslint.config.mjs b/packages/vscode/e2e/lint/fixtures/hover/rslint.config.mjs new file mode 100644 index 0000000..61f01b8 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/hover/rslint.config.mjs @@ -0,0 +1,7 @@ +export default [ + { + rules: { + 'no-console': 'error', + }, + }, +]; diff --git a/packages/vscode/e2e/lint/fixtures/hover/src/index.ts b/packages/vscode/e2e/lint/fixtures/hover/src/index.ts new file mode 100644 index 0000000..c636566 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/hover/src/index.ts @@ -0,0 +1,8 @@ +// rslint-disable-next-line no-console +console.log('suppressed'); +console.log('reported'); + +export function getValue() { + // rslint-disable-next-line local/no-null + return null; +} diff --git a/packages/vscode/e2e/lint/fixtures/hover/tsconfig.json b/packages/vscode/e2e/lint/fixtures/hover/tsconfig.json new file mode 100644 index 0000000..65a0038 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/hover/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index 11a5a87..a600cf1 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -284,6 +284,11 @@ async function main(): Promise { workspace: fixture('rule-option-types'), tests: suiteDir('suite-rule-option-types'), }, + { + name: 'Self-documenting diagnostics tests', + workspace: fixture('hover'), + tests: suiteDir('suite-hover'), + }, { name: 'import/no-cycle tests', workspace: fixture('import-cycle'), diff --git a/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts b/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts index 64497dd..3936792 100644 --- a/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts +++ b/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import * as vscode from 'vscode'; import { findPackageJsonUncached } from '../../../src/shared/packageResolve'; import { + diagnosticRuleIdIncludes, getRslintDiagnostics, waitForRslintDiagnostics, waitForRslintDiagnosticsCount, @@ -51,7 +52,7 @@ async function openLintTarget(): Promise { function hasNoDebugger(diagnostics: readonly vscode.Diagnostic[]): boolean { return diagnostics.some((diagnostic) => - diagnostic.message.includes('no-debugger'), + diagnosticRuleIdIncludes(diagnostic, 'no-debugger'), ); } diff --git a/packages/vscode/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts b/packages/vscode/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts index 62138cf..b010c23 100644 --- a/packages/vscode/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts +++ b/packages/vscode/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts @@ -7,7 +7,10 @@ import path from 'node:path'; import { waitForContentChange } from '../suite/fixall-helpers'; import { saveDocumentOnce } from '../utils/codeActionRegistry'; import { withCodeActionsOnSave } from '../utils/configuration'; -import { waitForRslintDiagnostics } from '../utils/diagnostics'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics, +} from '../utils/diagnostics'; import { closeAndDeleteTemporaryDocument, temporaryFilePath, @@ -31,8 +34,6 @@ suite('rslint object-form plugins integration', function () { return workspaceFolder.uri.fsPath; } - // LSP diagnostic messages are formatted as `[] ` - // (see internal/lsp/service.go), so ruleName is matchable on `.message`. function messages(diags: vscode.Diagnostic[]): string { return diags.map((d) => d.message).join(' | '); } @@ -69,11 +70,13 @@ suite('rslint object-form plugins integration', function () { const diagnostics = await waitForRslintDiagnostics( openedDocument, (diags) => - diags.some((d) => d.message.includes('local/prefer-array-some')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'local/prefer-array-some'), + ), ); assert.ok( diagnostics.some((d) => - d.message.includes('local/prefer-array-some'), + diagnosticRuleIdIncludes(d, 'local/prefer-array-some'), ), `prefer-array-some did not appear; cannot exercise fixAll. Got: ${messages(diagnostics)}`, ); @@ -124,22 +127,24 @@ suite('rslint object-form plugins integration', function () { await vscode.window.showTextDocument(doc); const diagnostics = await waitForRslintDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('local/no-null')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'local/no-null')), ); const msgs = messages(diagnostics); // Both plugin rules must come back from the worker... assert.ok( - diagnostics.some((d) => d.message.includes('local/no-null')), + diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'local/no-null')), `Expected local/no-null. Got: ${msgs}`, ); assert.ok( - diagnostics.some((d) => d.message.includes('local/prefer-array-some')), + diagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'local/prefer-array-some'), + ), `Expected local/prefer-array-some. Got: ${msgs}`, ); // ...alongside the natively-linted rule, proving the merge. assert.ok( - diagnostics.some((d) => d.message.includes('no-console')), + diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-console')), `Expected native no-console merged with plugin diagnostics. Got: ${msgs}`, ); }); diff --git a/packages/vscode/e2e/lint/suite-hover/hover.test.ts b/packages/vscode/e2e/lint/suite-hover/hover.test.ts new file mode 100644 index 0000000..61348a0 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-hover/hover.test.ts @@ -0,0 +1,128 @@ +import * as assert from 'node:assert'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics, +} from '../utils/diagnostics'; +import { waitForLintStackRegistration } from '../utils/extension'; + +const RULE_ID = 'no-console'; +const RULE_DOCS_LINK = 'https://rslint.rs/rules/eslint/no-console'; + +suite('Rslint self-documenting diagnostics', function () { + this.timeout(90_000); + + async function openFixture(): Promise { + await waitForLintStackRegistration(true); + const workspaceRoot = vscode.workspace.workspaceFolders![0].uri.fsPath; + const document = await vscode.workspace.openTextDocument( + path.join(workspaceRoot, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + return document; + } + + /** Position just inside the first occurrence of `text` in the document. */ + function positionInside( + document: vscode.TextDocument, + text: string, + ): vscode.Position { + const offset = document.getText().indexOf(text); + assert.ok(offset >= 0, `The fixture must contain "${text}"`); + return document.positionAt(offset + 1); + } + + async function hoverTextAt( + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { + const hovers = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + document.uri, + position, + ); + return (hovers ?? []) + .flatMap((hover) => hover.contents) + .map((value) => (typeof value === 'string' ? value : value.value)) + .join('\n'); + } + + test('shows the Rule docs link when hovering an Inline-directive rule id', async () => { + const document = await openFixture(); + const content = await hoverTextAt( + document, + positionInside(document, RULE_ID), + ); + + // The diagnostic-matching shape: `Rslint(rule-id)`, rule id linking out. + assert.ok( + content.includes(`Rslint([${RULE_ID}](${RULE_DOCS_LINK}))`), + `Hover did not render Rslint(${RULE_ID}) with the docs link: ${content}`, + ); + }); + + test('hovers a prefixed rule id on a mid-file Inline directive', async () => { + const document = await openFixture(); + const ruleId = 'local/no-null'; + const content = await hoverTextAt( + document, + positionInside(document, ruleId), + ); + + assert.ok( + content.includes(ruleId), + `Hover did not name ${ruleId}: "${content}"`, + ); + assert.ok( + content.includes('https://rslint.rs/rules/local/no-null'), + `Hover did not contain the derived docs link: "${content}"`, + ); + }); + + test('renders Inline-directive rule ids as document links', async () => { + const document = await openFixture(); + const ruleStart = document.getText().indexOf(RULE_ID); + assert.ok(ruleStart >= 0, 'The fixture must contain the rule id'); + + const links = await vscode.commands.executeCommand( + 'vscode.executeLinkProvider', + document.uri, + ); + const ruleLink = (links ?? []).find( + (link) => link.target?.toString() === RULE_DOCS_LINK, + ); + + assert.ok(ruleLink, `Expected a document link to ${RULE_DOCS_LINK}`); + assert.deepStrictEqual( + [ruleLink.range.start, ruleLink.range.end], + [ + document.positionAt(ruleStart), + document.positionAt(ruleStart + RULE_ID.length), + ], + 'The link range must cover exactly the rule id token', + ); + }); + + test('publishes a clickable rule code on diagnostics', async () => { + const document = await openFixture(); + const diagnostics = await waitForRslintDiagnostics(document, (items) => + items.some((diagnostic) => diagnosticRuleIdIncludes(diagnostic, RULE_ID)), + ); + const diagnostic = diagnostics.find((item) => + diagnosticRuleIdIncludes(item, RULE_ID), + ); + + assert.ok(diagnostic, `Expected a ${RULE_ID} diagnostic`); + assert.ok( + !diagnostic.message.startsWith(`[${RULE_ID}] `), + `Diagnostic prefix was not stripped: ${diagnostic.message}`, + ); + assert.strictEqual( + typeof diagnostic.code === 'object' + ? diagnostic.code.target.toString() + : undefined, + RULE_DOCS_LINK, + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-hover/index.ts b/packages/vscode/e2e/lint/suite-hover/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-hover/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts b/packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts index a736d7e..fd8f3f6 100644 --- a/packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts +++ b/packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts @@ -1,12 +1,16 @@ // Ported from web-infra-dev/rslint // `packages/vscode-extension/__tests__/suite-import-cycle/import-cycle.test.ts` -// at 760c4135. Assertion semantics are unchanged. The upstream fixture's -// deprecated rslint.json is an intentional deviation: this extension does not -// support JSON configs, so it uses an equivalent rslint.config.mjs. +// at 760c4135. Rule assertions read Diagnostic.code because issue #27 strips +// the message prefix. The upstream fixture's deprecated rslint.json is an +// intentional deviation: this extension does not support JSON configs, so it +// uses an equivalent rslint.config.mjs. import * as assert from 'assert'; import * as vscode from 'vscode'; import path from 'node:path'; -import { waitForRslintDiagnostics } from '../utils/diagnostics'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics, +} from '../utils/diagnostics'; import { closeTextEditor } from '../utils/documents'; /** @@ -32,8 +36,8 @@ import { closeTextEditor } from '../utils/documents'; suite('rslint import/no-cycle over LSP', function () { this.timeout(120000); - const cycleMarker = '[import/no-cycle]'; - const sentinelMarker = '[no-var]'; + const cycleRuleId = 'import/no-cycle'; + const sentinelRuleId = 'no-var'; const brokenC = [ 'export var witnessC = 1;', @@ -66,11 +70,11 @@ suite('rslint import/no-cycle over LSP', function () { function cycleDiagnostics( diagnostics: vscode.Diagnostic[], ): vscode.Diagnostic[] { - return diagnostics.filter((d) => d.message.includes(cycleMarker)); + return diagnostics.filter((d) => diagnosticRuleIdIncludes(d, cycleRuleId)); } function isLintedPass(diagnostics: vscode.Diagnostic[]): boolean { - return diagnostics.some((d) => d.message.includes(sentinelMarker)); + return diagnostics.some((d) => diagnosticRuleIdIncludes(d, sentinelRuleId)); } /** Replaces the whole buffer, leaving the document dirty and unsaved. */ diff --git a/packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts index d8c6552..8cda453 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts @@ -13,7 +13,10 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import path from 'node:path'; import fs from 'node:fs'; -import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics as waitForDiagnostics, +} from '../utils/diagnostics'; import { closeTextEditor, revertTextDocument } from '../utils/documents'; import { waitForLintStackRegistration } from '../utils/extension'; @@ -74,14 +77,16 @@ suite('rslint JS config support', function () { // Wait specifically for JS config diagnostics. The startup snapshot may // publish JSON fallback results before JS config activation commits. const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( diagnostics.length > 0, `Expected diagnostics but got ${diagnostics.length}`, ); assert.ok( - diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + diagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Expected no-unsafe-member-access diagnostic from JS config', ); }); @@ -94,15 +99,17 @@ suite('rslint JS config support', function () { // JSON config may load first with no-explicit-any, but the committed JS // config catalog should override it. const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + diagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Expected no-unsafe-member-access from JS config', ); assert.ok( - !diagnostics.some((d) => d.message.includes('no-explicit-any')), + !diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), 'Should NOT see no-explicit-any because JS config takes priority over JSON', ); }); @@ -113,7 +120,7 @@ suite('rslint JS config support', function () { // 1. Verify initial diagnostics have no-unsafe-member-access. await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); // 2. Subscribe BEFORE writing the new config — eliminates the @@ -143,25 +150,31 @@ suite('rslint JS config support', function () { const reloaded = waitForDiagnostics( doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')) && - !diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')) && + !diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); fs.writeFileSync(configPath, newConfig, 'utf8'); const updatedDiags = await reloaded; assert.ok( - updatedDiags.some((d) => d.message.includes('no-explicit-any')), + updatedDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-explicit-any'), + ), 'After hot reload, diagnostics should include no-explicit-any', ); assert.ok( !updatedDiags.some((d) => - d.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), ), 'After hot reload, no-unsafe-member-access should be gone', ); }, async () => { const restored = waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); fs.writeFileSync(configPath, originalConfig, 'utf8'); await restored; @@ -178,7 +191,7 @@ suite('rslint JS config support', function () { const doc = await openFixture('index.ts'); await vscode.window.showTextDocument(doc); await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); const countedConfig = `import fs from 'node:fs'; @@ -201,8 +214,10 @@ export default [{ const reloaded = waitForDiagnostics( doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')) && - !diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')) && + !diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); fs.rmSync(markerPath, { force: true }); fs.writeFileSync(configPath, countedConfig, 'utf8'); @@ -222,7 +237,9 @@ export default [{ await withFailClosedCleanup( async () => { const restored = waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); fs.writeFileSync(configPath, originalConfig, 'utf8'); await restored; @@ -240,7 +257,7 @@ export default [{ await vscode.window.showTextDocument(doc); await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); const configPath = path.join(getWorkspaceRoot(), 'rslint.config.js'); @@ -271,7 +288,9 @@ export default [{ fs.writeFileSync(configPath, originalConfig, 'utf8'); await waitForLintStackRegistration(true); await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); }, 'JS-config deletion test', @@ -287,14 +306,16 @@ export default [{ // Establish a positive publication first, so clearing cannot pass on the // document's not-yet-linted initial empty snapshot. await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); await withFailClosedCleanup( async () => { // Step 1: delete existing config and observe its diagnostics drop. const cleared = waitForDiagnostics(doc, (diags) => - diags.every((d) => !d.message.includes('no-unsafe-member-access')), + diags.every( + (d) => !diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); fs.unlinkSync(configPath); await cleared; @@ -317,18 +338,22 @@ export default [{ ]; `; const created = waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); fs.writeFileSync(configPath, newConfig, 'utf8'); const afterCreateDiags = await created; assert.ok( - afterCreateDiags.some((d) => d.message.includes('no-explicit-any')), + afterCreateDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-explicit-any'), + ), 'After creating new JS config, should see no-explicit-any diagnostic', ); }, async () => { const restored = waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); fs.writeFileSync(configPath, originalConfig, 'utf8'); await restored; @@ -373,7 +398,7 @@ export default [{ await waitForDiagnostics(rootDoc, (diags) => diags.some( (diagnostic) => - diagnostic.message.includes('no-unsafe-member-access') && + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access') && diagnostic.severity === vscode.DiagnosticSeverity.Warning, ), ); @@ -382,7 +407,7 @@ export default [{ await waitForDiagnostics(nestedDoc, (diags) => diags.some( (diagnostic) => - diagnostic.message.includes('no-debugger') && + diagnosticRuleIdIncludes(diagnostic, 'no-debugger') && diagnostic.severity === vscode.DiagnosticSeverity.Error, ), ); @@ -418,13 +443,15 @@ export default []; (diags) => diags.some( (diagnostic) => - diagnostic.message.includes('no-debugger') && + diagnosticRuleIdIncludes(diagnostic, 'no-debugger') && diagnostic.severity === vscode.DiagnosticSeverity.Error, ), ); assert.deepStrictEqual( postFailureDiagnostics - .filter((diagnostic) => diagnostic.message.includes('no-debugger')) + .filter((diagnostic) => + diagnosticRuleIdIncludes(diagnostic, 'no-debugger'), + ) .map((diagnostic) => diagnostic.severity), [vscode.DiagnosticSeverity.Error], 'The valid ancestor must lint a file opened after the broken child was evaluated', @@ -447,8 +474,10 @@ export default []; const rootRestored = waitForDiagnostics(rootDoc, (diags) => diags.some( (diagnostic) => - diagnostic.message.includes('no-unsafe-member-access') && - diagnostic.severity === vscode.DiagnosticSeverity.Error, + diagnosticRuleIdIncludes( + diagnostic, + 'no-unsafe-member-access', + ) && diagnostic.severity === vscode.DiagnosticSeverity.Error, ), ); fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); @@ -537,7 +566,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; await vscode.window.showTextDocument(rootDoc); const parentApplied = waitForDiagnostics(rootDoc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-explicit-any'), + diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'), ), ); const nestedCleared = waitForDiagnostics( @@ -570,10 +599,10 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; rootDoc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access'), ) && !diagnostics.some((diagnostic) => - diagnostic.message.includes('no-explicit-any'), + diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'), ), ); fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); @@ -620,7 +649,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; await vscode.window.showTextDocument(rootDoc); const reloaded = waitForDiagnostics(rootDoc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-explicit-any'), + diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'), ), ); fs.writeFileSync(rootConfigPath, changedRootConfig, 'utf8'); @@ -638,10 +667,10 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; rootDoc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access'), ) && !diagnostics.some((diagnostic) => - diagnostic.message.includes('no-explicit-any'), + diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'), ), ); for (const probe of probes) { @@ -705,13 +734,15 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; const result = waitForDiagnostics(doc, (diags) => diags.some( (d) => - d.message.includes(ruleName) && + diagnosticRuleIdIncludes(d, ruleName) && d.severity === vscode.DiagnosticSeverity.Warning, ), ); mutate(); const diagnostics = await result; - const diagnostic = diagnostics.find((d) => d.message.includes(ruleName)); + const diagnostic = diagnostics.find((d) => + diagnosticRuleIdIncludes(d, ruleName), + ); assert.strictEqual( diagnostic?.severity, vscode.DiagnosticSeverity.Warning, @@ -722,7 +753,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; await waitForDiagnostics(doc, (diags) => diags.some( (diagnostic) => - diagnostic.message.includes('no-unsafe-member-access') && + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access') && diagnostic.severity === vscode.DiagnosticSeverity.Error, ), ); @@ -759,7 +790,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; const restored = waitForDiagnostics(doc, (diags) => diags.some( (d) => - d.message.includes('no-unsafe-member-access') && + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access') && d.severity === vscode.DiagnosticSeverity.Error, ), ); @@ -782,7 +813,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; const doc = await openFixture('index.ts'); await vscode.window.showTextDocument(doc); await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); const lowerPriorityConfig = `export default [{ @@ -834,7 +865,7 @@ export default []; const lastGoodApplied = waitForDiagnostics(doc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access'), ), ); assert.ok( @@ -851,14 +882,18 @@ export default []; ); const diagnostics = await lastGoodApplied; assert.ok( - !diagnostics.some((d) => d.message.includes('no-explicit-any')), + !diagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'no-explicit-any'), + ), 'A broken .js must not fall through to .mjs or JSON', ); }, async () => { await revertTextDocument(doc); const restored = waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); fs.writeFileSync(jsPath, originalJS, 'utf8'); fs.rmSync(mjsPath, { force: true }); diff --git a/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts b/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts index e75cbb5..af178e3 100644 --- a/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts +++ b/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts @@ -6,7 +6,10 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import path from 'node:path'; import fs from 'node:fs'; -import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics as waitForDiagnostics, +} from '../utils/diagnostics'; import { revertTextDocument } from '../utils/documents'; import { CoreResolver } from '../../../src/stacks/lint/CoreResolver'; import type { StackState } from '../../../src/types'; @@ -140,24 +143,24 @@ suite('rslint monorepo multi-config support', function () { const [rootDiagnostics, fooDiagnostics] = await Promise.all([ waitForDiagnostics(rootDoc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-explicit-any'), + diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'), ), ), waitForDiagnostics(fooDoc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access'), ), ), ]); assert.ok( rootDiagnostics.some((diagnostic) => - diagnostic.message.includes('no-explicit-any'), + diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'), ), ); assert.ok( fooDiagnostics.some((diagnostic) => - diagnostic.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access'), ), ); @@ -178,15 +181,17 @@ suite('rslint monorepo multi-config support', function () { await vscode.window.showTextDocument(doc); const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - diagnostics.some((d) => d.message.includes('no-explicit-any')), + diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), 'Root file should see no-explicit-any from root config', ); assert.ok( - !diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + !diagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Root file should NOT see no-unsafe-member-access (off in root config)', ); }); @@ -196,15 +201,17 @@ suite('rslint monorepo multi-config support', function () { await vscode.window.showTextDocument(doc); const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + diagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Foo file should see no-unsafe-member-access from foo config', ); assert.ok( - !diagnostics.some((d) => d.message.includes('no-explicit-any')), + !diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), 'Foo file should NOT see no-explicit-any (off in foo config)', ); }); @@ -214,15 +221,17 @@ suite('rslint monorepo multi-config support', function () { await vscode.window.showTextDocument(doc); const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - diagnostics.some((d) => d.message.includes('no-explicit-any')), + diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), 'Bar file should see no-explicit-any from root config (fallback)', ); assert.ok( - !diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + !diagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Bar file should NOT see no-unsafe-member-access (off in root config)', ); }); @@ -236,11 +245,13 @@ suite('rslint monorepo multi-config support', function () { await vscode.window.showTextDocument(doc); const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + diagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Foo file should still use foo config despite broken sibling config', ); }); @@ -252,11 +263,11 @@ suite('rslint monorepo multi-config support', function () { await vscode.window.showTextDocument(doc); const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - diagnostics.some((d) => d.message.includes('no-explicit-any')), + diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), 'Broken package file should fall back to the root config', ); }); @@ -269,10 +280,12 @@ suite('rslint monorepo multi-config support', function () { // 1. Verify initial: foo config has no-unsafe-member-access: error const initialDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - initialDiags.some((d) => d.message.includes('no-unsafe-member-access')), + initialDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Initial: foo file should have no-unsafe-member-access', ); @@ -307,16 +320,18 @@ suite('rslint monorepo multi-config support', function () { await triggerRelint(editor); const updatedDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - updatedDiags.some((d) => d.message.includes('no-explicit-any')), + updatedDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-explicit-any'), + ), 'After change: foo file should see no-explicit-any', ); assert.ok( !updatedDiags.some((d) => - d.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), ), 'After change: foo file should NOT see no-unsafe-member-access', ); @@ -332,10 +347,12 @@ suite('rslint monorepo multi-config support', function () { // 1. Verify initial: foo config has no-unsafe-member-access: error const initialDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - initialDiags.some((d) => d.message.includes('no-unsafe-member-access')), + initialDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Initial: foo file should have no-unsafe-member-access', ); @@ -353,16 +370,18 @@ suite('rslint monorepo multi-config support', function () { // 3. Foo file should now fall back to root config (no-explicit-any: error) const afterDeleteDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - afterDeleteDiags.some((d) => d.message.includes('no-explicit-any')), + afterDeleteDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-explicit-any'), + ), 'After delete: foo file should fall back to root config (no-explicit-any)', ); assert.ok( !afterDeleteDiags.some((d) => - d.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), ), 'After delete: foo file should NOT see no-unsafe-member-access (off in root)', ); @@ -378,10 +397,12 @@ suite('rslint monorepo multi-config support', function () { // 1. Verify initial: foo config works const initialDiags = await waitForDiagnostics(fooDoc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - initialDiags.some((d) => d.message.includes('no-unsafe-member-access')), + initialDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Initial: foo file should have no-unsafe-member-access', ); @@ -401,10 +422,10 @@ suite('rslint monorepo multi-config support', function () { await vscode.window.showTextDocument(barDoc); const barDiags = await waitForDiagnostics(barDoc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - barDiags.some((d) => d.message.includes('no-explicit-any')), + barDiags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), 'Bar file should still use root config after foo config is corrupted', ); } finally { @@ -419,10 +440,10 @@ suite('rslint monorepo multi-config support', function () { // 1. Verify initial: bar uses root config (no-explicit-any: error) const initialDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - initialDiags.some((d) => d.message.includes('no-explicit-any')), + initialDiags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), 'Initial: bar file should have no-explicit-any from root config', ); @@ -456,16 +477,20 @@ suite('rslint monorepo multi-config support', function () { // 3. Bar should now use its own config const afterCreateDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); assert.ok( afterCreateDiags.some((d) => - d.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), ), 'After create: bar file should see no-unsafe-member-access from new bar config', ); assert.ok( - !afterCreateDiags.some((d) => d.message.includes('no-explicit-any')), + !afterCreateDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-explicit-any'), + ), 'After create: bar file should NOT see no-explicit-any (off in bar config)', ); @@ -475,15 +500,17 @@ suite('rslint monorepo multi-config support', function () { await triggerRelint(editor); const afterDeleteDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - afterDeleteDiags.some((d) => d.message.includes('no-explicit-any')), + afterDeleteDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-explicit-any'), + ), 'After delete: bar file should fall back to root config (no-explicit-any)', ); assert.ok( !afterDeleteDiags.some((d) => - d.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), ), 'After delete: bar file should NOT see no-unsafe-member-access (off in root)', ); @@ -509,16 +536,16 @@ suite('rslint monorepo multi-config support', function () { // 1. Establish positive publications for both configs. Any later empty bar // snapshot is therefore a real transition, not a not-yet-linted default. const initialFooDiags = await waitForDiagnostics(fooDoc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( initialFooDiags.some((d) => - d.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), ), 'Initial: foo file should have no-unsafe-member-access from foo config', ); await waitForDiagnostics(barDoc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); let testError: unknown; @@ -565,7 +592,7 @@ suite('rslint monorepo multi-config support', function () { const fooRestored = waitForDiagnostics(fooDoc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access'), ), ); assert.ok( @@ -590,7 +617,7 @@ suite('rslint monorepo multi-config support', function () { await revertTextDocument(fooDoc); const rootRestored = waitForDiagnostics(barDoc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-explicit-any'), + diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'), ), ); fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); @@ -616,10 +643,10 @@ suite('rslint monorepo multi-config support', function () { // 1. Verify initial: bar uses root config (no-explicit-any: error) const initialDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-explicit-any')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), ); assert.ok( - initialDiags.some((d) => d.message.includes('no-explicit-any')), + initialDiags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')), 'Initial: bar file should have no-explicit-any from root config', ); @@ -651,15 +678,21 @@ suite('rslint monorepo multi-config support', function () { await triggerRelint(editor); const updatedDiags = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); assert.ok( - updatedDiags.some((d) => d.message.includes('no-unsafe-member-access')), + updatedDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'After change: bar file should see no-unsafe-member-access from updated root', ); assert.ok( - !updatedDiags.some((d) => d.message.includes('no-explicit-any')), + !updatedDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-explicit-any'), + ), 'After change: bar file should NOT see no-explicit-any (off in updated root)', ); } finally { diff --git a/packages/vscode/e2e/lint/suite-multiroot/multiroot.test.ts b/packages/vscode/e2e/lint/suite-multiroot/multiroot.test.ts index b13b01c..5081de8 100644 --- a/packages/vscode/e2e/lint/suite-multiroot/multiroot.test.ts +++ b/packages/vscode/e2e/lint/suite-multiroot/multiroot.test.ts @@ -3,6 +3,7 @@ import * as assert from 'node:assert'; import path from 'node:path'; import * as vscode from 'vscode'; +import { diagnosticRuleIdIncludes } from '../utils/diagnostics'; function workspaceFolder(name: string): vscode.WorkspaceFolder { const folder = vscode.workspace.workspaceFolders?.find( @@ -35,7 +36,7 @@ async function waitForSingleRslintDiagnostic( const diagnostics = rslintDiagnostics(document); if ( diagnostics.length === 1 && - diagnostics[0].message.includes('no-explicit-any') + diagnosticRuleIdIncludes(diagnostics[0], 'no-explicit-any') ) { // Do not accept a transient single result while a duplicate owner is // still publishing its first diagnostics. diff --git a/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts b/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts index 85d1b68..0382afc 100644 --- a/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts +++ b/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts @@ -19,6 +19,7 @@ import * as vscode from 'vscode'; import path from 'node:path'; import fs from 'node:fs'; import { + diagnosticRuleIdIncludes, getRslintDiagnostics, waitForRslintDiagnostics as waitForDiagnostics, } from '../utils/diagnostics'; @@ -201,10 +202,12 @@ suite('rslint no config fallback', function () { fs.writeFileSync(js, jsConfig, 'utf8'); await waitForLintStackRegistration(true); const diags = await waitForDiagnostics(doc, (ds) => - ds.some((d) => d.message.includes('no-unsafe-member-access')), + ds.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Step 1: creating rslint.config.js should produce its diagnostics', ); @@ -244,7 +247,9 @@ suite('rslint no config fallback', function () { fs.writeFileSync(js, jsConfig, 'utf8'); await waitForLintStackRegistration(true); await waitForDiagnostics(doc, (ds) => - ds.some((d) => d.message.includes('no-unsafe-member-access')), + ds.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); // Delete the JS config: the folder is un-detected (rslint.json does diff --git a/packages/vscode/e2e/lint/suite-project-service-scope/project-service-scope.test.ts b/packages/vscode/e2e/lint/suite-project-service-scope/project-service-scope.test.ts index e833df9..7c0a483 100644 --- a/packages/vscode/e2e/lint/suite-project-service-scope/project-service-scope.test.ts +++ b/packages/vscode/e2e/lint/suite-project-service-scope/project-service-scope.test.ts @@ -4,7 +4,10 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import path from 'node:path'; import { findFixAllAction, requestFixAll } from '../suite/fixall-helpers'; -import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics as waitForDiagnostics, +} from '../utils/diagnostics'; // Type-aware rule scope when parserOptions uses `projectService: true` // (the shape `ts.configs.recommended` exports) without an explicit @@ -48,13 +51,13 @@ suite('rslint projectService type-aware scope', function () { const diagnostics = await waitForDiagnostics(doc, (diags) => rslintDiagnostics(diags).some((d) => - d.message.includes('no-unused-vars'), + diagnosticRuleIdIncludes(d, 'no-unused-vars'), ), ); const rslintDiags = rslintDiagnostics(diagnostics); assert.ok( - rslintDiags.some((d) => d.message.includes('no-unused-vars')), + rslintDiags.some((d) => diagnosticRuleIdIncludes(d, 'no-unused-vars')), `Expected no-unused-vars on src/covered.ts. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, ); }); @@ -70,16 +73,18 @@ suite('rslint projectService type-aware scope', function () { // diagnostics, so the negative assertion below can run synchronously // instead of waiting on a fixed-duration sleep. const diagnostics = await waitForDiagnostics(doc, (diags) => - rslintDiagnostics(diags).some((d) => d.message.includes('no-console')), + rslintDiagnostics(diags).some((d) => + diagnosticRuleIdIncludes(d, 'no-console'), + ), ); const rslintDiags = rslintDiagnostics(diagnostics); assert.ok( - rslintDiags.some((d) => d.message.includes('no-console')), + rslintDiags.some((d) => diagnosticRuleIdIncludes(d, 'no-console')), `Expected no-console marker to appear on test/skills.test.ts. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, ); assert.ok( - !rslintDiags.some((d) => d.message.includes('no-unused-vars')), + !rslintDiags.some((d) => diagnosticRuleIdIncludes(d, 'no-unused-vars')), `no-unused-vars should NOT fire on a file outside tsconfig.include. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, ); }); @@ -91,16 +96,18 @@ suite('rslint projectService type-aware scope', function () { await vscode.window.showTextDocument(doc); const diagnostics = await waitForDiagnostics(doc, (diags) => - rslintDiagnostics(diags).some((d) => d.message.includes('no-var')), + rslintDiagnostics(diags).some((d) => + diagnosticRuleIdIncludes(d, 'no-var'), + ), ); const rslintDiags = rslintDiagnostics(diagnostics); assert.ok( - rslintDiags.some((d) => d.message.includes('no-var')), + rslintDiags.some((d) => diagnosticRuleIdIncludes(d, 'no-var')), `Expected native no-var marker. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, ); assert.ok( - !rslintDiags.some((d) => d.message.includes('no-unused-vars')), + !rslintDiags.some((d) => diagnosticRuleIdIncludes(d, 'no-unused-vars')), `no-unused-vars should not run without a resolved tsconfig. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, ); }); @@ -111,7 +118,9 @@ suite('rslint projectService type-aware scope', function () { ); await vscode.window.showTextDocument(doc); await waitForDiagnostics(doc, (diags) => - rslintDiagnostics(diags).some((d) => d.message.includes('no-var')), + rslintDiagnostics(diags).some((d) => + diagnosticRuleIdIncludes(d, 'no-var'), + ), ); const fixAll = findFixAllAction(await requestFixAll(doc)); diff --git a/packages/vscode/e2e/lint/suite-type-aware-scope/type-aware-scope.test.ts b/packages/vscode/e2e/lint/suite-type-aware-scope/type-aware-scope.test.ts index 8dcea03..a56b686 100644 --- a/packages/vscode/e2e/lint/suite-type-aware-scope/type-aware-scope.test.ts +++ b/packages/vscode/e2e/lint/suite-type-aware-scope/type-aware-scope.test.ts @@ -3,7 +3,10 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import path from 'node:path'; -import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics as waitForDiagnostics, +} from '../utils/diagnostics'; import { closeTextEditor } from '../utils/documents'; // Tests that type-aware rules (e.g. require-await) only run on files covered @@ -31,18 +34,18 @@ suite('rslint type-aware rule scope', function () { await vscode.window.showTextDocument(doc); const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('require-await')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'require-await')), ); // require-await should fire (type-aware, file IS in tsconfig) assert.ok( - diagnostics.some((d) => d.message.includes('require-await')), + diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'require-await')), `Expected require-await for file in tsconfig. Got: ${diagnostics.map((d) => d.message).join(', ')}`, ); // no-console should also fire (non-type-aware, always runs) assert.ok( - diagnostics.some((d) => d.message.includes('no-console')), + diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-console')), 'Expected no-console for file in tsconfig', ); }); @@ -57,18 +60,18 @@ suite('rslint type-aware rule scope', function () { // Wait for no-console (non-type-aware) to appear — proves rslint IS linting the file const diagnostics = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('no-console')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-console')), ); // no-console SHOULD fire (non-type-aware, always runs) assert.ok( - diagnostics.some((d) => d.message.includes('no-console')), + diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'no-console')), `Expected no-console for file outside tsconfig. Got: ${diagnostics.map((d) => d.message).join(', ')}`, ); // require-await should NOT fire (type-aware, file is NOT in configured tsconfig) assert.ok( - !diagnostics.some((d) => d.message.includes('require-await')), + !diagnostics.some((d) => diagnosticRuleIdIncludes(d, 'require-await')), 'require-await should NOT fire for file outside parserOptions.project tsconfig', ); }); @@ -81,10 +84,10 @@ suite('rslint type-aware rule scope', function () { const doc = await vscode.workspace.openTextDocument(filePath); const editor = await vscode.window.showTextDocument(doc); const initial = await waitForDiagnostics(doc, (diags) => - diags.some((d) => d.message.includes('require-await')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'require-await')), ); assert.ok( - initial.some((d) => d.message.includes('require-await')), + initial.some((d) => diagnosticRuleIdIncludes(d, 'require-await')), 'Expected require-await before editing the standalone project source', ); @@ -106,11 +109,11 @@ suite('rslint type-aware rule scope', function () { const updated = await waitForDiagnostics( doc, (diags) => - diags.some((d) => d.message.includes('no-console')) && - !diags.some((d) => d.message.includes('require-await')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-console')) && + !diags.some((d) => diagnosticRuleIdIncludes(d, 'require-await')), ); assert.ok( - !updated.some((d) => d.message.includes('require-await')), + !updated.some((d) => diagnosticRuleIdIncludes(d, 'require-await')), 'Incremental standalone Program retained a stale require-await diagnostic', ); @@ -118,10 +121,12 @@ suite('rslint type-aware rule scope', function () { const reopened = await vscode.workspace.openTextDocument(filePath); await vscode.window.showTextDocument(reopened); const reopenedDiagnostics = await waitForDiagnostics(reopened, (diags) => - diags.some((d) => d.message.includes('require-await')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'require-await')), ); assert.ok( - reopenedDiagnostics.some((d) => d.message.includes('require-await')), + reopenedDiagnostics.some((d) => + diagnosticRuleIdIncludes(d, 'require-await'), + ), 'Reopened standalone project source did not restore disk diagnostics', ); }); @@ -137,10 +142,12 @@ suite('rslint type-aware rule scope', function () { const source = await vscode.workspace.openTextDocument(sourcePath); await vscode.window.showTextDocument(source); const initial = await waitForDiagnostics(source, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - initial.some((d) => d.message.includes('no-unsafe-member-access')), + initial.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Expected the dependency any type to produce no-unsafe-member-access', ); @@ -154,11 +161,15 @@ suite('rslint type-aware rule scope', function () { const updated = await waitForDiagnostics( source, (diags) => - diags.some((d) => d.message.includes('no-console')) && - !diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-console')) && + !diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), ); assert.ok( - !updated.some((d) => d.message.includes('no-unsafe-member-access')), + !updated.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'External dependency change left stale standalone Program diagnostics', ); } finally { @@ -166,10 +177,12 @@ suite('rslint type-aware rule scope', function () { } const restored = await waitForDiagnostics(source, (diags) => - diags.some((d) => d.message.includes('no-unsafe-member-access')), + diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), ); assert.ok( - restored.some((d) => d.message.includes('no-unsafe-member-access')), + restored.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), 'Restored dependency did not restore standalone Program diagnostics', ); }); diff --git a/packages/vscode/e2e/lint/suite/extension.test.ts b/packages/vscode/e2e/lint/suite/extension.test.ts index 551779d..49c62c1 100644 --- a/packages/vscode/e2e/lint/suite/extension.test.ts +++ b/packages/vscode/e2e/lint/suite/extension.test.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import path from 'node:path'; import { executeCodeActionProvider, getFixturesDir } from './fixall-helpers'; import { + diagnosticRuleIdIncludes, getRslintDiagnostics, waitForRslintDiagnostics as waitForDiagnostics, waitForRslintDiagnosticsCount as waitForDiagnosticsCount, @@ -32,16 +33,16 @@ suite('rslint extension', function () { } }); - function waitForDiagnosticsWithMessage( + function waitForDiagnosticsWithRuleId( doc: vscode.TextDocument, - messageSubstring: string, + ruleId: string, timeoutMs = 30000, ): Promise { return waitForDiagnostics( doc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes(messageSubstring), + diagnosticRuleIdIncludes(diagnostic, ruleId), ), timeoutMs, ); @@ -71,7 +72,7 @@ suite('rslint extension', function () { const control = await openFixture('disable.ts'); await vscode.window.showTextDocument(control); - const controlDiagnostics = await waitForDiagnosticsWithMessage( + const controlDiagnostics = await waitForDiagnosticsWithRuleId( control, 'no-unsafe-member-access', ); @@ -79,7 +80,7 @@ suite('rslint extension', function () { controlDiagnostics.some( (diagnostic) => diagnostic.source === 'rslint' && - diagnostic.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access'), ), 'Expected the unignored control file to produce an rslint diagnostic', ); @@ -106,7 +107,7 @@ suite('rslint extension', function () { // Find the no-unnecessary-type-assertion diagnostic const typeAssertionDiag = diagnostics.find( (d) => - d.message.includes('no-unnecessary-type-assertion') || + diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion') || (d.source === 'rslint' && d.message.includes('assertion')), ); assert.ok( @@ -414,12 +415,12 @@ suite('rslint extension', function () { cleanContent.replace('export', `${insertedContent}export`), 'VS Code should preserve the document EOL while applying the edit', ); - const diagnostics = await waitForDiagnosticsWithMessage( + const diagnostics = await waitForDiagnosticsWithRuleId( doc, 'no-unsafe-member-access', ); const unsafeMember = diagnostics.find((diagnostic) => - diagnostic.message.includes('no-unsafe-member-access'), + diagnosticRuleIdIncludes(diagnostic, 'no-unsafe-member-access'), ); assert.ok(unsafeMember, 'Expected an unsafe member access diagnostic'); assert.deepStrictEqual( @@ -533,7 +534,7 @@ suite('rslint extension', function () { 'const baseline: any = {};\nbaseline.member;\nexport {};\n', ), ); - await waitForDiagnosticsWithMessage(doc, 'no-unsafe-member-access'); + await waitForDiagnosticsWithRuleId(doc, 'no-unsafe-member-access'); // Step 1: Start with clean code — should have zero diagnostics await editor.edit((b) => @@ -553,7 +554,7 @@ suite('rslint extension', function () { 'const obj: any = {};\nobj.foo.bar;\nexport {};\n', ), ); - const errorADiags = await waitForDiagnosticsWithMessage( + const errorADiags = await waitForDiagnosticsWithRuleId( doc, 'no-unsafe-member-access', ); @@ -562,7 +563,9 @@ suite('rslint extension', function () { `Step 2 (error A): expected diagnostics, got ${errorADiags.length}`, ); assert.ok( - errorADiags.some((d) => d.message.includes('no-unsafe-member-access')), + errorADiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), `Step 2 (error A): expected no-unsafe-member-access diagnostic, got: ${errorADiags.map((d) => d.message).join(', ')}`, ); @@ -573,7 +576,7 @@ suite('rslint extension', function () { "const someValue: string = 'hello';\nconst result = someValue as string;\nexport {};\n", ), ); - const errorBDiags = await waitForDiagnosticsWithMessage( + const errorBDiags = await waitForDiagnosticsWithRuleId( doc, 'no-unnecessary-type-assertion', ); @@ -583,13 +586,15 @@ suite('rslint extension', function () { ); assert.ok( errorBDiags.some((d) => - d.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion'), ), `Step 3 (error B): expected no-unnecessary-type-assertion diagnostic, got: ${errorBDiags.map((d) => d.message).join(', ')}`, ); // Verify error A is gone assert.ok( - !errorBDiags.some((d) => d.message.includes('no-unsafe-member-access')), + !errorBDiags.some((d) => + diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'), + ), `Step 3 (error B): no-unsafe-member-access should be gone`, ); diff --git a/packages/vscode/e2e/lint/suite/fixall-cascade.test.ts b/packages/vscode/e2e/lint/suite/fixall-cascade.test.ts index beaa88b..36565fa 100644 --- a/packages/vscode/e2e/lint/suite/fixall-cascade.test.ts +++ b/packages/vscode/e2e/lint/suite/fixall-cascade.test.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { waitForDiagnostics, waitForContentChange, + diagnosticRuleIdIncludes, findFixAllAction, requestFixAll, withTmpFile, @@ -27,7 +28,7 @@ suite('rslint fixAll - cascade (multi-pass)', function () { await withTmpFile(cascadeContent, async (doc) => { const initialDiags = await waitForDiagnostics(doc); const wrapperDiags = initialDiags.filter((d) => - d.message.includes('no-wrapper-object-types'), + diagnosticRuleIdIncludes(d, 'no-wrapper-object-types'), ); assert.ok( wrapperDiags.length > 0, @@ -72,7 +73,9 @@ suite('rslint fixAll - cascade (multi-pass)', function () { const diags = await waitForDiagnostics(doc); assert.ok( - diags.some((d) => d.message.includes('no-wrapper-object-types')), + diags.some((d) => + diagnosticRuleIdIncludes(d, 'no-wrapper-object-types'), + ), `Expected no-wrapper-object-types before on-save cascade. Got: ${diags .map((d) => d.message) .join(' | ')}`, diff --git a/packages/vscode/e2e/lint/suite/fixall-error.test.ts b/packages/vscode/e2e/lint/suite/fixall-error.test.ts index 3d4080f..1261438 100644 --- a/packages/vscode/e2e/lint/suite/fixall-error.test.ts +++ b/packages/vscode/e2e/lint/suite/fixall-error.test.ts @@ -1,11 +1,12 @@ // Ported from web-infra-dev/rslint (deviation: setup waits go through -// waitForDiagnosticsWithMessages -- see fixall-helpers.ts for why). +// waitForDiagnosticsWithRuleIds -- see fixall-helpers.ts for why). // `packages/vscode-extension/__tests__/suite/fixall-error.test.ts` (origin/main). import * as assert from 'assert'; import * as vscode from 'vscode'; import { - waitForDiagnosticsWithMessages, + waitForDiagnosticsWithRuleIds, waitForContentChange, + diagnosticRuleIdIncludes, findFixAllAction, requestFixAll, withTmpFile, @@ -58,13 +59,13 @@ suite('rslint fixAll - error flows', function () { editor, "const pVal: string = 'x';\nconst pRes = (pVal as string).trim();\n", ); - const probeDiags = await waitForDiagnosticsWithMessages( + const probeDiags = await waitForDiagnosticsWithRuleIds( doc, 'no-unnecessary-type-assertion', ); assert.ok( probeDiags.some((d) => - d.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion'), ), `Expected fixable diagnostic before syntax-error save. Got: ${probeDiags .map((d) => d.message) diff --git a/packages/vscode/e2e/lint/suite/fixall-helpers.ts b/packages/vscode/e2e/lint/suite/fixall-helpers.ts index 71606c7..6bf3f3a 100644 --- a/packages/vscode/e2e/lint/suite/fixall-helpers.ts +++ b/packages/vscode/e2e/lint/suite/fixall-helpers.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import path from 'node:path'; import fs from 'node:fs'; import { + diagnosticRuleIdIncludes, waitForRslintDiagnostics, waitForRslintDiagnosticsCount, waitForRslintDiagnosticsToChange, @@ -17,12 +18,13 @@ import { import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry'; export { saveDocumentOnce } from '../utils/codeActionRegistry'; +export { diagnosticRuleIdIncludes } from '../utils/diagnostics'; export const waitForDiagnostics = waitForRslintDiagnostics; /** - * Wait until the rslint diagnostics for `doc` include every given message - * substring. + * 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 @@ -32,13 +34,15 @@ export const waitForDiagnostics = waitForRslintDiagnostics; * with slow file watchers (macOS). Waiting for the expected diagnostics keeps * the terminal assertion identical without depending on publish batching. */ -export function waitForDiagnosticsWithMessages( +export function waitForDiagnosticsWithRuleIds( doc: vscode.TextDocument, - ...messages: string[] + ...ruleIds: string[] ): Promise { return waitForRslintDiagnostics(doc, (diagnostics) => - messages.every((message) => - diagnostics.some((diagnostic) => diagnostic.message.includes(message)), + ruleIds.every((ruleId) => + diagnostics.some((diagnostic) => + diagnosticRuleIdIncludes(diagnostic, ruleId), + ), ), ); } diff --git a/packages/vscode/e2e/lint/suite/fixall-onsave.test.ts b/packages/vscode/e2e/lint/suite/fixall-onsave.test.ts index b094801..c36925b 100644 --- a/packages/vscode/e2e/lint/suite/fixall-onsave.test.ts +++ b/packages/vscode/e2e/lint/suite/fixall-onsave.test.ts @@ -1,5 +1,5 @@ // Ported from web-infra-dev/rslint (deviation: setup waits go through -// waitForDiagnosticsWithMessages -- see fixall-helpers.ts for why). +// waitForDiagnosticsWithRuleIds -- see fixall-helpers.ts for why). // `packages/vscode-extension/__tests__/suite/fixall-onsave.test.ts` (origin/main). import * as assert from 'assert'; import * as vscode from 'vscode'; @@ -7,9 +7,10 @@ import { getRslintDiagnostics } from '../utils/diagnostics'; import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry'; import { waitForDiagnostics, - waitForDiagnosticsWithMessages, + waitForDiagnosticsWithRuleIds, waitForDiagnosticsCount, waitForContentChange, + diagnosticRuleIdIncludes, withOnSaveFixAll, replaceAll, saveDocumentOnce, @@ -21,7 +22,7 @@ function assertHasFixableDiagnostic( ): void { assert.ok( diagnostics.some((d) => - d.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion'), ), `${context}: expected no-unnecessary-type-assertion. Got: ${diagnostics .map((d) => d.message) @@ -40,7 +41,7 @@ suite('rslint fixAll - on-save', function () { "const gfVal: string = 'x';\nconst gfRes = (gfVal as string).trim();\n", ); - const diags = await waitForDiagnosticsWithMessages( + const diags = await waitForDiagnosticsWithRuleIds( doc, 'no-unnecessary-type-assertion', ); @@ -74,7 +75,7 @@ suite('rslint fixAll - on-save', function () { ].join('\n'); await replaceAll(editor, fixableContent); - const diags = await waitForDiagnosticsWithMessages( + const diags = await waitForDiagnosticsWithRuleIds( doc, 'no-unnecessary-type-assertion', ); @@ -101,7 +102,7 @@ suite('rslint fixAll - on-save', function () { editor, "const probeVal: string = 'x';\nconst probeRes = (probeVal as string).trim();\n", ); - const probeDiags = await waitForDiagnosticsWithMessages( + const probeDiags = await waitForDiagnosticsWithRuleIds( doc, 'no-unnecessary-type-assertion', ); @@ -141,7 +142,7 @@ suite('rslint fixAll - on-save', function () { editor, "const probeVal2: string = 'x';\nconst probeRes2 = (probeVal2 as string).trim();\n", ); - const probeDiags = await waitForDiagnosticsWithMessages( + const probeDiags = await waitForDiagnosticsWithRuleIds( doc, 'no-unnecessary-type-assertion', ); diff --git a/packages/vscode/e2e/lint/suite/fixall.test.ts b/packages/vscode/e2e/lint/suite/fixall.test.ts index f8f3c05..a37ba51 100644 --- a/packages/vscode/e2e/lint/suite/fixall.test.ts +++ b/packages/vscode/e2e/lint/suite/fixall.test.ts @@ -1,13 +1,14 @@ // Ported from web-infra-dev/rslint (deviation: setup waits go through -// waitForDiagnosticsWithMessages -- see fixall-helpers.ts for why). +// waitForDiagnosticsWithRuleIds -- see fixall-helpers.ts for why). // `packages/vscode-extension/__tests__/suite/fixall.test.ts` (origin/main). import * as assert from 'assert'; import * as vscode from 'vscode'; import { waitForDiagnostics, - waitForDiagnosticsWithMessages, + waitForDiagnosticsWithRuleIds, waitForDiagnosticsToChange, waitForDiagnosticsCount, + diagnosticRuleIdIncludes, openFixture, findFixAllAction, requestFixAll, @@ -82,12 +83,12 @@ suite('rslint fixAll - code actions', function () { await withTmpFile(fixableContent, async (doc, editor) => { const initialDiagnostics = await waitForDiagnostics(doc, (diagnostics) => diagnostics.some((diagnostic) => - diagnostic.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(diagnostic, 'no-unnecessary-type-assertion'), ), ); assert.ok( initialDiagnostics.some((diagnostic) => - diagnostic.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(diagnostic, 'no-unnecessary-type-assertion'), ), `Expected a fixable control diagnostic. Got: ${initialDiagnostics .map((diagnostic) => diagnostic.message) @@ -129,14 +130,14 @@ suite('rslint fixAll - code actions', function () { const fixableContent = "const frVal: string = 'hello';\nconst frRes = (frVal as string).toUpperCase();\n"; await withTmpFile(fixableContent, async (doc) => { - const initialDiags = await waitForDiagnosticsWithMessages( + const initialDiags = await waitForDiagnosticsWithRuleIds( doc, 'no-unnecessary-type-assertion', ); assert.ok(initialDiags.length > 0, 'Should have initial diagnostics'); const fixableDiags = initialDiags.filter((d) => - d.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion'), ); assert.ok( fixableDiags.length > 0, @@ -171,7 +172,7 @@ suite('rslint fixAll - code actions', function () { '', ].join('\n'); await withTmpFile(mixedContent, async (doc) => { - const initialDiags = await waitForDiagnosticsWithMessages( + const initialDiags = await waitForDiagnosticsWithRuleIds( doc, 'no-unnecessary-type-assertion', 'no-unsafe', @@ -179,10 +180,10 @@ suite('rslint fixAll - code actions', function () { assert.ok(initialDiags.length > 0, 'Should have diagnostics'); const fixableBefore = initialDiags.filter((d) => - d.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion'), ); const nonFixableBefore = initialDiags.filter((d) => - d.message.includes('no-unsafe'), + diagnosticRuleIdIncludes(d, 'no-unsafe'), ); assert.ok( fixableBefore.length > 0, @@ -210,7 +211,7 @@ suite('rslint fixAll - code actions', function () { ); const fixableAfter = updatedDiags.filter((d) => - d.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion'), ); assert.ok( fixableAfter.length < fixableBefore.length, @@ -218,7 +219,7 @@ suite('rslint fixAll - code actions', function () { ); const nonFixableAfter = updatedDiags.filter((d) => - d.message.includes('no-unsafe'), + diagnosticRuleIdIncludes(d, 'no-unsafe'), ); assert.ok( nonFixableAfter.length > 0, @@ -257,12 +258,12 @@ suite('rslint fixAll - code actions', function () { const fixableContent = "const sfVal: string = 'x';\nconst sfRes = (sfVal as string).trim();\n"; await withTmpFile(fixableContent, async (doc) => { - const initialDiags = await waitForDiagnosticsWithMessages( + const initialDiags = await waitForDiagnosticsWithRuleIds( doc, 'no-unnecessary-type-assertion', ); const fixableCount = initialDiags.filter((d) => - d.message.includes('no-unnecessary-type-assertion'), + diagnosticRuleIdIncludes(d, 'no-unnecessary-type-assertion'), ).length; assert.ok( fixableCount > 0, diff --git a/packages/vscode/e2e/lint/utils/diagnostics.ts b/packages/vscode/e2e/lint/utils/diagnostics.ts index 8444a79..b5a7684 100644 --- a/packages/vscode/e2e/lint/utils/diagnostics.ts +++ b/packages/vscode/e2e/lint/utils/diagnostics.ts @@ -4,6 +4,24 @@ import * as vscode from 'vscode'; export const rslintDiagnosticSource = 'rslint'; +/** + * Rslint's rule id lives in the clickable VS Code diagnostic-code shape. + * Issue #27 intentionally strips the old `[rule-id] ` message prefix, so the + * ported suites must identify rules here instead of searching the prose. + */ +function diagnosticRuleId(diagnostic: vscode.Diagnostic): string | undefined { + const code = diagnostic.code; + if (typeof code === 'object') return String(code.value); + return code === undefined ? undefined : String(code); +} + +export function diagnosticRuleIdIncludes( + diagnostic: vscode.Diagnostic, + fragment: string, +): boolean { + return diagnosticRuleId(diagnostic)?.includes(fragment) ?? false; +} + export function getRslintDiagnostics( documentOrUri: vscode.TextDocument | vscode.Uri, ): vscode.Diagnostic[] { diff --git a/packages/vscode/scripts/playgroundNode.mjs b/packages/vscode/scripts/playgroundNode.mjs new file mode 100644 index 0000000..1af0f91 --- /dev/null +++ b/packages/vscode/scripts/playgroundNode.mjs @@ -0,0 +1,127 @@ +// Materializes a Node.js binary that satisfies the extension's runtime floor +// into .playground/node-bin/, so the F5 playground (see /.vscode/launch.json) +// can prepend it to the Extension Development Host's PATH. A GUI-launched +// VS Code never runs the contributor's shell hooks (fnm/nvm auto-switching on +// the repo's .nvmrc), so without this the dev host inherits whatever `node` +// the desktop session has — often below the floor, and Rslint then refuses to +// start with "version mismatch". +// +// The floor is read from src/shared/versionCheck.ts (NODE_RUNTIME_RANGE), the +// single source of truth — do not restate the range here. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = path.resolve(fileURLToPath(import.meta.url), '..', '..'); +const require = createRequire(path.join(packageRoot, 'package.json')); +const semver = require('semver'); + +const versionCheckSource = fs.readFileSync( + path.join(packageRoot, 'src', 'shared', 'versionCheck.ts'), + 'utf8', +); +const rangeMatch = versionCheckSource.match( + /NODE_RUNTIME_RANGE\s*=\s*'([^']+)'/, +); +if (!rangeMatch) { + throw new Error('Could not read NODE_RUNTIME_RANGE from versionCheck.ts'); +} +const floor = rangeMatch[1]; + +/** + * One spawn per probed binary: version and executable path together. + * @returns {{version: string, execPath: string} | undefined} + */ +function probe(binary) { + try { + const [version, execPath] = execFileSync( + binary, + ['-p', "process.versions.node + '\\n' + process.execPath"], + { encoding: 'utf8' }, + ) + .trim() + .split('\n'); + const valid = semver.valid(semver.coerce(version)); + return valid ? { version: valid, execPath } : undefined; + } catch { + return undefined; + } +} + +function* managedNodeBinaries() { + const home = os.homedir(); + const exe = process.platform === 'win32' ? 'node.exe' : 'bin/node'; + const roots = [ + // fnm + process.env.FNM_DIR && path.join(process.env.FNM_DIR, 'node-versions'), + path.join(home, 'Library', 'Application Support', 'fnm', 'node-versions'), + path.join(home, '.local', 'share', 'fnm', 'node-versions'), + path.join(home, '.fnm', 'node-versions'), + // nvm + path.join(home, '.nvm', 'versions', 'node'), + // volta + path.join(home, '.volta', 'tools', 'image', 'node'), + // asdf + path.join(home, '.asdf', 'installs', 'nodejs'), + ].filter(Boolean); + for (const root of roots) { + let entries = []; + try { + entries = fs.readdirSync(root); + } catch { + continue; + } + for (const entry of entries) { + // Every one of these managers names the install directory after its + // version, so candidates are filtered without spawning anything. + const version = semver.valid(semver.coerce(entry)); + if (!version || !semver.satisfies(version, floor)) continue; + // fnm nests the actual install one level down. + for (const suffix of [exe, path.join('installation', exe)]) { + const candidate = path.join(root, entry, suffix); + if (fs.existsSync(candidate)) yield { binary: candidate, version }; + } + } + } +} + +/** @returns {{version: string, execPath: string} | undefined} */ +function resolveCompliantNode() { + // A PATH node that already satisfies the floor wins: zero surprise. + const fromPath = probe('node'); + if (fromPath && semver.satisfies(fromPath.version, floor)) return fromPath; + // Otherwise the highest satisfying install any common version manager has, + // confirmed with a single spawn. + let best; + for (const candidate of managedNodeBinaries()) { + if (!best || semver.gt(candidate.version, best.version)) best = candidate; + } + return best ? probe(best.binary) : undefined; +} + +const target = path.join(packageRoot, '.playground', 'node-bin'); +const link = path.join( + target, + process.platform === 'win32' ? 'node.exe' : 'node', +); +const resolved = resolveCompliantNode(); +if (!resolved) { + console.error( + `The playground needs a Node.js satisfying "${floor}" and none was found ` + + 'on PATH or in fnm/nvm/volta/asdf installs. Install one (see .nvmrc), ' + + 'then relaunch.', + ); + process.exit(1); +} + +fs.mkdirSync(target, { recursive: true }); +fs.rmSync(link, { force: true }); +try { + fs.symlinkSync(resolved.execPath, link); +} catch { + fs.copyFileSync(resolved.execPath, link); // symlinks need privileges on Windows +} +console.log(`playground node: ${resolved.version} (${resolved.execPath})`); diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index 1093ad4..f51d7fe 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -68,6 +68,10 @@ export const readPackageVersion = ( * floor. Verified: 22.17.1 reports `process.features.typescript` false, * 22.18.0 reports `strip`. See `shared/nodeResolution.ts` for how candidates * are probed. + * + * `scripts/playgroundNode.mjs` regex-parses this exact declaration (it is a + * plain .mjs with no access to TS exports) — keep the single-quoted literal + * form if this constant moves or is reformatted. */ export const NODE_RUNTIME_RANGE = '^22.18.0 || >=23.6.0'; diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index d0c810e..18f1498 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -299,6 +299,7 @@ export interface RslintOptions { readonly router: WorkspaceDocumentRouter; readonly logger: Logger; readonly reportStatus: RslintStatusSink; + readonly onClosed?: () => void; } export class Rslint implements Disposable { @@ -311,6 +312,7 @@ export class Rslint implements Disposable { private readonly installation: CoreInstallation; private readonly lspOutputChannel: OutputChannel; private readonly outputChannel: OutputChannel; + private readonly onClosed: (() => void) | undefined; private readonly configWatchers: FileSystemWatcher[] = []; private configReloadTimer: ReturnType | undefined; private configReloadChain: Promise = Promise.resolve(); @@ -334,6 +336,7 @@ export class Rslint implements Disposable { this.logger = options.logger; this.lspOutputChannel = options.lspOutputChannel; this.outputChannel = options.outputChannel; + this.onClosed = options.onClosed; } private report(state: StackState): void { @@ -616,6 +619,14 @@ export class Rslint implements Disposable { } private async closeImpl(): Promise { + try { + await this.closeResources(); + } finally { + this.onClosed?.(); + } + } + + private async closeResources(): Promise { const errors: unknown[] = []; const disposeSafely = (resource: Disposable | undefined): void => { if (!resource) return; @@ -721,6 +732,10 @@ export class Rslint implements Disposable { return this.client?.state === State.Running; } + public serverAdvertisesHover(): boolean { + return Boolean(this.client?.initializeResult?.capabilities.hoverProvider); + } + public async sendDocumentOpen(document: TextDocument): Promise { const provider = this.client ?.getFeature(DidOpenTextDocumentNotification.method) diff --git a/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts b/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts index f488a59..c6eee59 100644 --- a/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts +++ b/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts @@ -9,6 +9,7 @@ import { type WorkspaceFolder, } from 'vscode'; import type { Middleware } from 'vscode-languageclient/node'; +import { enrichRslintDiagnostic } from './diagnosticEnrichment'; const SUPPORTED_LANGUAGE_IDS = new Set([ 'typescript', @@ -168,6 +169,9 @@ export class WorkspaceDocumentRouter { (candidate) => candidate.uri.toString() === uri.toString(), ); if (!document || !this.isServerOpenOwner(runtime, document)) return; + for (const diagnostic of diagnostics) { + enrichRslintDiagnostic(diagnostic); + } next(uri, diagnostics); }, }; diff --git a/packages/vscode/src/stacks/lint/diagnosticEnrichment.ts b/packages/vscode/src/stacks/lint/diagnosticEnrichment.ts new file mode 100644 index 0000000..e4cfeb9 --- /dev/null +++ b/packages/vscode/src/stacks/lint/diagnosticEnrichment.ts @@ -0,0 +1,36 @@ +import { Uri, type Diagnostic } from 'vscode'; +import { ruleDocsUrl } from './ruleDocumentation'; + +const RULE_PREFIX = /^\[([^\]\s]+)\] /; + +// Rule ids repeat heavily across a publish; the key space is bounded by the +// active rule set, so the cache needs no eviction. +const docsUriCache = new Map(); +function ruleDocsUri(ruleId: string): Uri { + let uri = docsUriCache.get(ruleId); + if (!uri) { + uri = Uri.parse(ruleDocsUrl(ruleId)); + docsUriCache.set(ruleId, uri); + } + return uri; +} + +/** Enriches the diagnostic shape published by today's Rslint server. */ +export function enrichRslintDiagnostic(diagnostic: Diagnostic): void { + // An object code means the language client converted a server-published + // codeDescription into the authoritative value/target shape. This synthesis + // then yields automatically and becomes dead code to delete (ADR 0004). + if (typeof diagnostic.code === 'object') return; + + const match = RULE_PREFIX.exec(diagnostic.message); + if (!match) return; + + const code = diagnostic.code ?? match[1]; + // The language client has already converted LSP diagnostics here. VS Code's + // equivalent of LSP code + codeDescription is the value/target code shape. + diagnostic.code = { + value: code, + target: ruleDocsUri(String(code)), + }; + diagnostic.message = diagnostic.message.slice(match[0].length); +} diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 8a9d672..b3d31a3 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -10,6 +10,7 @@ import { CoreResolver, type ResolvedCoreRuntime } from './CoreResolver'; import { Logger } from './logger'; import { Rslint } from './Rslint'; import type { RslintMode } from './resolution'; +import { registerRuleDocumentationProviders } from './ruleDocumentationProviders'; import { RuntimeManager } from './RuntimeManager'; import { aggregateFolderStates, @@ -74,10 +75,17 @@ class RslintController implements StackController { #context: StackContext | undefined; #logger: Logger | undefined; #runtimeManager: RuntimeManager | undefined; + #router: WorkspaceDocumentRouter | undefined; /** The detection gate: a folder lints only while the snapshot lights it. */ #snapshot: DetectionSnapshot | undefined; readonly #subscriptions: vscode.Disposable[] = []; readonly #folderStates = new Map(); + // Mirror of the live runtimes, kept here (not on the router) so answering + // "does this document's server advertise hover?" needs no new surface on the + // upstream-copied WorkspaceDocumentRouter. Reachability is still gated by + // router ownership; each runtime removes itself only when it is still the + // entry for its key, so an overlapping replacement survives the old close. + readonly #runtimes = new Map(); #disposed = false; async register(context: StackContext): Promise> { @@ -86,6 +94,14 @@ class RslintController implements StackController { this.#logger = new Logger(context.output); this.startRuntimeManager(); + this.#subscriptions.push( + ...registerRuleDocumentationProviders({ + servesDocument: (document) => this.servesDocument(document), + serverAdvertisesHover: (document) => + this.serverAdvertisesHover(document), + refreshOn: context.onDidChangeDetection, + }), + ); this.#subscriptions.push( context.onDidChangeDetection((snapshot) => { @@ -174,6 +190,7 @@ class RslintController implements StackController { return; } const router = new WorkspaceDocumentRouter(); + this.#router = router; this.#runtimeManager = new RuntimeManager( router, new CoreResolver(), @@ -215,7 +232,7 @@ class RslintController implements StackController { const { workspaceFolder, installation } = resolved; const folderKey = folderKeyOf(workspaceFolder); this.setState(folderKey, 'runtimes', resolved.key, { kind: 'starting' }); - return new Rslint({ + const runtime: Rslint = new Rslint({ rootKey: resolved.key, workspaceFolder, installation, @@ -235,7 +252,14 @@ class RslintController implements StackController { attributeToCore(state, installation.packageDirectory), ); }, + onClosed: () => { + if (this.#runtimes.get(resolved.key) === runtime) { + this.#runtimes.delete(resolved.key); + } + }, }); + this.#runtimes.set(resolved.key, runtime); + return runtime; } private detectedFolders() { @@ -248,6 +272,19 @@ class RslintController implements StackController { return this.#snapshot?.forFolder(folder)?.stacks.rslint.mode; } + private servesDocument(document: vscode.TextDocument): boolean { + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + return folder !== undefined && this.folderMode(folder) !== undefined; + } + + private serverAdvertisesHover(document: vscode.TextDocument): boolean { + const owner = this.#router?.ownerKeyForDocument(document); + return ( + owner !== undefined && + this.#runtimes.get(owner)?.serverAdvertisesHover() === true + ); + } + /** Drops the states of folders detection no longer lights. */ private pruneDepartedFolders(): void { if (this.#disposed) return; @@ -340,6 +377,8 @@ class RslintController implements StackController { } await this.closeRuntimeManager(); this.#folderStates.clear(); + this.#runtimes.clear(); + this.#router = undefined; this.#logger = undefined; this.#context = undefined; this.#snapshot = undefined; diff --git a/packages/vscode/src/stacks/lint/inlineDirectives.ts b/packages/vscode/src/stacks/lint/inlineDirectives.ts new file mode 100644 index 0000000..3115b0a --- /dev/null +++ b/packages/vscode/src/stacks/lint/inlineDirectives.ts @@ -0,0 +1,60 @@ +export interface InlineDirectiveRuleToken { + readonly ruleId: string; + /** UTF-16 offset of the first character in the rule id. */ + readonly start: number; + /** UTF-16 offset immediately after the rule id. */ + readonly end: number; +} + +// This intentionally scans raw text, so comment-shaped string, template, or +// regex literals whose body starts with an Inline directive are false positives. +// A language-aware scanner is disproportionate for a hover/underline affordance; +// requiring the directive as the first token keeps the accepted surface tiny. +const COMMENT_PATTERN = /\/\/[^\r\n]*|\/\*[\s\S]*?\*\//g; +const DIRECTIVE_PATTERN = + /^\s*(?:rslint|eslint)-(?:disable-next-line|disable-line|disable|enable)(?=\s|$)/; +const DESCRIPTION_SEPARATOR = ' -- '; + +/** + * Finds the rule-id tokens carried by Inline directives in source comments. + * A bare directive represents every rule and therefore yields no tokens. + */ +export function parseInlineDirectiveRuleTokens( + source: string, +): InlineDirectiveRuleToken[] { + const tokens: InlineDirectiveRuleToken[] = []; + + for (const comment of source.matchAll(COMMENT_PATTERN)) { + const commentText = comment[0]; + const markerLength = 2; + const closingLength = commentText.startsWith('/*') ? 2 : 0; + const body = commentText.slice( + markerLength, + commentText.length - closingLength, + ); + const directive = DIRECTIVE_PATTERN.exec(body); + if (!directive) continue; + + const rulesStartInBody = directive[0].length; + const remainder = body.slice(rulesStartInBody); + const ruleList = remainder.split(DESCRIPTION_SEPARATOR)[0]; + const ruleListStart = + (comment.index ?? 0) + markerLength + rulesStartInBody; + + let segmentStart = 0; + for (const segment of ruleList.split(',')) { + const leadingWhitespace = segment.length - segment.trimStart().length; + const ruleId = segment.trim(); + // Rule ids are comma-separated. Internal whitespace means this segment + // is not a rule-id token, but the id is deliberately not checked against + // any bundled rule knowledge. + if (ruleId.length > 0 && !/\s/.test(ruleId)) { + const start = ruleListStart + segmentStart + leadingWhitespace; + tokens.push({ ruleId, start, end: start + ruleId.length }); + } + segmentStart += segment.length + 1; + } + } + + return tokens; +} diff --git a/packages/vscode/src/stacks/lint/ruleDocumentation.ts b/packages/vscode/src/stacks/lint/ruleDocumentation.ts new file mode 100644 index 0000000..d6cf6b5 --- /dev/null +++ b/packages/vscode/src/stacks/lint/ruleDocumentation.ts @@ -0,0 +1,13 @@ +const RULE_DOCS_BASE_URL = 'https://rslint.rs/rules'; + +/** Derives a best-effort Rule docs link without validating the rule id. */ +export function ruleDocsUrl(ruleId: string): string { + const separator = ruleId.lastIndexOf('/'); + if (separator === -1) { + return `${RULE_DOCS_BASE_URL}/eslint/${ruleId}`; + } + + const prefix = ruleId.slice(0, separator).replace(/^@/, ''); + const ruleName = ruleId.slice(separator + 1); + return `${RULE_DOCS_BASE_URL}/${prefix}/${ruleName}`; +} diff --git a/packages/vscode/src/stacks/lint/ruleDocumentationProviders.ts b/packages/vscode/src/stacks/lint/ruleDocumentationProviders.ts new file mode 100644 index 0000000..0dac32c --- /dev/null +++ b/packages/vscode/src/stacks/lint/ruleDocumentationProviders.ts @@ -0,0 +1,149 @@ +import vscode from 'vscode'; +import { + parseInlineDirectiveRuleTokens, + type InlineDirectiveRuleToken, +} from './inlineDirectives'; +import { ruleDocsUrl } from './ruleDocumentation'; +import { isSupportedWorkspaceDocument } from './WorkspaceDocumentRouter'; + +// Registration selector only — runtime eligibility checks go through the +// router's `isSupportedWorkspaceDocument`. Keep this list in step with +// `SUPPORTED_LANGUAGE_IDS` in WorkspaceDocumentRouter.ts (not exported there). +const DOCUMENT_SELECTOR: vscode.DocumentSelector = [ + 'typescript', + 'typescriptreact', + 'javascript', + 'javascriptreact', +].map((language) => ({ language, scheme: 'file' })); + +export interface RuleDocumentationProviderOptions { + readonly servesDocument: (document: vscode.TextDocument) => boolean; + readonly serverAdvertisesHover: (document: vscode.TextDocument) => boolean; + /** Extra moments to recompute decorations (e.g. a detection change). */ + readonly refreshOn?: vscode.Event; +} + +function tokenRange( + document: vscode.TextDocument, + token: { readonly start: number; readonly end: number }, +): vscode.Range { + return new vscode.Range( + document.positionAt(token.start), + document.positionAt(token.end), + ); +} + +// Hover, document links and decorations all consume the same token list per +// document; the memo makes one edit cost one parse regardless of consumer. +const tokenCache = new WeakMap< + vscode.TextDocument, + { version: number; tokens: readonly InlineDirectiveRuleToken[] } +>(); +function directiveTokens( + document: vscode.TextDocument, +): readonly InlineDirectiveRuleToken[] { + const cached = tokenCache.get(document); + if (cached?.version === document.version) return cached.tokens; + const tokens = parseInlineDirectiveRuleTokens(document.getText()); + tokenCache.set(document, { version: document.version, tokens }); + return tokens; +} + +/** Registers the Inline-directive affordances owned by the lint stack. */ +export function registerRuleDocumentationProviders( + options: RuleDocumentationProviderOptions, +): vscode.Disposable[] { + const hoverProvider: vscode.HoverProvider = { + provideHover(document, position, token) { + if ( + token.isCancellationRequested || + !options.servesDocument(document) || + options.serverAdvertisesHover(document) + ) { + return undefined; + } + + const offset = document.offsetAt(position); + const rule = directiveTokens(document).find( + (candidate) => candidate.start <= offset && offset < candidate.end, + ); + if (!rule) return undefined; + + // The shape VS Code renders for the published diagnostics — + // `Rslint(rule-id)` with the rule id linking to its docs page. + return new vscode.Hover( + new vscode.MarkdownString( + `Rslint([${rule.ruleId}](${ruleDocsUrl(rule.ruleId)}))`, + ), + tokenRange(document, rule), + ); + }, + }; + + const documentLinkProvider: vscode.DocumentLinkProvider = { + provideDocumentLinks(document, token) { + if (token.isCancellationRequested || !options.servesDocument(document)) { + return []; + } + return directiveTokens(document).map( + (rule) => + new vscode.DocumentLink( + tokenRange(document, rule), + vscode.Uri.parse(ruleDocsUrl(rule.ruleId)), + ), + ); + }, + }; + + // A persistent underline marks each rule id as an affordance (hover / + // ctrl+click); the DocumentLink underline alone only shows while the + // modifier key is held. + const decorationType = vscode.window.createTextEditorDecorationType({ + textDecoration: 'underline', + }); + + const decorate = (editor: vscode.TextEditor): void => { + const document = editor.document; + const eligible = + isSupportedWorkspaceDocument(document) && + options.servesDocument(document); + editor.setDecorations( + decorationType, + eligible + ? directiveTokens(document).map((rule) => tokenRange(document, rule)) + : [], + ); + }; + const decorateVisibleEditors = (): void => { + for (const editor of vscode.window.visibleTextEditors) decorate(editor); + }; + // One trailing-edge debounce across all triggers. It also keeps the initial + // pass off the awaited register() path (adaptation 1: register returns fast). + let debounce: ReturnType | undefined; + const scheduleDecorate = (): void => { + clearTimeout(debounce); + debounce = setTimeout(decorateVisibleEditors, 100); + }; + scheduleDecorate(); + + return [ + vscode.languages.registerHoverProvider(DOCUMENT_SELECTOR, hoverProvider), + vscode.languages.registerDocumentLinkProvider( + DOCUMENT_SELECTOR, + documentLinkProvider, + ), + decorationType, + vscode.window.onDidChangeVisibleTextEditors(scheduleDecorate), + vscode.workspace.onDidChangeTextDocument((event) => { + if ( + vscode.window.visibleTextEditors.some( + (editor) => editor.document === event.document, + ) + ) { + scheduleDecorate(); + } + }), + ...(options.refreshOn ? [options.refreshOn(scheduleDecorate)] : []), + { dispose: () => clearTimeout(debounce) }, + ]; +} diff --git a/packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts b/packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts new file mode 100644 index 0000000..b470221 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, rs } from '@rstest/core'; +import type { Diagnostic } from 'vscode'; + +rs.mock('vscode', () => ({ + Uri: { + parse: (value: string) => ({ value, toString: () => value }), + }, +})); + +import { enrichRslintDiagnostic } from '../../../src/stacks/lint/diagnosticEnrichment'; + +describe('enrichRslintDiagnostic', () => { + it('sets the diagnostic code and Rule docs link and strips the prefix', () => { + const diagnostic = { + message: '[no-console] Unexpected console statement.', + } as Diagnostic; + + enrichRslintDiagnostic(diagnostic); + + expect(diagnostic.message).toBe('Unexpected console statement.'); + expect(diagnostic.code).toMatchObject({ value: 'no-console' }); + expect( + typeof diagnostic.code === 'object' + ? diagnostic.code.target.toString() + : undefined, + ).toBe('https://rslint.rs/rules/eslint/no-console'); + }); + + it('passes non-matching diagnostics through untouched', () => { + for (const message of [ + 'Unexpected console statement.', + '[no-console]Unexpected console statement.', + 'prefix [no-console] Unexpected console statement.', + '[not a rule] Unexpected console statement.', + ]) { + const diagnostic = { message } as Diagnostic; + const before = { ...diagnostic }; + + enrichRslintDiagnostic(diagnostic); + + expect(diagnostic).toEqual(before); + } + }); + + it('yields to an object code with a server-published target', () => { + const diagnostic = { + message: '[no-console] Unexpected console statement.', + code: { + value: 'no-console', + target: { + toString: () => 'https://server.example/rules/no-console', + }, + }, + } as Diagnostic; + const before = { ...diagnostic }; + + enrichRslintDiagnostic(diagnostic); + + expect(diagnostic).toEqual(before); + }); + + it('upgrades a primitive code with a Rule docs link and strips the prefix', () => { + const diagnostic = { + message: '[parsed-rule] Unexpected console statement.', + code: 'no-console', + } as Diagnostic; + + enrichRslintDiagnostic(diagnostic); + + expect(diagnostic.message).toBe('Unexpected console statement.'); + expect(diagnostic.code).toMatchObject({ value: 'no-console' }); + expect( + typeof diagnostic.code === 'object' + ? diagnostic.code.target.toString() + : undefined, + ).toBe('https://rslint.rs/rules/eslint/no-console'); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/inlineDirectives.test.ts b/packages/vscode/tests/stacks/lint/inlineDirectives.test.ts new file mode 100644 index 0000000..aa15c10 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/inlineDirectives.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from '@rstest/core'; +import { parseInlineDirectiveRuleTokens } from '../../../src/stacks/lint/inlineDirectives'; + +function parsedRuleIds(source: string): string[] { + return parseInlineDirectiveRuleTokens(source).map((token) => token.ruleId); +} + +describe('parseInlineDirectiveRuleTokens', () => { + it('recognizes every rslint and eslint Inline directive form', () => { + for (const prefix of ['rslint', 'eslint']) { + for (const directive of [ + 'disable', + 'enable', + 'disable-line', + 'disable-next-line', + ]) { + expect(parsedRuleIds(`// ${prefix}-${directive} no-console`)).toEqual([ + 'no-console', + ]); + } + } + }); + + it('returns comma-separated rule ids with their precise source ranges', () => { + const source = + 'const value = 1; // rslint-disable no-console, @typescript-eslint/no-explicit-any'; + const tokens = parseInlineDirectiveRuleTokens(source); + + expect(tokens.map((token) => token.ruleId)).toEqual([ + 'no-console', + '@typescript-eslint/no-explicit-any', + ]); + for (const token of tokens) { + expect(source.slice(token.start, token.end)).toBe(token.ruleId); + } + }); + + it('ignores the description trailer', () => { + expect( + parsedRuleIds( + '// rslint-disable no-console, no-alert -- allowed in this fixture', + ), + ).toEqual(['no-console', 'no-alert']); + }); + + it('produces no tokens for wildcard directives', () => { + expect(parsedRuleIds('// rslint-disable')).toEqual([]); + expect(parsedRuleIds('// eslint-enable -- restore every rule')).toEqual([]); + }); + + it('recognizes Inline directives in block comments', () => { + const source = + '/*\n eslint-disable-next-line @typescript-eslint/no-explicit-any, no-console\n*/'; + const tokens = parseInlineDirectiveRuleTokens(source); + + expect(tokens.map((token) => token.ruleId)).toEqual([ + '@typescript-eslint/no-explicit-any', + 'no-console', + ]); + for (const token of tokens) { + expect(source.slice(token.start, token.end)).toBe(token.ruleId); + } + }); + + it('requires the directive to be the first token in the comment body', () => { + expect(parsedRuleIds('// explanation: rslint-disable no-console')).toEqual( + [], + ); + expect(parsedRuleIds('/* note rslint-disable no-console */')).toEqual([]); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/ruleDocumentation.test.ts b/packages/vscode/tests/stacks/lint/ruleDocumentation.test.ts new file mode 100644 index 0000000..451abb3 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/ruleDocumentation.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from '@rstest/core'; +import { ruleDocsUrl } from '../../../src/stacks/lint/ruleDocumentation'; + +describe('ruleDocsUrl', () => { + it('derives a prefixed Rule docs link', () => { + expect(ruleDocsUrl('@typescript-eslint/no-floating-promises')).toBe( + 'https://rslint.rs/rules/typescript-eslint/no-floating-promises', + ); + }); + + it('derives a core Rule docs link under eslint', () => { + expect(ruleDocsUrl('no-console')).toBe( + 'https://rslint.rs/rules/eslint/no-console', + ); + }); +});