From 430ed82c314b86b0579da8f98e315075a28745f4 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 20 Aug 2026 19:25:39 +0800 Subject: [PATCH 1/6] docs: record self-documenting diagnostics design (ADR 0004) --- CONTEXT.md | 2 ++ docs/adr/0004-no-bundled-rule-knowledge.md | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 docs/adr/0004-no-bundled-rule-knowledge.md 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). From 513a646e3b2e5dcd34d8227d2bddd6833de96212 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 21 Aug 2026 13:31:18 +0800 Subject: [PATCH 2/6] feat(vscode): make Rslint diagnostics self-documenting in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side half of #27. New lint-stack modules parse Inline directives (rslint-/eslint- prefixes, all four forms, comma lists, ' -- ' trailers) into per-rule-id hover (rendered as Rslint(rule-id) with the id linking to its docs page), DocumentLink and persistent-underline affordances, sharing one memoized parse per document version. The router middleware lifts the '[rule-id] ' message prefix into Diagnostic.code + codeDescription-equivalent target so the Problems panel shows a clickable rule id; it yields automatically once the server publishes code itself and passes unmatched messages through untouched. Everything derives from one base URL plus the rule id — no bundled rule metadata, no network, no configuration (ADR 0004). The hover provider stands down if a future server advertises hoverProvider. Recorded as the eighth adaptation in AGENTS.md. --- packages/vscode/AGENTS.md | 5 +- packages/vscode/src/stacks/lint/Rslint.ts | 4 + .../stacks/lint/WorkspaceDocumentRouter.ts | 4 + .../src/stacks/lint/diagnosticEnrichment.ts | 36 +++++ packages/vscode/src/stacks/lint/index.ts | 37 ++++- .../src/stacks/lint/inlineDirectives.ts | 56 +++++++ .../src/stacks/lint/ruleDocumentation.ts | 13 ++ .../stacks/lint/ruleDocumentationProviders.ts | 149 ++++++++++++++++++ .../stacks/lint/diagnosticEnrichment.test.ts | 56 +++++++ .../stacks/lint/inlineDirectives.test.ts | 71 +++++++++ .../stacks/lint/ruleDocumentation.test.ts | 16 ++ 11 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 packages/vscode/src/stacks/lint/diagnosticEnrichment.ts create mode 100644 packages/vscode/src/stacks/lint/inlineDirectives.ts create mode 100644 packages/vscode/src/stacks/lint/ruleDocumentation.ts create mode 100644 packages/vscode/src/stacks/lint/ruleDocumentationProviders.ts create mode 100644 packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts create mode 100644 packages/vscode/tests/stacks/lint/inlineDirectives.test.ts create mode 100644 packages/vscode/tests/stacks/lint/ruleDocumentation.test.ts diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index a41aa20..e903d12 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`; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively. ## Rules diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index d0c810e..4d0cb63 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -721,6 +721,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..2823250 --- /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 { + // The day the server publishes `code` itself, its answer is authoritative — + // this synthesis yields automatically and becomes dead code to delete + // (ADR 0004), even if the message keeps the `[rule-id] ` prefix. + if (diagnostic.code !== undefined) return; + + const match = RULE_PREFIX.exec(diagnostic.message); + if (!match) return; + + const ruleId = 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: ruleId, + target: ruleDocsUri(ruleId), + }; + 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..d592696 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: a closed runtime's key disappears from the router before + // this map is consulted, and `onRuntimeClosed` prunes the entry itself. + 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(), @@ -200,6 +217,7 @@ class RslintController implements StackController { this.clearState('failures', document.uri.toString()); }, onRuntimeClosed: (resolved) => { + this.#runtimes.delete(resolved.key); this.clearState('runtimes', resolved.key); }, }, @@ -215,7 +233,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 = new Rslint({ rootKey: resolved.key, workspaceFolder, installation, @@ -236,6 +254,8 @@ class RslintController implements StackController { ); }, }); + this.#runtimes.set(resolved.key, runtime); + return runtime; } private detectedFolders() { @@ -248,6 +268,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 +373,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..20bbcbb --- /dev/null +++ b/packages/vscode/src/stacks/lint/inlineDirectives.ts @@ -0,0 +1,56 @@ +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; +} + +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..f2ea688 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts @@ -0,0 +1,56 @@ +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 a server-published code even when the prefix remains', () => { + const diagnostic = { + message: '[no-console] Unexpected console statement.', + code: 'no-console', + } as Diagnostic; + const before = { ...diagnostic }; + + enrichRslintDiagnostic(diagnostic); + + expect(diagnostic).toEqual(before); + }); +}); 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', + ); + }); +}); From 1f9b214be7b2625fc197c03514b6a73f90fc7d33 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 21 Aug 2026 13:31:28 +0800 Subject: [PATCH 3/6] test(vscode): cover self-documenting diagnostics in E2E New suite-hover (isolated fixture) asserts the Rslint(rule-id) hover on line-0 and mid-file Inline directives, the DocumentLink range over the rule id token, and the enriched Diagnostic.code with its docs target and stripped message prefix. The ported suites migrate rule identification from message-substring matching to the shared diagnosticRuleIdIncludes accessor, since the rule id now lives in the diagnostic code instead of the message prose. --- .../e2e/lint/fixtures/hover/rslint.config.mjs | 7 + .../e2e/lint/fixtures/hover/src/index.ts | 8 ++ .../e2e/lint/fixtures/hover/tsconfig.json | 10 ++ packages/vscode/e2e/lint/runTest.ts | 5 + .../e2e/lint/suite-bridge/bridge.test.ts | 3 +- .../eslint-plugins.test.ts | 23 +-- .../vscode/e2e/lint/suite-hover/hover.test.ts | 128 +++++++++++++++++ packages/vscode/e2e/lint/suite-hover/index.ts | 3 + .../suite-import-cycle/import-cycle.test.ts | 20 +-- .../e2e/lint/suite-jsconfig/jsconfig.test.ts | 121 ++++++++++------ .../e2e/lint/suite-monorepo/monorepo.test.ts | 133 +++++++++++------- .../lint/suite-multiroot/multiroot.test.ts | 3 +- .../e2e/lint/suite-noconfig/noconfig.test.ts | 11 +- .../project-service-scope.test.ts | 29 ++-- .../type-aware-scope.test.ts | 55 +++++--- .../vscode/e2e/lint/suite/extension.test.ts | 33 +++-- .../e2e/lint/suite/fixall-cascade.test.ts | 7 +- .../e2e/lint/suite/fixall-error.test.ts | 9 +- .../vscode/e2e/lint/suite/fixall-helpers.ts | 16 ++- .../e2e/lint/suite/fixall-onsave.test.ts | 15 +- packages/vscode/e2e/lint/suite/fixall.test.ts | 27 ++-- packages/vscode/e2e/lint/utils/diagnostics.ts | 18 +++ 22 files changed, 492 insertions(+), 192 deletions(-) create mode 100644 packages/vscode/e2e/lint/fixtures/hover/rslint.config.mjs create mode 100644 packages/vscode/e2e/lint/fixtures/hover/src/index.ts create mode 100644 packages/vscode/e2e/lint/fixtures/hover/tsconfig.json create mode 100644 packages/vscode/e2e/lint/suite-hover/hover.test.ts create mode 100644 packages/vscode/e2e/lint/suite-hover/index.ts 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[] { From 7969f592b256b3e04c362a6ec43100a7463c4928 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 21 Aug 2026 13:31:36 +0800 Subject: [PATCH 4/6] build(vscode): give the F5 playground a floor-satisfying Node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GUI-launched VS Code never runs the contributor's shell hooks, so the dev host inherits the desktop session's node — often below the lint worker's runtime floor, at which point Rslint reports version mismatch and lints nothing. A new preLaunchTask chain (playground node -> watch) materializes a floor-satisfying Node into a gitignored .playground/node-bin/ (PATH node if compliant, else the highest satisfying fnm/nvm/volta/asdf install, filtered by directory name so only one probe spawns), and launch.json prepends that directory to the dev host's PATH only — nothing machine-wide changes. The floor is read from versionCheck.ts, the single source of truth. --- .gitignore | 3 + .vscode/launch.json | 14 ++- .vscode/tasks.json | 26 +++++ packages/vscode/scripts/playgroundNode.mjs | 127 +++++++++++++++++++++ packages/vscode/src/shared/versionCheck.ts | 4 + 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 packages/vscode/scripts/playgroundNode.mjs 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/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'; From 25699272020383b6509b181585c6120bcb4da5aa Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 21 Aug 2026 13:31:45 +0800 Subject: [PATCH 5/6] test(vscode): turn the rslint playground fixture into a feature showcase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture the F5 playground opens now demonstrates every lint capability in region-separated sections: clickable rule docs in the Problems panel (a local plugin rule whose derived link deliberately 404s beside native rules that resolve to real pages), Inline-directive hover/underline/ctrl+click, every directive form (next-line, trailing disable-line, eslint- prefix, bare wildcard, comma lists with a description trailer), and the mistyped-id pitfall. Native rules join the config in a second entry — one entry takes either community plugin instances or built-in plugin names, never both. The smoke-test contract is preserved: exactly one null literal, asserted rule set unchanged. --- .../e2e/fixtures/rslint/rslint.config.mjs | 14 +++++ .../vscode/e2e/fixtures/rslint/src/index.ts | 61 ++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) 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 From 3d08174b345c25a697c4687cf7275925fd10cc46 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 21 Aug 2026 13:46:30 +0800 Subject: [PATCH 6/6] fix(vscode): address PR review findings on runtime mirror and code guard - Prune the controller's runtime capability mirror identity-safely via an optional Rslint.onClosed hook, so a same-key runtime replacement created during an in-flight close is no longer deleted by the old runtime's cleanup (RuntimeManager removes its entry before the async close ends). - Yield diagnostic enrichment only to an object code (the converted server-published codeDescription shape); a future primitive server code is preserved as the value while the docs target is still derived and the [rule-id] prefix stripped. - Document the accepted raw-text false-positive trade-off on the inline directive comment scanner. --- packages/vscode/AGENTS.md | 2 +- packages/vscode/src/stacks/lint/Rslint.ts | 11 ++++++++ .../src/stacks/lint/diagnosticEnrichment.ts | 14 +++++----- packages/vscode/src/stacks/lint/index.ts | 12 ++++++--- .../src/stacks/lint/inlineDirectives.ts | 4 +++ .../stacks/lint/diagnosticEnrichment.test.ts | 26 +++++++++++++++++-- 6 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index e903d12..0225be1 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -17,7 +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`; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively. +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/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 4d0cb63..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; diff --git a/packages/vscode/src/stacks/lint/diagnosticEnrichment.ts b/packages/vscode/src/stacks/lint/diagnosticEnrichment.ts index 2823250..e4cfeb9 100644 --- a/packages/vscode/src/stacks/lint/diagnosticEnrichment.ts +++ b/packages/vscode/src/stacks/lint/diagnosticEnrichment.ts @@ -17,20 +17,20 @@ function ruleDocsUri(ruleId: string): Uri { /** Enriches the diagnostic shape published by today's Rslint server. */ export function enrichRslintDiagnostic(diagnostic: Diagnostic): void { - // The day the server publishes `code` itself, its answer is authoritative — - // this synthesis yields automatically and becomes dead code to delete - // (ADR 0004), even if the message keeps the `[rule-id] ` prefix. - if (diagnostic.code !== undefined) return; + // 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 ruleId = match[1]; + 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: ruleId, - target: ruleDocsUri(ruleId), + 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 d592696..b3d31a3 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -83,8 +83,8 @@ class RslintController implements StackController { // 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: a closed runtime's key disappears from the router before - // this map is consulted, and `onRuntimeClosed` prunes the entry itself. + // 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; @@ -217,7 +217,6 @@ class RslintController implements StackController { this.clearState('failures', document.uri.toString()); }, onRuntimeClosed: (resolved) => { - this.#runtimes.delete(resolved.key); this.clearState('runtimes', resolved.key); }, }, @@ -233,7 +232,7 @@ class RslintController implements StackController { const { workspaceFolder, installation } = resolved; const folderKey = folderKeyOf(workspaceFolder); this.setState(folderKey, 'runtimes', resolved.key, { kind: 'starting' }); - const runtime = new Rslint({ + const runtime: Rslint = new Rslint({ rootKey: resolved.key, workspaceFolder, installation, @@ -253,6 +252,11 @@ 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; diff --git a/packages/vscode/src/stacks/lint/inlineDirectives.ts b/packages/vscode/src/stacks/lint/inlineDirectives.ts index 20bbcbb..3115b0a 100644 --- a/packages/vscode/src/stacks/lint/inlineDirectives.ts +++ b/packages/vscode/src/stacks/lint/inlineDirectives.ts @@ -6,6 +6,10 @@ export interface InlineDirectiveRuleToken { 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|$)/; diff --git a/packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts b/packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts index f2ea688..b470221 100644 --- a/packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts +++ b/packages/vscode/tests/stacks/lint/diagnosticEnrichment.test.ts @@ -42,10 +42,15 @@ describe('enrichRslintDiagnostic', () => { } }); - it('yields to a server-published code even when the prefix remains', () => { + it('yields to an object code with a server-published target', () => { const diagnostic = { message: '[no-console] Unexpected console statement.', - code: 'no-console', + code: { + value: 'no-console', + target: { + toString: () => 'https://server.example/rules/no-console', + }, + }, } as Diagnostic; const before = { ...diagnostic }; @@ -53,4 +58,21 @@ describe('enrichRslintDiagnostic', () => { 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'); + }); });