Skip to content

Commit c96aba9

Browse files
dmartinochoaclaude
andcommitted
fix: review-batch — restart race, semver compare, glob over-match, scan rejection paths
Three review rounds turned up these: - Concurrent-restart race in extension.ts where startClient read the module-level `client` after awaiting startup; a parallel stopClient could clear it before the post-start wiring ran. Captures the client in a local and checks identity after the await. - whatsNew lexicographic prerelease compare misranked rc.10 < rc.2 (so a user on rc.2 would miss the rc.10 upgrade toast). Replaced with semver §11.4 per-identifier compare (numeric segments compare numerically, longer set wins on tie). - providers.ts `**` glob emitted `.*` and crossed segment boundaries — `**/Dockerfile` would match `myDockerfile`. `**/` now translates to `(?:.*/)?`. - scanOnSave propagated scan rejections as unhandled promise rejections (onDidSaveTextDocument is fire-and-forget). Added optional `onError` hook wired to clientLog. - scanWorkspace command rejection now goes through a `runScanCommand` wrapper that logs and shows a Pipeline-Check-branded toast instead of VS Code's generic "Command failed". Housekeeping: - log.setLogChannel signature widened to accept `undefined` (drops the `as unknown as OutputChannel` test cast). - manifest.test.ts welcome-link regex now captures dotted command IDs. - workspaceScan and navigate test names/coverage updated to match what they actually test, with new sibling tests for the propagation paths (findScannableFiles rejection, two-finding strict-advance). - Test stub reset consistency in codeLens / findingsView (full resetStubState in beforeEach). - codeql.yml cleanup — drops template scaffolding, keeps the same three languages and the pinned action SHAs. - Two void-prefix consistency fixes on showInformationMessage in extension.ts. Unit tests: 245 → 254 (+9). Typecheck and lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9bda4b2 commit c96aba9

16 files changed

Lines changed: 321 additions & 145 deletions

.github/workflows/codeql.yml

Lines changed: 21 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,42 @@
1-
# For most projects, this workflow file will not need changing; you simply need
2-
# to commit it to your repository.
3-
#
4-
# You may wish to alter this file to override the set of languages analyzed,
5-
# or to provide custom queries or build logic.
6-
#
7-
# ******** NOTE ********
8-
# We have attempted to detect the languages in your repository. Please check
9-
# the `language` matrix defined below to confirm you have the correct set of
10-
# supported CodeQL languages.
11-
#
12-
name: "CodeQL Advanced"
1+
name: CodeQL
2+
3+
# Static analysis on push, PR, and a weekly schedule. The `actions`
4+
# language catches GHA-specific issues in this repo's own workflows;
5+
# `javascript-typescript` covers the extension source; `python`
6+
# covers scripts/gen_icon.py.
137

148
on:
159
push:
16-
branches: [ "main" ]
10+
branches: [main]
1711
pull_request:
18-
branches: [ "main" ]
12+
branches: [main]
1913
schedule:
2014
- cron: '45 3 * * 2'
2115

2216
jobs:
2317
analyze:
24-
timeout-minutes: 30
2518
name: Analyze (${{ matrix.language }})
26-
# Runner size impacts CodeQL analysis time. To learn more, please see:
27-
# - https://gh.io/recommended-hardware-resources-for-running-codeql
28-
# - https://gh.io/supported-runners-and-hardware-resources
29-
# - https://gh.io/using-larger-runners (GitHub.com only)
30-
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
31-
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
19+
timeout-minutes: 30
20+
runs-on: ubuntu-latest
3221
permissions:
33-
# required for all workflows
3422
security-events: write
35-
36-
# required to fetch internal or private CodeQL packs
3723
packages: read
38-
39-
# only required for workflows in private repositories
4024
actions: read
4125
contents: read
42-
4326
strategy:
4427
fail-fast: false
4528
matrix:
46-
include:
47-
- language: actions
48-
build-mode: none
49-
- language: javascript-typescript
50-
build-mode: none
51-
- language: python
52-
build-mode: none
53-
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
54-
# Use `c-cpp` to analyze code written in C, C++ or both
55-
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
56-
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
57-
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
58-
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
59-
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
60-
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
29+
language: [actions, javascript-typescript, python]
6130
steps:
62-
- name: Checkout repository
63-
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
64-
with:
65-
persist-credentials: false
66-
67-
# Add any setup steps before running the `github/codeql-action/init` action.
68-
# This includes steps like installing compilers or runtimes (`actions/setup-node`
69-
# or others). This is typically only required for manual builds.
70-
# - name: Setup runtime (example)
71-
# uses: actions/setup-example@v1
31+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
32+
with:
33+
persist-credentials: false
7234

73-
# Initializes the CodeQL tools for scanning.
74-
- name: Initialize CodeQL
75-
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
76-
with:
77-
languages: ${{ matrix.language }}
78-
build-mode: ${{ matrix.build-mode }}
79-
# If you wish to specify custom queries, you can do so here or in a config file.
80-
# By default, queries listed here will override any specified in a config file.
81-
# Prefix the list here with "+" to use these queries and those in the config file.
82-
83-
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
84-
# queries: security-extended,security-and-quality
85-
86-
# If the analyze step fails for one of the languages you are analyzing with
87-
# "We were unable to automatically build your code", modify the matrix above
88-
# to set the build mode to "manual" for that language. Then modify this step
89-
# to build your code.
90-
# ℹ️ Command-line programs to run using the OS shell.
91-
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
92-
- name: Run manual build steps
93-
if: matrix.build-mode == 'manual'
94-
shell: bash
95-
run: |
96-
echo 'If you are using a "manual" build mode for one or more of the' \
97-
'languages you are analyzing, replace this with the commands to build' \
98-
'your code, for example:'
99-
echo ' make bootstrap'
100-
echo ' make release'
101-
exit 1
35+
- uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
36+
with:
37+
languages: ${{ matrix.language }}
38+
build-mode: none
10239

103-
- name: Perform CodeQL Analysis
104-
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
105-
with:
106-
category: "/language:${{matrix.language}}"
40+
- uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
41+
with:
42+
category: "/language:${{ matrix.language }}"

src/__testStubs__/vscode.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ declare global {
8282
var __stubFindFilesByPattern:
8383
| Record<string, Array<{ toString: () => string; fsPath: string }>>
8484
| undefined;
85+
// When set, every `findFiles` call rejects with this error. Used by
86+
// the workspaceScan test that pins propagation of a pre-loop failure
87+
// (workspace closed mid-call, fs error) — the case where the scan
88+
// never reaches the per-file try/catch and the rejection escapes
89+
// scanWorkspace's outer try/finally.
90+
var __stubFindFilesError: Error | undefined;
8591
// What `workspace.workspaceFolders` should return for the duration
8692
// of a test. `undefined` mimics "no workspace open".
8793
var __stubWorkspaceFolders:
@@ -147,6 +153,7 @@ export function resetStubState(): void {
147153
globalThis.__stubDiagnostics = [];
148154
globalThis.__stubFindFiles = undefined;
149155
globalThis.__stubFindFilesByPattern = undefined;
156+
globalThis.__stubFindFilesError = undefined;
150157
globalThis.__stubWorkspaceFolders = undefined;
151158
globalThis.__stubOpenTextDocumentFailures = undefined;
152159
globalThis.__stubOpenTextDocumentGate = undefined;
@@ -311,6 +318,9 @@ export function vscodeStub(): Record<string, unknown> {
311318
findFiles: (include: string, exclude?: string, maxResults?: number) => {
312319
const calls = ensureCalls();
313320
calls.findFiles.push({ include, exclude, maxResults });
321+
if (globalThis.__stubFindFilesError) {
322+
return Promise.reject(globalThis.__stubFindFilesError);
323+
}
314324
const byPattern = globalThis.__stubFindFilesByPattern;
315325
if (byPattern) {
316326
return Promise.resolve(byPattern[include] ?? []);

src/codeLens.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ vi.mock("vscode", async () => {
55
return vscodeStub();
66
});
77

8+
import { resetStubState } from "./__testStubs__/vscode";
89
import {
910
FindingsCodeLensProvider,
1011
composeLensTitle,
@@ -137,6 +138,12 @@ describe("FindingsCodeLensProvider — pipelineCheck.codeLens.enabled toggle", (
137138
} as unknown as import("vscode").TextDocument;
138139

139140
beforeEach(() => {
141+
// Reset every shared global before seeding the slots this suite
142+
// actually reads. Resetting only `__stubDiagnostics` + `__stubConfig`
143+
// (the old pattern) leaves `__stubCalls.executeCommand` etc.
144+
// accumulating from neighbouring tests — fine today, fragile if a
145+
// future test in this file starts asserting on the call history.
146+
resetStubState();
140147
(globalThis as { __stubDiagnostics?: unknown }).__stubDiagnostics = [
141148
[
142149
{ toString: () => "file:///a.yml" },
@@ -154,7 +161,6 @@ describe("FindingsCodeLensProvider — pipelineCheck.codeLens.enabled toggle", (
154161
],
155162
],
156163
];
157-
(globalThis as { __stubConfig?: Record<string, unknown> }).__stubConfig = {};
158164
});
159165

160166
it("emits a lens when codeLens.enabled is true (default)", () => {

src/extension.ts

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,32 @@ async function changeGrouping(
8888
provider.setGroupMode(choice.mode);
8989
}
9090
}
91+
/**
92+
* Wrapper around `scanWorkspace()` for the two user-fired commands
93+
* (`Scan workspace` and `Refresh findings`). `scanWorkspace` re-throws
94+
* if `findScannableFiles` rejects (workspace closed mid-scan, fs error
95+
* before the loop is set up); without this wrapper, the command's
96+
* rejected promise lands as a generic "Command 'X' resulted in an
97+
* error" toast divorced from the click. The wrapper writes a real
98+
* breadcrumb to the log and shows a Pipeline-Check-branded toast so
99+
* the user can act on the failure.
100+
*
101+
* Per-file errors during the loop are already counted as `failed` and
102+
* surfaced through `formatSummary`; this path is strictly for failures
103+
* that prevent the scan from running at all.
104+
*/
105+
async function runScanCommand(): Promise<void> {
106+
try {
107+
await scanWorkspace();
108+
} catch (err) {
109+
const message = err instanceof Error ? err.message : String(err);
110+
clientLog.error(`scan: failed to start — ${message}`);
111+
void vscode.window.showErrorMessage(
112+
`Pipeline-Check: scan could not start — ${message}`,
113+
);
114+
}
115+
}
116+
91117
const LANGUAGE_ID = "pipelineCheck";
92118
const LANGUAGE_NAME = "Pipeline-Check";
93119
const OUTPUT_CHANNEL = "Pipeline-Check";
@@ -190,19 +216,38 @@ async function startClient(): Promise<void> {
190216
// this guard is the genuinely-duplicate case.
191217
return;
192218
}
193-
client = buildClient();
219+
// Capture the client we just built into a local. The module-level
220+
// `client` can be reassigned by a concurrent `stopClient()` while we
221+
// sit on the `await` below; reading off the local keeps the
222+
// post-start wiring referencing the SAME client we awaited, and lets
223+
// us cleanly detect the "ours got swapped out" case before touching
224+
// shared state.
225+
const local = buildClient();
226+
client = local;
194227
// The output channel is created by LanguageClient as a side effect of
195228
// construction, so it is always present here. Capture it before we
196229
// drop the broken client on failure (the "Open server log" action
197230
// below still needs to focus it to surface the server's traceback).
198-
const outputChannel: vscode.OutputChannel = client.outputChannel;
231+
const outputChannel: vscode.OutputChannel = local.outputChannel;
199232
// Point the client-side logger at the same channel the LSP server
200233
// writes to, so [client] and [server] lines interleave with shared
201234
// timestamps — much easier to read when triaging a bug report.
202235
clientLog.setLogChannel(outputChannel);
203236
try {
204237
clientLog.info("language server: starting");
205-
await startWithTimeout(client, START_TIMEOUT_MS);
238+
await startWithTimeout(local, START_TIMEOUT_MS);
239+
// Concurrent-restart race: if stopClient() ran while we were
240+
// awaiting, the module-level `client` was already reassigned (or
241+
// cleared). The LSP we just started is orphaned — best-effort kill
242+
// it so we don't leak a subprocess, and bail without flipping any
243+
// shared state that now belongs to a different client.
244+
if (client !== local) {
245+
clientLog.warn(
246+
"language server: start completed but client was swapped during startup; killing orphan",
247+
);
248+
void local.stop().catch(() => undefined);
249+
return;
250+
}
206251
clientLog.info("language server: started");
207252
setLspReady(true);
208253
// Watch the post-start lifecycle so a mid-session crash (server
@@ -213,7 +258,7 @@ async function startClient(): Promise<void> {
213258
// "Scan workspace" even though clicking it produces no findings.
214259
// The listener lives in module scope so stopClient can tear it
215260
// down before a restart builds a new one against the new client.
216-
clientStateChangeDisposable = client.onDidChangeState((event) => {
261+
clientStateChangeDisposable = local.onDidChangeState((event) => {
217262
if (event.newState === State.Stopped) {
218263
clientLog.warn("language server: state transitioned to stopped");
219264
setLspReady(false);
@@ -250,10 +295,14 @@ async function startClient(): Promise<void> {
250295
outputChannel.show();
251296
}
252297
});
253-
// Drop the broken client so a subsequent restart starts fresh
254-
// rather than trying to recover from a half-initialised state.
255-
client = undefined;
256-
setLspReady(false);
298+
// Only clear the module slot if it still points at OUR client. If
299+
// stopClient already swapped in a new one (concurrent restart),
300+
// clobbering would strand the new client. Same idea on the ready
301+
// flag — leave it alone unless we're the live client.
302+
if (client === local) {
303+
client = undefined;
304+
setLspReady(false);
305+
}
257306
}
258307
}
259308

@@ -337,15 +386,15 @@ export async function activate(
337386
// didOpen pipeline on each. Findings panel updates as the server
338387
// publishes; no extra state to manage.
339388
vscode.commands.registerCommand("pipelineCheck.scanWorkspace", () =>
340-
scanWorkspace(),
389+
runScanCommand(),
341390
),
342391
// "Refresh Findings" was historically a tree-only re-render. Now
343392
// that we have a real scan command, refresh runs an actual scan so
344393
// the button matches the user's mental model — clicking "refresh"
345394
// should fetch fresh data, not re-paint stale data. The tree
346395
// updates automatically as scan publishes arrive (R10).
347396
vscode.commands.registerCommand("pipelineCheck.findings.refresh", () =>
348-
scanWorkspace(),
397+
runScanCommand(),
349398
),
350399
vscode.commands.registerCommand(
351400
"pipelineCheck.findings.changeGrouping",
@@ -452,14 +501,14 @@ export async function activate(
452501
// If start failed it surfaced its own error toast; we'd otherwise
453502
// show "failed to start" and "restarted" at the same time.
454503
if (client) {
455-
vscode.window.showInformationMessage("Language server restarted.");
504+
void vscode.window.showInformationMessage("Language server restarted.");
456505
}
457506
}),
458507
vscode.commands.registerCommand("pipelineCheck.showLog", () => {
459508
if (client?.outputChannel) {
460509
client.outputChannel.show();
461510
} else {
462-
vscode.window.showInformationMessage(
511+
void vscode.window.showInformationMessage(
463512
"The language server is not running yet. Open a supported file " +
464513
"or run 'Pipeline-Check: Restart language server'.",
465514
);
@@ -498,6 +547,15 @@ export async function activate(
498547
return !disabled.includes(provider);
499548
},
500549
scan: () => scanWorkspace({ quiet: true }),
550+
// Surface a scan failure to the log instead of letting it bubble
551+
// out as an unhandled rejection. onDidSaveTextDocument doesn't
552+
// await its listener, so without this hook the only trace would
553+
// be a generic extension-host error nobody connects back to a
554+
// save event — the log line names the symptom clearly.
555+
onError: (err) => {
556+
const msg = err instanceof Error ? err.message : String(err);
557+
clientLog.error(`scan-on-save: scan failed — ${msg}`);
558+
},
501559
});
502560
context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(onSave));
503561

src/findingsView.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ vi.mock("vscode", async () => {
1010
});
1111

1212
// Import after the mock is registered.
13+
import { resetStubState } from "./__testStubs__/vscode";
1314
import { FindingsTreeProvider } from "./findingsView";
1415

1516
const ctx = {
@@ -65,7 +66,12 @@ function setStubDiagnostics(findings: FakeFinding[]): void {
6566
}
6667

6768
beforeEach(() => {
68-
(globalThis as { __stubDiagnostics?: unknown }).__stubDiagnostics = [];
69+
// Full reset (not just `__stubDiagnostics`) so the shared `__stubCalls`
70+
// — populated by every FindingsTreeProvider constructor via the
71+
// `setContext` executeCommand — doesn't accumulate across tests.
72+
// Currently no test asserts on that history; the reset keeps a
73+
// future assertion honest.
74+
resetStubState();
6975
});
7076

7177
describe("FindingsTreeProvider — collection from diagnostics", () => {

src/log.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,9 @@ function fakeChannel() {
3535

3636
beforeEach(() => {
3737
// Reset the module-scope channel so a test that doesn't call
38-
// setLogChannel can verify the no-op path.
39-
setLogChannel(
40-
undefined as unknown as import("vscode").OutputChannel,
41-
);
38+
// setLogChannel can verify the no-op path. The signature now
39+
// accepts `undefined` directly — no `as unknown as` cast needed.
40+
setLogChannel(undefined);
4241
});
4342

4443
describe("formatTimestamp", () => {

src/log.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ let channel: vscode.OutputChannel | undefined;
1717
* Set the output channel logs are written to. Called once from
1818
* activate() after the LanguageClient has constructed its channel,
1919
* so client logs and server logs share the same surface.
20+
*
21+
* Accepts `undefined` so a test (or any future caller that needs to
22+
* reset state) can detach without an `as unknown as OutputChannel`
23+
* cast. The module already treated a missing channel as a no-op; the
24+
* signature now documents that explicitly.
2025
*/
21-
export function setLogChannel(c: vscode.OutputChannel): void {
26+
export function setLogChannel(c: vscode.OutputChannel | undefined): void {
2227
channel = c;
2328
}
2429

0 commit comments

Comments
 (0)