From e395acbf1223d192eb9b5a131f9894709db8fdac Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 3 Sep 2026 16:35:59 +0800 Subject: [PATCH 1/3] fix(vscode): update Rslint trace and config handling --- packages/vscode/AGENTS.md | 2 +- .../fixtures/unicode-bom/rslint.config.mjs | 15 + .../lint/fixtures/unicode-bom/src/unmarked.ts | 1 + .../lint/fixtures/unicode-bom/tsconfig.json | 8 + packages/vscode/e2e/lint/runTest.ts | 10 + .../suite-jsconfig/config-transaction.test.ts | 31 +- packages/vscode/e2e/lint/suite-trace/index.ts | 3 + .../suite-trace/trace-output-channel.test.ts | 327 ++++++++++++++++++ .../e2e/lint/suite-unicode-bom/index.ts | 3 + .../suite-unicode-bom/unicode-bom.test.ts | 154 +++++++++ packages/vscode/src/stacks/lint/Rslint.ts | 31 +- packages/vscode/src/stacks/lint/index.ts | 6 +- packages/vscode/tests/extension.test.ts | 8 +- 13 files changed, 563 insertions(+), 36 deletions(-) create mode 100644 packages/vscode/e2e/lint/fixtures/unicode-bom/rslint.config.mjs create mode 100644 packages/vscode/e2e/lint/fixtures/unicode-bom/src/unmarked.ts create mode 100644 packages/vscode/e2e/lint/fixtures/unicode-bom/tsconfig.json create mode 100644 packages/vscode/e2e/lint/suite-trace/index.ts create mode 100644 packages/vscode/e2e/lint/suite-trace/trace-output-channel.test.ts create mode 100644 packages/vscode/e2e/lint/suite-unicode-bom/index.ts create mode 100644 packages/vscode/e2e/lint/suite-unicode-bom/unicode-bom.test.ts diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 246962c..b2520f3 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -30,7 +30,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. Enable-settings are coarse kill switches only. - Reconciles and restarts share one serialized queue (`enqueue`); a reconcile leaves a live stack alone, so the restart path — the commands, and the full pass any relevant settings change triggers — is the only thing that rebuilds one. Do not add a second queue. - Restart is a shell concern, not a stack one: `rstack.restart` rebuilds every controller, `rstack..restart` rebuilds one. A stack must never register its own restart command — a shallower "bounce the tool's process" restart keeps that controller's stale package resolution and version check, which is the bug the command exists to clear. -- A relevant settings change (a gate key, or a key a live controller declares in `restartOnSettings`) triggers **one full restart pass**, never a targeted one. Per-stack selectivity was removed deliberately: settings edits are rare, and deciding per stack between "reconcile handles the gate" and "restart handles the setting" swallowed the restart when one save wrote a gate key at its already-effective value alongside a shared setting. The full pass re-evaluates every gate, so flips in either direction need no special casing. +- A relevant settings change (a gate key, or a key a live controller declares in `restartOnSettings`) triggers **one full restart pass**, never a targeted one. Per-stack selectivity was removed deliberately: settings edits are rare, and deciding per stack between "reconcile handles the gate" and "restart handles the setting" swallowed the restart when one save wrote a gate key at its already-effective value alongside a shared setting. The full pass re-evaluates every gate, so flips in either direction need no special casing. `rstack.rslint.trace.server` and `rstack.fmt.trace.server` are deliberately not restart triggers: vscode-languageclient applies both to running clients through `$/setTrace` and includes the current value when a later client initializes. - Deprecated `rslint.json` / `rslint.jsonc` are unsupported by decision, not omission — never make them detection signals. - Never share a child process across stacks: the tools have incompatible cwd semantics (lint LSP anchors on spawn cwd; test worker pins to project root; the `rs fmt` server takes its config root from the workspace folder the client reports, falling back to spawn cwd, with no upward walk either way). The lint and fmt servers now happen to stand in the same directory — the folder root — which changes nothing: they are different CLIs, different protocols and different version gates. - In Restricted Mode (workspace trust), only the status bar runs — no process spawns, no project code loaded. diff --git a/packages/vscode/e2e/lint/fixtures/unicode-bom/rslint.config.mjs b/packages/vscode/e2e/lint/fixtures/unicode-bom/rslint.config.mjs new file mode 100644 index 0000000..fb48534 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/unicode-bom/rslint.config.mjs @@ -0,0 +1,15 @@ +export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + 'unicode-bom': 'error', + 'no-var': 'error', + }, + }, +]; diff --git a/packages/vscode/e2e/lint/fixtures/unicode-bom/src/unmarked.ts b/packages/vscode/e2e/lint/fixtures/unicode-bom/src/unmarked.ts new file mode 100644 index 0000000..7712fb9 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/unicode-bom/src/unmarked.ts @@ -0,0 +1 @@ +export const unmarked = 1; diff --git a/packages/vscode/e2e/lint/fixtures/unicode-bom/tsconfig.json b/packages/vscode/e2e/lint/fixtures/unicode-bom/tsconfig.json new file mode 100644 index 0000000..99878f0 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/unicode-bom/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "strict": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index a600cf1..7ca02b5 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -235,6 +235,11 @@ async function main(): Promise { workspace: fixture('jsconfig'), tests: suiteDir('suite-jsconfig'), }, + { + name: 'Trace output channel tests', + workspace: fixture('jsconfig'), + tests: suiteDir('suite-trace'), + }, { name: 'Monorepo config tests', workspace: fixture('monorepo'), @@ -279,6 +284,11 @@ async function main(): Promise { workspace: fixture('eslint-plugins'), tests: suiteDir('suite-eslint-plugins'), }, + { + name: 'unicode-bom tests', + workspace: fixture('unicode-bom'), + tests: suiteDir('suite-unicode-bom'), + }, { name: 'Generated rule-option-types tests', workspace: fixture('rule-option-types'), diff --git a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts index cc43d1f..1b50b25 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts @@ -8,8 +8,8 @@ // `CONFIG_DISCOVERY_PROTOCOL_VERSION` as a constructor argument. The tests // inject the devDependency's constant; the fixtures currently pin the same // 0.9.0 release line. -// - The watch-glob test asserts upstream's glob is kept verbatim, lockfiles -// included. +// - The watch-glob test asserts upstream's current JS/TS config list is kept +// verbatim, with this extension's existing lockfile additions. import * as assert from 'node:assert'; import { @@ -34,6 +34,7 @@ import { RelativePattern, Uri, type DocumentFilter, + type OutputChannel, type WorkspaceFolder, } from 'vscode'; @@ -49,11 +50,31 @@ suite('initial config refresh retry classification', () => { name: 'second-root', uri: Uri.file('/workspace/second-root'), }; - const firstOptions = createLanguageClientOptions(firstFolder, undefined); - const secondOptions = createLanguageClientOptions(secondFolder, undefined); + const traceOutputChannel: OutputChannel = { + name: 'Rslint client-options test', + append() {}, + appendLine() {}, + replace() {}, + clear() {}, + show() {}, + hide() {}, + dispose() {}, + }; + const firstOptions = createLanguageClientOptions( + firstFolder, + undefined, + traceOutputChannel, + ); + const secondOptions = createLanguageClientOptions( + secondFolder, + undefined, + traceOutputChannel, + ); assert.strictEqual(firstOptions.workspaceFolder, firstFolder); assert.strictEqual(secondOptions.workspaceFolder, secondFolder); + assert.strictEqual(firstOptions.traceOutputChannel, traceOutputChannel); + assert.strictEqual(secondOptions.traceOutputChannel, traceOutputChannel); for (const [options, folder] of [ [firstOptions, firstFolder], [secondOptions, secondFolder], @@ -240,7 +261,7 @@ suite('LSP config discovery transactions', () => { assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.ts/); assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.mts/); assert.doesNotMatch(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.\*/); - assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.jsonc/); + assert.doesNotMatch(CONFIG_REFRESH_WATCH_GLOB, /rslint\.json/); assert.match(CONFIG_REFRESH_WATCH_GLOB, /pnpm-lock\.yaml/); assert.doesNotMatch(CONFIG_REFRESH_WATCH_GLOB, /\.gitignore/); assert.strictEqual( diff --git a/packages/vscode/e2e/lint/suite-trace/index.ts b/packages/vscode/e2e/lint/suite-trace/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-trace/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-trace/trace-output-channel.test.ts b/packages/vscode/e2e/lint/suite-trace/trace-output-channel.test.ts new file mode 100644 index 0000000..8a52418 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-trace/trace-output-channel.test.ts @@ -0,0 +1,327 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-jsconfig/trace-output-channel.test.ts` +// (origin/main). Intentional adaptations: +// - The LanguageClient id and setting section use this extension's +// `rstack.rslint` namespace. +// - Every client uses one in-memory `Rstack: Rslint` channel for ordinary logs +// and protocol traces, matching this extension's four-channel cap. The +// upstream assertion that traces avoid separate per-server log channels is +// therefore replaced by assertions against the one shared channel. +// - The raw test server uses this repo's existing direct `vscode-jsonrpc` +// dependency rather than adding upstream's `vscode-languageserver` test +// dependency. It observes the same initialize trace field and `$/setTrace` +// notifications. +import * as assert from 'node:assert'; +import { PassThrough } from 'node:stream'; +import { + ConfigurationTarget, + Uri, + workspace, + type OutputChannel, + type ViewColumn, + type WorkspaceFolder, +} from 'vscode'; +import { + LanguageClient, + State, + type StreamInfo, +} from 'vscode-languageclient/node'; +import { + createMessageConnection, + type MessageConnection, +} from 'vscode-jsonrpc/node'; +import { createLanguageClientOptions } from '../../../src/stacks/lint/Rslint'; + +type TraceValue = 'off' | 'messages' | 'verbose'; + +class MemoryOutputChannel implements OutputChannel { + public readonly name = 'Rstack: Rslint'; + public value = ''; + public disposeCalls = 0; + + public append(value: string): void { + this.value += value; + } + + public appendLine(value: string): void { + this.value += `${value}\n`; + } + + public replace(value: string): void { + this.value = value; + } + + public clear(): void { + this.value = ''; + } + + public show(preserveFocus?: boolean): void; + public show(column?: ViewColumn, preserveFocus?: boolean): void; + public show( + _columnOrPreserveFocus?: ViewColumn | boolean, + _preserveFocus?: boolean, + ): void {} + + public hide(): void {} + + public dispose(): void { + this.disposeCalls++; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isTraceValue(value: unknown): value is TraceValue { + return value === 'off' || value === 'messages' || value === 'verbose'; +} + +async function eventually( + predicate: () => boolean, + description: string, +): Promise { + const deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(`Timed out waiting for ${description}`); +} + +interface TraceClientHarness { + readonly client: LanguageClient; + readonly initialTrace: TraceValue | undefined; + readonly serverStarts: number; + readonly traceUpdates: TraceValue[]; + dispose(): Promise; +} + +function createTraceClientHarness( + name: string, + workspaceFolder: WorkspaceFolder, + sharedOutputChannel: OutputChannel, +): TraceClientHarness { + let serverStarts = 0; + let initialTrace: TraceValue | undefined; + const traceUpdates: TraceValue[] = []; + let connection: MessageConnection | undefined; + let clientInput: PassThrough | undefined; + let clientOutput: PassThrough | undefined; + const client = new LanguageClient( + 'rstack.rslint', + name, + async (): Promise => { + serverStarts++; + clientInput = new PassThrough(); + clientOutput = new PassThrough(); + connection = createMessageConnection(clientOutput, clientInput); + connection.onRequest((method, params) => { + if (method === 'initialize') { + initialTrace = + isRecord(params) && isTraceValue(params.trace) + ? params.trace + : undefined; + return { capabilities: {} }; + } + if (method === 'shutdown') return undefined; + throw new Error(`Unexpected request: ${method}`); + }); + connection.onNotification((method, params) => { + if (method !== '$/setTrace') return; + if (isRecord(params) && isTraceValue(params.value)) { + traceUpdates.push(params.value); + } + }); + connection.listen(); + return { reader: clientInput, writer: clientOutput }; + }, + createLanguageClientOptions( + workspaceFolder, + sharedOutputChannel, + sharedOutputChannel, + ), + ); + + return { + client, + traceUpdates, + get initialTrace() { + return initialTrace; + }, + get serverStarts() { + return serverStarts; + }, + async dispose(): Promise { + try { + await client.dispose(); + } finally { + connection?.dispose(); + clientInput?.destroy(); + clientOutput?.destroy(); + } + }, + }; +} + +suite('Rstack Rslint LSP trace', () => { + test('updates every runtime without replacing clients', async function () { + this.timeout(15_000); + const workspaceFolder = workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, 'test requires a workspace folder'); + const secondWorkspaceFolder: WorkspaceFolder = { + index: workspaceFolder.index + 1, + name: workspaceFolder.name, + uri: Uri.joinPath(workspaceFolder.uri, 'second-runtime'), + }; + const configuration = workspace.getConfiguration('rstack.rslint'); + const originalWorkspaceValue = + configuration.inspect('trace.server')?.workspaceValue; + const sharedOutputChannel = new MemoryOutputChannel(); + const harnesses = [ + createTraceClientHarness( + 'Rslint live trace test A', + workspaceFolder, + sharedOutputChannel, + ), + createTraceClientHarness( + 'Rslint live trace test B', + secondWorkspaceFolder, + sharedOutputChannel, + ), + // A second client for the same workspace models documents that resolve + // to another physical core installation within that workspace. + createTraceClientHarness( + 'Rslint live trace test C', + workspaceFolder, + sharedOutputChannel, + ), + ]; + + try { + await configuration.update( + 'trace.server', + 'off', + ConfigurationTarget.Workspace, + ); + await harnesses[0].client.start(); + assert.strictEqual(harnesses[0].initialTrace, 'off'); + assert.strictEqual(harnesses[0].serverStarts, 1); + assert.strictEqual(harnesses[1].serverStarts, 0); + assert.strictEqual(harnesses[2].serverStarts, 0); + + const outputLengthBeforeOffProbe = sharedOutputChannel.value.length; + await harnesses[0].client.sendNotification('rslint/traceProbe', { + phase: 'off', + }); + assert.strictEqual( + sharedOutputChannel.value.length, + outputLengthBeforeOffProbe, + ); + + await configuration.update( + 'trace.server', + 'messages', + ConfigurationTarget.Workspace, + ); + await eventually( + () => harnesses[0].traceUpdates.includes('messages'), + 'the messages trace notification for the running runtime', + ); + // A runtime created after the setting changed must inherit the current + // level in initialize rather than waiting for another settings event. + await Promise.all([ + harnesses[1].client.start(), + harnesses[2].client.start(), + ]); + for (const harness of harnesses.slice(1)) { + assert.strictEqual(harness.initialTrace, 'messages'); + assert.strictEqual(harness.serverStarts, 1); + } + await Promise.all( + harnesses.map(({ client }, index) => + client.sendNotification('rslint/traceProbe', { + phase: `messages-${String(index)}`, + }), + ), + ); + assert.ok( + sharedOutputChannel.value.split('rslint/traceProbe').length - 1 >= + harnesses.length, + 'every runtime should write protocol messages to the shared channel', + ); + + await configuration.update( + 'trace.server', + 'verbose', + ConfigurationTarget.Workspace, + ); + await eventually( + () => + harnesses.every(({ traceUpdates }) => + traceUpdates.includes('verbose'), + ), + 'the verbose trace notification for every runtime', + ); + await Promise.all( + harnesses.map(({ client }, index) => + client.sendNotification('rslint/traceProbe', { + phase: `verbose-payload-${String(index)}`, + }), + ), + ); + for (const index of harnesses.keys()) { + assert.ok( + sharedOutputChannel.value.includes( + `verbose-payload-${String(index)}`, + ), + ); + } + + await configuration.update( + 'trace.server', + 'off', + ConfigurationTarget.Workspace, + ); + await eventually( + () => + harnesses.every(({ traceUpdates }) => traceUpdates.at(-1) === 'off'), + 'the disabled trace notification for every runtime', + ); + const traceLengthAfterDisable = sharedOutputChannel.value.length; + await Promise.all( + harnesses.map(({ client }) => + client.sendNotification('rslint/traceProbe', { + phase: 'disabled-again', + }), + ), + ); + assert.strictEqual( + sharedOutputChannel.value.length, + traceLengthAfterDisable, + ); + for (const harness of harnesses) { + assert.strictEqual(harness.client.state, State.Running); + assert.strictEqual(harness.serverStarts, 1); + } + } finally { + let outputDisposeCallsAfterClientClose: number | undefined; + try { + await Promise.all(harnesses.map((harness) => harness.dispose())); + } finally { + outputDisposeCallsAfterClientClose = sharedOutputChannel.disposeCalls; + sharedOutputChannel.dispose(); + await configuration.update( + 'trace.server', + originalWorkspaceValue, + ConfigurationTarget.Workspace, + ); + } + assert.strictEqual( + outputDisposeCallsAfterClientClose, + 0, + 'language clients must not dispose the extension-owned channel', + ); + } + }); +}); diff --git a/packages/vscode/e2e/lint/suite-unicode-bom/index.ts b/packages/vscode/e2e/lint/suite-unicode-bom/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-unicode-bom/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-unicode-bom/unicode-bom.test.ts b/packages/vscode/e2e/lint/suite-unicode-bom/unicode-bom.test.ts new file mode 100644 index 0000000..f9fee1d --- /dev/null +++ b/packages/vscode/e2e/lint/suite-unicode-bom/unicode-bom.test.ts @@ -0,0 +1,154 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import fs from 'node:fs'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics, +} from '../utils/diagnostics'; +import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry'; + +// Intentional adaptation from upstream: issue #27 moves rule ids from the +// diagnostic message into VS Code's clickable diagnostic-code field. Matching +// that field below preserves the upstream no-var positive control and +// unicode-bom absence assertion after the message prefix is stripped. + +/** + * A byte order mark is the one thing a linter can see about a file that an + * editor's document cannot hold. VS Code decodes the file before handing it to + * the model — the mark becomes the document's *encoding*, shown in the status + * bar as "UTF-8 with BOM", never a character the text contains. + * + * So the language server does not run `unicode-bom` at all. The mark is not in + * the document, no text edit reaches it, and for an unsaved buffer the only + * remaining witness is the file on disk, which the buffer may already disagree + * with. `rslint --fix`, which rewrites the file itself, is where the rule + * applies. + * + * `src/marked.ts` is written here rather than committed: a file whose first + * bytes are EF BB BF is invisible in review and formatters keep wanting to + * strip it. The mark is only ever written as the escape `\uFEFF`, never as the + * character itself. + * + * The fixture also enables `no-var` and the source declares one, so every + * assertion of silence below runs against a file the server is demonstrably + * linting. + */ +suite('rslint unicode-bom over LSP', function () { + this.timeout(120000); + + const BOM = '\uFEFF'; + const markedSource = + 'export function marked() {\n var value = 1;\n return value;\n}\n'; + + function workspaceRoot(): string { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) throw new Error('VS Code test workspace is unavailable'); + return folder.uri.fsPath; + } + + function fixturePath(filename: string): string { + return path.join(workspaceRoot(), 'src', filename); + } + + async function openMarked(): Promise { + const doc = await vscode.workspace.openTextDocument( + fixturePath('marked.ts'), + ); + await vscode.window.showTextDocument(doc); + return doc; + } + + /** Waits for the pass that reports `no-var`, and returns everything it said. */ + async function lintedDiagnostics( + doc: vscode.TextDocument, + ): Promise { + return waitForRslintDiagnostics(doc, (all) => + all.some((d) => diagnosticRuleIdIncludes(d, 'no-var')), + ); + } + + suiteSetup(() => { + fs.writeFileSync(fixturePath('marked.ts'), BOM + markedSource, 'utf8'); + }); + + suiteTeardown(() => { + fs.rmSync(fixturePath('marked.ts'), { force: true }); + }); + + // ======== The constraint the behavior rests on ======== + + test('the file carries a mark that its editor document does not', async () => { + const bytes = fs.readFileSync(fixturePath('marked.ts')); + assert.deepStrictEqual( + [...bytes.subarray(0, 3)], + [0xef, 0xbb, 0xbf], + 'the fixture must start with the UTF-8 encoding of U+FEFF', + ); + + const doc = await openMarked(); + const text = doc.getText(); + + assert.ok( + !text.startsWith(BOM), + 'VS Code decodes the mark into the document encoding, so the model text ' + + 'must not contain U+FEFF. If this ever fails, an editor could see the ' + + 'mark after all and skipping the rule would be worth revisiting.', + ); + assert.strictEqual( + text, + markedSource, + 'the document should hold the source without the mark', + ); + }); + + // ======== The rule does not run in the editor ======== + + test('reports nothing about the mark on a marked file', async () => { + const doc = await openMarked(); + const diagnostics = await lintedDiagnostics(doc); + + assert.deepStrictEqual( + diagnostics + .filter((d) => diagnosticRuleIdIncludes(d, 'unicode-bom')) + .map((d) => d.message), + [], + `expected no unicode-bom diagnostic, got: ${diagnostics + .map((d) => d.message) + .join(' | ')}`, + ); + }); + + test('offers no code action for the mark', async () => { + const doc = await openMarked(); + await lintedDiagnostics(doc); + await waitForCodeActionRegistryQuiescence(); + + const before = doc.getText(); + const quickFixes = + (await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + doc.uri, + new vscode.Range(0, 0, doc.lineCount, 0), + vscode.CodeActionKind.QuickFix.value, + )) ?? []; + const titles = quickFixes.map((action) => action.title); + + assert.ok( + !titles.some( + (title) => + title.includes('unicode-bom') || title.includes('Unicode BOM'), + ), + `no unicode-bom action should be offered, got: ${titles.join(' | ')}`, + ); + // no-var's own actions are what make the absence above meaningful: quick + // fixes were computed for this file, and only unicode-bom's are missing. + assert.ok( + titles.includes('Disable no-var for this line'), + `expected no-var's actions to be present, got: ${titles.join(' | ')}`, + ); + + assert.strictEqual(doc.getText(), before, 'the document must be untouched'); + assert.ok(!doc.isDirty, 'nothing should have modified the document'); + }); +}); diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index df993d4..b931f9d 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -25,7 +25,6 @@ import { type Middleware, type ServerOptions, State, - Trace, } from 'vscode-languageclient/node'; import { configuredNodeBelowFloor, @@ -61,18 +60,11 @@ const LOCKFILE_NAMES = [ 'yarn.lock', ] as const; -/** - * Kept verbatim from upstream, JSON names included: they are not detection - * signals but watching them is harmless, and the Go server does load - * `rslint.json` from its cwd as a no-JS-config fallback in automatic mode. - */ const RSLINT_CONFIG_WATCH_NAMES = [ 'rslint.config.js', 'rslint.config.mjs', 'rslint.config.ts', 'rslint.config.mts', - 'rslint.json', - 'rslint.jsonc', ] as const; export const CONFIG_REFRESH_WATCH_GLOB = `**/{${[ @@ -108,6 +100,7 @@ export function shouldResetDocumentSessionOnServerState( export function createLanguageClientOptions( workspaceFolder: WorkspaceFolder, outputChannel: OutputChannel | undefined, + traceOutputChannel: OutputChannel, middleware?: Middleware, ): LanguageClientOptions { const documentSelector = createWorkspaceDocumentSelector(workspaceFolder); @@ -117,6 +110,10 @@ export function createLanguageClientOptions( // rslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion documentSelector as unknown as LanguageClientOptions['documentSelector'], outputChannel, + // vscode-languageclient reads rstack.rslint.trace.server and owns its + // initial and live $/setTrace updates. Supplying this unconditionally + // ensures enabling tracing after startup uses the shared Rslint channel. + traceOutputChannel, middleware, }; } @@ -412,13 +409,10 @@ export class Rslint implements Disposable { this.serverProcessOwner = serverProcessOwner; const serverOptions: ServerOptions = async () => serverProcessOwner.start(); - const traceServer = workspace - .getConfiguration('rstack.rslint', this.workspaceFolder.uri) - .get('trace.server', 'off'); - const traceEnabled = traceServer !== 'off'; const clientOptions = createLanguageClientOptions( this.workspaceFolder, this.outputChannel, + this.lspOutputChannel, this.router.createMiddleware(this), ); const errorHandlerHolder: { current?: ErrorHandler } = {}; @@ -440,12 +434,8 @@ export class Rslint implements Disposable { ); }, }; - if (traceEnabled) { - clientOptions.traceOutputChannel = this.lspOutputChannel; - } - const client = new ManagedLanguageClient( - 'rslint', + 'rstack.rslint', `Rslint Language Server (${this.workspaceFolder.name})`, serverOptions, clientOptions, @@ -506,13 +496,6 @@ export class Rslint implements Disposable { ); }); - if (traceEnabled) { - await client.setTrace( - traceServer === 'verbose' ? Trace.Verbose : Trace.Messages, - ); - this.assertStartCurrent(epoch, signal, client); - } - this.installConfigRefreshWatchers(mode); const retried = await retryConfigRefreshOnSourceChange( async () => this.requestConfigRefresh('initial'), diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index cc29e1b..74b4862 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -68,11 +68,7 @@ class RslintController implements StackController { * full restart pass (a stack must never rebuild itself), which also clears * the shared User Node preflight memo — an in-place reconcile would keep it. */ - readonly restartOnSettings = [ - NODE_EXECUTABLE_SETTING, - 'corePath', - 'trace.server', - ]; + readonly restartOnSettings = [NODE_EXECUTABLE_SETTING, 'corePath']; #context: StackContext | undefined; #logger: Logger | undefined; diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index c1e094d..ab4ae95 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -319,7 +319,7 @@ describe('restart-triggering settings', () => { harness.detected = new Set(['rslint', 'rstest', 'fmt']); harness.restartOnSettings = new Map([ ['rstest', ['nodeExecutable']], - ['rslint', ['rstack.nodeExecutable', 'corePath', 'trace.server']], + ['rslint', ['rstack.nodeExecutable', 'corePath']], ]); await activate(context); harness.events.length = 0; @@ -405,6 +405,12 @@ describe('restart-triggering settings', () => { expect(harness.events).toEqual([]); }); + it('leaves language-client trace changes to the running client', async () => { + changeSetting('rstack.rslint.trace.server'); + await settle(); + expect(harness.events).toEqual([]); + }); + it('ignores a declared name under another stack namespace', async () => { // The section is built as `rstack..`, so rslint's corePath // must not move rstest even though both are declared somewhere. From 59ade6c18f0fef5850535f3fbfe002011aa44190 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 3 Sep 2026 16:38:08 +0800 Subject: [PATCH 2/3] docs(vscode): record targeted upstream ports --- packages/vscode/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index b2520f3..d67b5d1 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -6,7 +6,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase. - The copies diverge from upstream in exactly nine ways (the "adaptations" below). When syncing upstream, preserve them. A tenth divergence is either a bug or must be added to this list. -- **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. +- **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. - **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. ## The nine adaptations From 0627884acaa693fbba5925f2bc31e00cd4c13069 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 3 Sep 2026 21:35:04 +0800 Subject: [PATCH 3/3] fix(vscode): scope rslint trace.server to the window --- packages/vscode/package.json | 2 +- packages/vscode/src/stacks/lint/Rslint.ts | 1 + packages/vscode/tests/extension.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 6cf9238..e13cfd8 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -166,7 +166,7 @@ "verbose" ], "default": "off", - "scope": "resource", + "scope": "window", "description": "Traces the communication between VS Code and the Rslint language server" } } diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index b931f9d..a41ad9e 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -113,6 +113,7 @@ export function createLanguageClientOptions( // vscode-languageclient reads rstack.rslint.trace.server and owns its // initial and live $/setTrace updates. Supplying this unconditionally // ensures enabling tracing after startup uses the shared Rslint channel. + // The setting is window-scoped because the client reads it without a URI. traceOutputChannel, middleware, }; diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index ab4ae95..6b50541 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -434,6 +434,28 @@ describe('restart-triggering settings', () => { }); }); +describe('the extension manifest', () => { + it('scopes language-client tracing to the window', () => { + const manifest = require('../package.json') as { + contributes: { + configuration: Array<{ + properties: Record; + }>; + }; + }; + const settings = Object.assign( + {}, + ...manifest.contributes.configuration.map(({ properties }) => properties), + ) as Record; + + // languageclient resolves trace.server from the client id without a + // resource URI, so resource scope would advertise a folder override that + // the running client never reads. + expect(settings['rstack.rslint.trace.server']?.scope).toBe('window'); + expect(settings['rstack.fmt.trace.server']?.scope).toBe('window'); + }); +}); + describe('the shell restart command', () => { beforeEach(async () => { harness.reset();