From 4ba4a049d5e008b15bec2efc1485f6c3fb26e4cc Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 10:27:12 +0200 Subject: [PATCH 01/11] docs(roadmap): add review-pass findings (R1-R29) In-depth review queued 29 follow-up items grouped by category: code fixes, performance, UX gaps, architecture, testing, marketplace, CI, and strategic. Items already covered elsewhere in this roadmap are not repeated. Co-Authored-By: Claude Opus 4.7 (1M context) --- ROADMAP.md | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 3051f2a..a666534 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -391,3 +391,105 @@ on their own. The 50px of vertical space we'd save up front is small relative to the structural cost of having to add the header back the first time we want a second view. + +--- + +## Review pass (2026-05-19) — improvements from in-depth code review + +The findings below came out of a holistic review of the codebase after +v0.1.1 shipped. Categories cluster related work into reviewable PRs. + +### Code-level fixes (cheap wins) + +- [ ] **R1** Reorder the `filterByThreshold` import in + [src/extension.ts:72](src/extension.ts#L72) up to the rest of the + import block. Currently sits after a function declaration. +- [ ] **R2** "Restart language server" toast at + [src/extension.ts:223](src/extension.ts#L223) fires even when + `startClient()` failed. Guard with `if (client)`. +- [ ] **R3** Add a timeout to `stopClient()` + ([src/extension.ts:185-192](src/extension.ts#L185-L192)) — + a deadlocked LSP child holds the deactivate path indefinitely. +- [ ] **R4** `groupByFile` does `key = f.uri.toString()` then + `vscode.Uri.parse(key)` to get the Uri back + ([src/findingsView.ts:357-385](src/findingsView.ts#L357-L385)). + Use a `Map` keyed on the Uri directly. +- [ ] **R5** `compareByLocation` + ([src/findingsView.ts:413](src/findingsView.ts#L413)) sorts on + `uri.toString()` which includes the `file://` scheme prefix. + Sort on `fsPath` instead. + +### Performance + +- [ ] **R6** Cache `collectFindings()` per refresh. Currently called + twice per refresh (`buildRoot` + `updateBadge`); each walks every + diagnostic in the workspace. +- [ ] **R7** Filter `onDidChangeDiagnostics` by the event's `uris` + payload — skip the refresh when none of the changed URIs carry a + pipeline-check diagnostic. + +### UX gaps + +- [ ] **R8** Wire `Diagnostic.code.target` into the leaf tooltip. The + server publishes the rule's docs URL; we read `code.value` and + throw the URL away. Add a docs link at the bottom of the leaf + tooltip (set `isTrusted = true` on the `MarkdownString`). +- [ ] **R9** Status bar item showing critical/high counts. Click + reveals the Findings panel. +- [ ] **R10** Rename `pipelineCheck.findings.refresh` — re-renders + from current diagnostics, doesn't trigger a fresh scan. Once + scan-workspace lands, have refresh call `scanWorkspace()`. +- [ ] **R11** `CodeAction` provider for suppression comments. + Right-click a finding → "Suppress GHA-001 for this line" that + inserts the CLI's suppression syntax. +- [ ] **R12** "Go to next/previous finding" commands with keybindings. +- [ ] **R13** Set `Diagnostic.tags` for `Deprecated` / + `Unnecessary` where the rule indicates it. Server-side change. + +### Architecture + +- [ ] **R14** Extract the candidate-file pattern list from + `activationEvents`, `documentSelector`, and (post-merge) + `SCAN_PATTERNS` into a single shared TS module. Today the list + lives in three places. +- [ ] **R15** Add `onCommand:pipelineCheck.scanWorkspace` to + `activationEvents` so opening an isolated file from outside the + workspace can still trigger activation. +- [ ] **R16** Client-side structured logging into the output channel + with a `[client]` prefix. Breadcrumbs for bug reports. + +### Testing + +- [ ] **R17** `@vscode/test-electron` integration tests covering real + LSP publish and tree render. +- [ ] **R18** Extract the `vi.mock("vscode", ...)` factory into a + shared `src/__testStubs__/vscode.ts`. + +### Marketplace + +- [ ] **R19** **Ship the screenshots** the HTML comment at + [README.md:13-25](README.md#L13-L25) has been waiting for since + v0.1.0. Highest-leverage marketplace conversion improvement. +- [ ] **R20** CI check that `package.json#description` stays ≤ 145 + characters (the marketplace-search truncation limit). + +### CI / release + +- [ ] **R21** Test matrix: + `[ubuntu-latest, windows-latest, macos-latest]`. The + `LF → CRLF` warnings on every git operation hint at the bugs. +- [ ] **R22** Finish the eslint flat-config migration so the + drift between eslint v8 and TS 6 / esbuild 0.28 stops widening. +- [ ] **R23** Resolve the CodeQL default-setup conflict — disable + default setup or delete codeql.yml. Don't ship with a + permanently-red required check. +- [ ] **R24** `vsce publish --pre-release` channel for the next + minor bump. + +### Strategic + +- [ ] **R25** Per-provider toggles in settings. +- [ ] **R26** Inline `CodeLens` summarising findings per file. +- [ ] **R27** Workspace-level config file shared with the CLI. +- [ ] **R28** Opt-in telemetry (`vscode.env.isTelemetryEnabled`). +- [ ] **R29** Scan-on-save mode. From 937ac8397f9eaac112fcbd64149057948ecc325e Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 10:35:47 +0200 Subject: [PATCH 02/11] review-followups: code fixes, perf, docs links, status bar, CI matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the cheap-and-high-value subset of the post-v0.1.1 review pass. R1 — extension.ts: move the filterByThreshold import up to the rest of the import block. Was sitting after a function declaration as a merge artefact. R2 — extension.ts: "Restart language server" toast no longer fires when startClient failed. The catch path already surfaces its own error notification; the success toast now gates on `if (client)`. R3 — extension.ts: stopClient races local.stop() against a 2-second timer (STOP_TIMEOUT_MS) and dispose()s the client on timeout. A deadlocked LSP child used to hold the deactivate path indefinitely and VS Code reported "Window not responding". R4 — findingsView.ts: groupByFile drops the Uri.parse round-trip. Bucket value now carries the original Uri alongside the items array. R5 — findingsView.ts: compareByLocation sorts on fsPath instead of the full URI string. Cross-scheme entries (file:// vs untitled://) no longer bunch at one end of the tree. R6 — findingsView.ts: collectFindings memoised behind a per-refresh cache. buildRoot and updateBadge used to walk the workspace diagnostic store twice per refresh; now both read from the same walk. cachedFindings is invalidated in refresh(). R7 — findingsView.ts: onDidChangeDiagnostics handler now early-outs when the batch's URIs neither carry a pipeline-check diagnostic nor appeared in the last finding set. ESLint / mypy / redhat.yaml keystroke chatter no longer wakes up the tree rebuild. The lastFindingUris set carries the URIs we last had findings for so *clears* are detected too (a stale leaf can't outlive a cleared file). R8 — findingsView.ts: leaf tooltip now appends a "$(book) RULE documentation" link when the server publishes Diagnostic.code.target. MarkdownString gets isTrusted=true so the link is clickable inside a TreeItem tooltip. composeLeafTooltip extracted as a function for unit testing. R9 — statusBar.ts: new module. Adds a left-side status bar item showing per-severity counts (e.g. "$(shield) 3C 1H"). Click reveals the Findings panel via the auto-generated pipelineCheck.findings.focus command. Updates on every onDidChangeDiagnostics; pure helpers (formatStatusBarText, formatStatusBarTooltip, countDiagnostics) live on the module so the tests pin the copy without booting VS Code. R21 — ci.yml: matrix over [ubuntu-latest, windows-latest, macos-latest]. npm audit and the vsix upload stay pinned to Linux (network-bound / would otherwise collide on identical names). fail-fast: false so a Windows-only failure doesn't kill the macOS and Linux runs. Tests: - findingsView.test.ts: MarkdownString stub gains appendMarkdown / isTrusted / supportThemeIcons. getDiagnostics stub handles both the zero-arg (all pairs) and uri-arg (just that URI) forms used by R7's batch-touches-us check. - findingsView.test.ts: +3 tests covering R8 (link appended when docsUrl present, tooltip clean when absent) and R6 (refresh() picks up newly-published diagnostics). - statusBar.test.ts: new file. 13 tests across formatStatusBarText (clean / critical-first / high-pair / collapse-to-total), formatStatusBarTooltip (no findings / breakdown / singular), and countDiagnostics (source filter / tally / INFO fallback / lowercase normalise). Total: 53 tests pass; lint + smoke clean. Three-OS CI verifies on each push. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 21 +++++- src/extension.ts | 38 +++++++++- src/findingsView.test.ts | 115 ++++++++++++++++++++++++++-- src/findingsView.ts | 134 +++++++++++++++++++++++++++++---- src/statusBar.test.ts | 145 +++++++++++++++++++++++++++++++++++ src/statusBar.ts | 159 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 584 insertions(+), 28 deletions(-) create mode 100644 src/statusBar.test.ts create mode 100644 src/statusBar.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee7bb80..6af70e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,17 @@ permissions: jobs: check: - runs-on: ubuntu-latest + # The extension is cross-platform; the LSP child-process spawn + # path, the bundle loader, and several file-path helpers are all + # Windows-sensitive. Running the gate on three OSes catches the + # LF/CRLF and path-separator bugs that single-OS CI silently + # ignores. The matrix shares the same step list — only the vsix + # upload and the network-bound npm audit are pinned to Linux. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 @@ -45,16 +55,19 @@ jobs: run: npm run smoke - name: npm audit (prod deps, high+) + # Network-bound and platform-independent; one run is enough. + if: matrix.os == 'ubuntu-latest' run: npm audit --omit=dev --audit-level=high - name: Verify vsix packs cleanly # vsce is a pinned devDependency (see package.json) — Dependabot # bumps it via the npm config. `npm ci` has already installed it. - run: | - npx vsce package --out pipeline-check.vsix - ls -lh pipeline-check.vsix + run: npx vsce package --out pipeline-check.vsix - uses: actions/upload-artifact@v7 + # Single artefact upload from the Linux job; identical-name + # uploads from the matrix would collide. + if: matrix.os == 'ubuntu-latest' with: name: vsix path: pipeline-check.vsix diff --git a/src/extension.ts b/src/extension.ts index 32ad27f..d14cd0b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,8 @@ import { TransportKind, } from "vscode-languageclient/node"; import { FindingsTreeProvider, GroupMode } from "./findingsView"; +import { filterByThreshold } from "./severityFilter"; +import { registerStatusBar } from "./statusBar"; // Group-mode options offered by the Findings panel's "Change // Grouping" button. Labels are user-facing; descriptions are the @@ -69,8 +71,6 @@ async function changeGrouping( provider.setGroupMode(choice.mode); } } -import { filterByThreshold } from "./severityFilter"; - const LANGUAGE_ID = "pipelineCheck"; const LANGUAGE_NAME = "Pipeline-Check"; const OUTPUT_CHANNEL = "Pipeline-Check"; @@ -182,13 +182,34 @@ async function startClient(): Promise { } } +// Hard ceiling on how long deactivate / restart waits for the LSP +// child to shut down cleanly. A deadlocked server would otherwise +// hold the deactivate path indefinitely and VS Code reports "Window +// not responding". +const STOP_TIMEOUT_MS = 2000; + async function stopClient(): Promise { if (!client) { return; } const local = client; client = undefined; - await local.stop(); + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + local.stop(), + new Promise((resolve) => { + timer = setTimeout(resolve, STOP_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + // If stop() didn't win the race the client is stranded; dispose + // explicitly so its subscriptions don't outlive us. + local.dispose?.(); + } } export async function activate( @@ -208,6 +229,10 @@ export async function activate( // badge. Handing the view back closes the loop and triggers an // initial badge update. findingsProvider.setTreeView(findingsView); + // Status bar item lives at the bottom-left and shows the per- + // severity tally. Click reveals the Findings panel. registerStatusBar + // pushes the item onto context.subscriptions internally. + registerStatusBar(context); context.subscriptions.push( findingsView, vscode.commands.registerCommand("pipelineCheck.findings.refresh", () => @@ -220,7 +245,12 @@ export async function activate( vscode.commands.registerCommand("pipelineCheck.restart", async () => { await stopClient(); await startClient(); - vscode.window.showInformationMessage("Language server restarted."); + // Only confirm success when startClient left a live client behind. + // If start failed it surfaced its own error toast; we'd otherwise + // show "failed to start" and "restarted" at the same time. + if (client) { + vscode.window.showInformationMessage("Language server restarted."); + } }), vscode.commands.registerCommand("pipelineCheck.showLog", () => { if (client?.outputChannel) { diff --git a/src/findingsView.test.ts b/src/findingsView.test.ts index 2830242..a98d150 100644 --- a/src/findingsView.test.ts +++ b/src/findingsView.test.ts @@ -49,7 +49,13 @@ vi.mock("vscode", () => { ) {} } class MarkdownString { - constructor(public readonly value: string) {} + isTrusted = false; + supportThemeIcons = false; + constructor(public value: string) {} + appendMarkdown(s: string): this { + this.value += s; + return this; + } } const Uri = { parse: (s: string) => { @@ -75,9 +81,26 @@ vi.mock("vscode", () => { uri.fsPath ?? uri.path ?? "", }, languages: { - getDiagnostics: () => - (globalThis as { __stubDiagnostics?: unknown[] }).__stubDiagnostics ?? - [], + // Two call shapes: + // - `getDiagnostics()` returns every [uri, diagnostic[]] pair + // - `getDiagnostics(uri)` returns just that uri's diagnostics + // The provider's batch-skip path uses the second form; the + // collection path uses the first. + getDiagnostics: (uri?: { toString: () => string }) => { + const all = + ( + globalThis as { + __stubDiagnostics?: Array<[ + { toString: () => string }, + unknown[], + ]>; + } + ).__stubDiagnostics ?? []; + if (uri === undefined) return all; + const key = uri.toString(); + const match = all.find(([u]) => u.toString() === key); + return match ? match[1] : []; + }, onDidChangeDiagnostics: () => ({ dispose: () => undefined }), }, commands: { executeCommand: () => Promise.resolve() }, @@ -99,6 +122,7 @@ type FakeFinding = { rule: string; severity?: string; line?: number; + docsUrl?: string; }; function setStubDiagnostics(findings: FakeFinding[]): void { @@ -109,7 +133,10 @@ function setStubDiagnostics(findings: FakeFinding[]): void { arr.push({ source: "pipeline-check", message: `${f.rule} title\n\nThe long description.\n\nFix: do X.`, - code: { value: f.rule, target: { toString: () => "" } }, + code: { + value: f.rule, + target: { toString: () => f.docsUrl ?? "" }, + }, range: { start: { line: f.line ?? 0, character: 0 }, end: { line: f.line ?? 0, character: 0 }, @@ -481,3 +508,81 @@ describe("FindingsTreeProvider — group mode behaviour", () => { expect(p.getGroupMode()).toBe("file"); }); }); + +describe("FindingsTreeProvider — rule docs link in tooltip", () => { + // When the server publishes ``Diagnostic.code.target`` (the rule's + // documentation URL), the leaf tooltip should carry a clickable + // "Read more" link below the message body. When the URL is absent + // or empty, the tooltip is just the message. The link target is + // exactly what the server published — we don't synthesise URLs. + + it("appends a docs link when the server publishes one", () => { + setStubDiagnostics([ + { + file: "a.yml", + rule: "GHA-001", + severity: "HIGH", + docsUrl: "https://example.com/rules/gha-001", + }, + ]); + const p = new FindingsTreeProvider(ctx); + p.setGroupMode("severity"); + const roots = p.getChildren(); + const leaves = p.getChildren(roots[0]); + const item = p.getTreeItem(leaves[0]); + const tip = item.tooltip as { value: string; isTrusted: boolean }; + expect(tip.value).toContain("GHA-001 title"); + expect(tip.value).toContain( + "[$(book) GHA-001 documentation](https://example.com/rules/gha-001)", + ); + expect(tip.isTrusted).toBe(true); + }); + + it("leaves the tooltip clean when the server publishes no URL", () => { + setStubDiagnostics([ + { + file: "a.yml", + rule: "GHA-001", + severity: "HIGH", + // no docsUrl + }, + ]); + const p = new FindingsTreeProvider(ctx); + p.setGroupMode("severity"); + const roots = p.getChildren(); + const leaves = p.getChildren(roots[0]); + const item = p.getTreeItem(leaves[0]); + const tip = item.tooltip as { value: string }; + expect(tip.value).toContain("GHA-001 title"); + expect(tip.value).not.toContain("documentation"); + }); +}); + +describe("FindingsTreeProvider — findings cache invalidation", () => { + // refresh() drops the cached findings list so the next render sees + // any new diagnostic publishes. Without invalidation, a freshly + // published finding wouldn't appear in the tree until the user + // toggled the group mode or restarted VS Code. The test exercises + // the path end-to-end: render once, swap the stub data, refresh, + // render again. + + it("refresh() picks up newly-published diagnostics", () => { + setStubDiagnostics([ + { file: "a.yml", rule: "GHA-001", severity: "HIGH" }, + ]); + const p = new FindingsTreeProvider(ctx); + p.setGroupMode("severity"); + expect(p.getChildren()).toHaveLength(1); + + setStubDiagnostics([ + { file: "a.yml", rule: "GHA-001", severity: "HIGH" }, + { file: "b.yml", rule: "GHA-002", severity: "CRITICAL" }, + ]); + // Without refresh() the second publish would be invisible. + p.refresh(); + const roots = p.getChildren(); + expect(roots).toHaveLength(2); + // CRITICAL is leftmost in the sort. + expect(roots[0].kind === "group" && roots[0].label).toBe("CRITICAL"); + }); +}); diff --git a/src/findingsView.ts b/src/findingsView.ts index 59c2127..a87318c 100644 --- a/src/findingsView.ts +++ b/src/findingsView.ts @@ -71,6 +71,10 @@ type Finding = { readonly diagnostic: vscode.Diagnostic; readonly severity: SeverityName; readonly ruleId: string; + // The rule's documentation URL, when the server published one via + // ``Diagnostic.code.target``. Used to render a "Read more" link in + // the leaf tooltip — same prose the CLI's `--explain` shows. + readonly docsUrl: string | undefined; }; type GroupNode = { @@ -103,14 +107,30 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider { // makes the pre-wiring window explicit — refresh() called before // setTreeView simply skips the badge update. private treeView: vscode.TreeView | undefined; + // Memoised result of ``collectFindings()``. A single refresh used to + // walk the global diagnostic store twice (once from buildRoot, once + // from updateBadge); now both paths read from this cache and one + // walk is enough. ``refresh()`` invalidates by setting it to null. + private cachedFindings: Finding[] | null = null; + // URIs that carried a pipeline-check diagnostic at the last refresh. + // Used to detect *clears*: when a URI in this set shows up in an + // onDidChangeDiagnostics batch with no remaining pipeline-check + // diagnostic, we still need to refresh so the stale leaf vanishes. + private lastFindingUris = new Set(); constructor(context: vscode.ExtensionContext) { // VS Code does not expose a per-source filter on the diagnostic- - // change event, so we re-render on every publish. collectFindings - // re-filters by source, so the rendered tree only changes when a - // pipeline-check publish actually arrives. + // change event, so we have to subscribe to all of them and bounce + // the ones we don't care about. We DO get the list of changed URIs + // on the event, so the early-out below skips the tree rebuild when + // no pipeline-check publish landed in this batch — keystroke-rate + // ESLint / mypy / redhat.yaml chatter no longer wakes us up. context.subscriptions.push( - vscode.languages.onDidChangeDiagnostics(() => this.refresh()), + vscode.languages.onDidChangeDiagnostics((e) => { + if (this.batchTouchesUs(e.uris)) { + this.refresh(); + } + }), ); // Seed the context key the title-bar menu reads to highlight the // active group mode. Must run after the view is registered to @@ -146,10 +166,49 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider { } refresh(): void { + // Drop the memo so the next read sees the fresh diagnostics. + this.cachedFindings = null; this._onDidChangeTreeData.fire(); this.updateBadge(); } + /** + * Cached accessor used everywhere a refresh path needs the current + * findings. Walks the workspace diagnostic store once per refresh + * instead of once per consumer, and rebuilds the "URIs we had + * findings for" set so the next batch-skip check has fresh data. + */ + private findings(): Finding[] { + if (this.cachedFindings === null) { + this.cachedFindings = collectFindings(); + this.lastFindingUris = new Set( + this.cachedFindings.map((f) => f.uri.toString()), + ); + } + return this.cachedFindings; + } + + /** + * Returns true if any of the changed URIs in this batch either + * carries a pipeline-check diagnostic right now (publish or update) + * or carried one at the last refresh (clear). The second branch is + * what stops a stale leaf from outliving a cleared file. + */ + private batchTouchesUs(uris: readonly vscode.Uri[]): boolean { + for (const uri of uris) { + if (this.lastFindingUris.has(uri.toString())) { + return true; + } + const diags = vscode.languages.getDiagnostics(uri); + for (const d of diags) { + if (d.source === DIAGNOSTIC_SOURCE) { + return true; + } + } + } + return false; + } + getTreeItem(node: TreeNode): vscode.TreeItem { if (node.kind === "group") { const item = new vscode.TreeItem( @@ -189,7 +248,7 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider { ); item.iconPath = SEVERITY_ICON[f.severity]; item.description = composeLeafDescription(f, this.groupMode); - item.tooltip = new vscode.MarkdownString(f.diagnostic.message); + item.tooltip = composeLeafTooltip(f); item.command = { command: "vscode.open", title: "Reveal finding", @@ -207,7 +266,7 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider { } private buildRoot(): TreeNode[] { - const all = collectFindings(); + const all = this.findings(); if (all.length === 0) { return []; } @@ -225,7 +284,7 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider { if (!this.treeView) { return; } - const total = collectFindings().length; + const total = this.findings().length; this.treeView.badge = total === 0 ? undefined @@ -246,6 +305,23 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider { // // The ``file.yml:23`` form mirrors what compilers emit and what // VS Code's status bar uses — familiar at a glance. +function composeLeafTooltip(f: Finding): vscode.MarkdownString { + // The server composes the message as "title\n\ndescription\n\nFix: ...". + // Markdown renders the paragraph rhythm; we append a docs link + // below it when the server published one. The MarkdownString must + // be ``isTrusted = true`` for the link to be clickable inside a + // TreeItem tooltip. + const md = new vscode.MarkdownString(f.diagnostic.message); + if (f.docsUrl) { + md.appendMarkdown( + `\n\n[$(book) ${f.ruleId || "Rule"} documentation](${f.docsUrl})`, + ); + } + md.isTrusted = true; + md.supportThemeIcons = true; + return md; +} + function composeLeafDescription(f: Finding, mode: GroupMode): string { const line = f.diagnostic.range.start.line + 1; const fileRef = `${basenameFromUri(f.uri)}:${line}`; @@ -278,12 +354,31 @@ function collectFindings(): Finding[] { diagnostic: diag, severity: readSeverity(diag), ruleId: readRuleId(diag), + docsUrl: readDocsUrl(diag), }); } } return out; } +function readDocsUrl(diag: vscode.Diagnostic): string | undefined { + // ``Diagnostic.code.target`` is the rule's documentation URL when + // ``codeDescription.href`` is set on the server side. We surface it + // as a "Read more" link in the leaf tooltip so the editor closes + // the loop on the marketplace copy that promises "hover descriptions + // with --explain prose". Anything not a URL-shaped object falls + // through and the link is just omitted from the tooltip. + if ( + diag.code && + typeof diag.code === "object" && + "target" in diag.code && + diag.code.target + ) { + return diag.code.target.toString(); + } + return undefined; +} + function readSeverity(diag: vscode.Diagnostic): SeverityName { // diagnostics.py stuffs the upstream severity NAME into // ``Diagnostic.data["severity"]``. vscode-languageclient passes @@ -354,17 +449,23 @@ function groupBySeverity(findings: readonly Finding[]): GroupNode[] { } function groupByFile(findings: readonly Finding[]): GroupNode[] { - const buckets = new Map(); + // Key the bucket on the stringified URI so a Map keeps stable lookup + // (vscode.Uri values from different findings can be distinct objects + // even when they point at the same file), but also store the original + // Uri so we don't have to parse it back out for the group node. + const buckets = new Map(); for (const f of findings) { const key = f.uri.toString(); - const arr = buckets.get(key) ?? []; - arr.push(f); - buckets.set(key, arr); + const bucket = buckets.get(key); + if (bucket) { + bucket.items.push(f); + } else { + buckets.set(key, { uri: f.uri, items: [f] }); + } } return [...buckets] .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, items]): GroupNode => { - const uri = vscode.Uri.parse(key); + .map(([, { uri, items }]): GroupNode => { items.sort(compareByLocation); // Parent dir moves to the tooltip so the description column // stays count-only across all three group modes — a uniform @@ -411,8 +512,11 @@ function groupByRule(findings: readonly Finding[]): GroupNode[] { } function compareByLocation(a: Finding, b: Finding): number { - const lhs = a.uri.toString(); - const rhs = b.uri.toString(); + // Sort on fsPath instead of the full Uri string so cross-scheme + // entries (file:// vs vscode-untitled:// etc.) don't bunch + // pathologically at one end. Within the same file, line order wins. + const lhs = a.uri.fsPath; + const rhs = b.uri.fsPath; if (lhs !== rhs) { return lhs.localeCompare(rhs); } diff --git a/src/statusBar.test.ts b/src/statusBar.test.ts new file mode 100644 index 0000000..b8d5a48 --- /dev/null +++ b/src/statusBar.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi } from "vitest"; + +// statusBar.ts imports `vscode` for the runtime wiring; the pure +// helpers (formatStatusBarText, formatStatusBarTooltip, +// countDiagnostics) don't touch it but the module-level import has to +// resolve. Tiny stub covers it. +vi.mock("vscode", () => ({ + StatusBarAlignment: { Left: 1, Right: 2 }, + window: {}, + languages: {}, +})); + +import { + countDiagnostics, + formatStatusBarText, + formatStatusBarTooltip, +} from "./statusBar"; + +// Helpers +const make = (sev?: string) => ({ + source: "pipeline-check", + message: "x", + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + severity: 0, + data: sev ? { severity: sev } : undefined, +}); + +describe("formatStatusBarText", () => { + it("returns 'clean' when there are no findings", () => { + expect( + formatStatusBarText({ CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 }), + ).toBe("$(shield) clean"); + }); + + it("leads with critical count when present", () => { + expect( + formatStatusBarText({ CRITICAL: 3, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 }), + ).toBe("$(shield) 3C"); + }); + + it("pairs critical with high when both present", () => { + expect( + formatStatusBarText({ CRITICAL: 3, HIGH: 1, MEDIUM: 9, LOW: 0, INFO: 0 }), + ).toBe("$(shield) 3C 1H"); + }); + + it("shows high alone when no critical", () => { + expect( + formatStatusBarText({ CRITICAL: 0, HIGH: 4, MEDIUM: 0, LOW: 0, INFO: 0 }), + ).toBe("$(shield) 4H"); + }); + + it("pairs high with medium when no critical", () => { + expect( + formatStatusBarText({ CRITICAL: 0, HIGH: 4, MEDIUM: 2, LOW: 9, INFO: 9 }), + ).toBe("$(shield) 4H 2M"); + }); + + it("collapses to a total when only medium/low/info present", () => { + expect( + formatStatusBarText({ CRITICAL: 0, HIGH: 0, MEDIUM: 2, LOW: 3, INFO: 1 }), + ).toBe("$(shield) 6"); + }); +}); + +describe("formatStatusBarTooltip", () => { + it("reports 'no findings' on a clean workspace", () => { + expect( + formatStatusBarTooltip({ CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 }), + ).toBe("Pipeline-Check: no findings"); + }); + + it("breaks down every nonzero bucket", () => { + const tip = formatStatusBarTooltip({ + CRITICAL: 1, + HIGH: 2, + MEDIUM: 0, + LOW: 3, + INFO: 0, + }); + expect(tip).toContain("Pipeline-Check: 6 findings"); + expect(tip).toContain("CRITICAL: 1"); + expect(tip).toContain("HIGH: 2"); + expect(tip).toContain("LOW: 3"); + expect(tip).not.toContain("MEDIUM"); + expect(tip).not.toContain("INFO"); + expect(tip).toContain("Click to open the Findings panel."); + }); + + it("singular form for one finding", () => { + expect( + formatStatusBarTooltip({ CRITICAL: 0, HIGH: 1, MEDIUM: 0, LOW: 0, INFO: 0 }), + ).toContain("1 finding"); + }); +}); + +describe("countDiagnostics", () => { + it("ignores diagnostics whose source is not pipeline-check", () => { + const iter: Array<[unknown, unknown[]]> = [ + ["uri", [{ ...make("HIGH"), source: "eslint" }]], + ]; + expect( + countDiagnostics( + iter as unknown as Iterable, + ), + ).toEqual({ CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 }); + }); + + it("tallies pipeline-check diagnostics by severity", () => { + const iter: Array<[unknown, unknown[]]> = [ + ["a", [make("CRITICAL"), make("HIGH"), make("HIGH")]], + ["b", [make("LOW")]], + ]; + expect( + countDiagnostics( + iter as unknown as Iterable, + ), + ).toEqual({ CRITICAL: 1, HIGH: 2, MEDIUM: 0, LOW: 1, INFO: 0 }); + }); + + it("falls back to INFO for missing/unknown severity", () => { + const iter: Array<[unknown, unknown[]]> = [ + ["a", [make(), make("BOGUS")]], + ]; + expect( + countDiagnostics( + iter as unknown as Iterable, + ), + ).toEqual({ CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 2 }); + }); + + it("normalises lowercase severity names", () => { + const iter: Array<[unknown, unknown[]]> = [ + ["a", [make("high"), make("critical")]], + ]; + expect( + countDiagnostics( + iter as unknown as Iterable, + ), + ).toEqual({ CRITICAL: 1, HIGH: 1, MEDIUM: 0, LOW: 0, INFO: 0 }); + }); +}); diff --git a/src/statusBar.ts b/src/statusBar.ts new file mode 100644 index 0000000..f818613 --- /dev/null +++ b/src/statusBar.ts @@ -0,0 +1,159 @@ +// Status bar item that surfaces pipeline-check finding counts at the +// bottom-left of the window, so users get glanceable feedback without +// opening the Findings panel. Clicking the item reveals the panel. +// +// The visible-counts logic lives in `formatStatusBarText` as a pure +// function so the tests can pin the copy without booting VS Code. + +import * as vscode from "vscode"; + +const DIAGNOSTIC_SOURCE = "pipeline-check"; + +// What we read off ``Diagnostic.data.severity``. Mirrors the +// SEVERITY_ORDER in findingsView.ts; kept local here so the status +// bar can ship without a circular import. +type SeverityName = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "INFO"; + +export interface SeverityCounts { + readonly CRITICAL: number; + readonly HIGH: number; + readonly MEDIUM: number; + readonly LOW: number; + readonly INFO: number; +} + +const ZERO_COUNTS: SeverityCounts = { + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, +}; + +/** + * Render the status bar text from a per-severity tally. The output + * leans on the highest two severities present so the bar stays short + * (status-bar items steal horizontal space from everything to their + * right). Specifically: + * + * - no findings → "$(shield) clean" + * - critical present → "$(shield) 3C 1H" (or just "3C") + * - high but no crit → "$(shield) 4H 2M" + * - medium and below → "$(shield) 5" (total count, no letter) + */ +export function formatStatusBarText(c: SeverityCounts): string { + if (c.CRITICAL === 0 && c.HIGH === 0 && c.MEDIUM === 0 && c.LOW === 0 && c.INFO === 0) { + return "$(shield) clean"; + } + const parts: string[] = []; + if (c.CRITICAL > 0) { + parts.push(`${c.CRITICAL}C`); + if (c.HIGH > 0) { + parts.push(`${c.HIGH}H`); + } + } else if (c.HIGH > 0) { + parts.push(`${c.HIGH}H`); + if (c.MEDIUM > 0) { + parts.push(`${c.MEDIUM}M`); + } + } else { + // No critical or high — collapse to a single total so the bar + // doesn't shout for noise. + const total = c.MEDIUM + c.LOW + c.INFO; + parts.push(String(total)); + } + return `$(shield) ${parts.join(" ")}`; +} + +/** + * Render the tooltip — a longer breakdown shown on hover. Always + * lists every nonzero bucket, so the abbreviated bar text never + * hides information; just makes it less prominent. + */ +export function formatStatusBarTooltip(c: SeverityCounts): string { + const total = c.CRITICAL + c.HIGH + c.MEDIUM + c.LOW + c.INFO; + if (total === 0) { + return "Pipeline-Check: no findings"; + } + const lines = [ + `Pipeline-Check: ${total} finding${total === 1 ? "" : "s"}`, + ]; + for (const sev of ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] as const) { + if (c[sev] > 0) { + lines.push(` ${sev}: ${c[sev]}`); + } + } + lines.push("Click to open the Findings panel."); + return lines.join("\n"); +} + +/** + * Tally pipeline-check diagnostics across the workspace by severity. + * Falls back to INFO when ``data.severity`` is missing or unknown + * (same rule the Findings tree uses). + */ +export function countDiagnostics( + iter: Iterable, +): SeverityCounts { + const counts = { ...ZERO_COUNTS } as { -readonly [K in SeverityName]: number }; + for (const [, diags] of iter) { + for (const d of diags) { + if (d.source !== DIAGNOSTIC_SOURCE) continue; + const name = readSeverity(d); + counts[name] += 1; + } + } + return counts; +} + +function readSeverity(diag: vscode.Diagnostic): SeverityName { + const data = (diag as vscode.Diagnostic & { + data?: { severity?: string }; + }).data; + const name = (data?.severity ?? "").toUpperCase(); + switch (name) { + case "CRITICAL": + case "HIGH": + case "MEDIUM": + case "LOW": + case "INFO": + return name; + default: + return "INFO"; + } +} + +/** + * Wire up the status bar item. Returns a Disposable the caller pushes + * onto the extension's subscriptions so the item is removed on + * deactivate. The item itself rewires on every diagnostic change and + * navigates to the Findings panel on click. + */ +export function registerStatusBar( + context: vscode.ExtensionContext, +): vscode.StatusBarItem { + const item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + // Click reveals the Findings panel; same action as the activity-bar + // icon, just from a different surface. + item.command = "pipelineCheck.findings.focus"; + item.name = "Pipeline-Check"; + + const update = () => { + const counts = countDiagnostics(vscode.languages.getDiagnostics()); + item.text = formatStatusBarText(counts); + item.tooltip = formatStatusBarTooltip(counts); + item.show(); + }; + + // Seed and subscribe. + update(); + context.subscriptions.push( + item, + vscode.languages.onDidChangeDiagnostics(update), + ); + + return item; +} From 6a03ac191f6003b5d5c5cebe3b8119b81db266fb Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 10:48:19 +0200 Subject: [PATCH 03/11] review-followups-2: nav, shared patterns, logging, test stub, CI check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on review-followups (#11). Lands the next batch of items from the post-v0.1.1 review: R12, R14, R16, R18, R20. R12 — next/previous finding navigation: - src/navigate.ts: collectFindingLocations enumerates every pipeline-check diagnostic across the workspace, sorted by fsPath then by line. pickNextIndex picks the neighbouring finding relative to the editor cursor, wrapping at both ends. goToFinding pulls the active editor's location, calls pickNextIndex, and reveals the target via showTextDocument. - src/extension.ts: registers pipelineCheck.goToNextFinding and pipelineCheck.goToPreviousFinding. - package.json: declares both commands and binds Alt+F8 / Shift+Alt+F8 (the F8 family is VS Code's "next problem" muscle memory; Alt+F8 stays out of conflict with the global binding). - src/navigate.test.ts: 11 tests pinning the sort order, the wrap behaviour at both ends, the no-cursor case, and the strict-after / strict-before semantics that keep "next" from pinning when the cursor sits exactly on a finding. R14 — single source of truth for trigger patterns: - src/providers.ts: TRIGGER_PATTERNS + TRIGGER_DOCUMENT_SELECTOR. The activationEvents in package.json cannot import this module (VS Code reads the manifest before any code runs), so the events list duplicates the patterns intentionally. - src/providers.test.ts: locks the documentSelector list to the package.json activationEvents — every workspaceContains: event must correspond to a pattern, and vice versa, with brace-glob expansion. Catches drift before it ships. - src/extension.ts: documentSelector now reads from providers.ts. R16 — client-side structured logging: - src/log.ts: appendLine into the LanguageClient's outputChannel with a [client] HH:MM:SS.mmm prefix. info/warn/error helpers and a withTiming wrapper that brackets a thunk with start/ok/failed lines. Silent no-op until setLogChannel is called, so activation-order edge cases don't throw. - src/extension.ts: pointed setLogChannel at outputChannel after the client is constructed; logs "starting" / "started" / "failed to start — " around the LSP boot. - src/log.test.ts: 7 tests covering timestamp formatting, level preservation, the pre-setLogChannel no-op path, and withTiming's success / failure branches. R18 — shared vscode test stub: - src/__testStubs__/vscode.ts: extracted the 93-line vi.mock factory out of findingsView.test.ts. Each test file now reads: vi.mock("vscode", async () => { const { vscodeStub } = await import("./__testStubs__/vscode"); return vscodeStub(); }); vi.mock's hoisting forbids referencing outer-scope bindings, so the async-import pattern is the only way to share. Returns a fresh object per call so tests don't leak state into each other. - src/findingsView.test.ts: collapsed to the shared stub. R20 — marketplace description length CI gate: - .github/workflows/ci.yml: a "Marketplace description length" step (Linux only) fails the build if package.json#description exceeds 145 characters — the marketplace's search-results truncation point. Today's description is 141 chars; the gate keeps future edits honest. Total: 75 tests pass (was 53 on review-followups). Lint, compile, smoke all green. Three-OS CI matrix on every push. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 9 ++ package.json | 22 +++++ src/__testStubs__/vscode.ts | 113 ++++++++++++++++++++++ src/extension.ts | 34 ++++--- src/findingsView.test.ts | 112 ++-------------------- src/log.test.ts | 109 +++++++++++++++++++++ src/log.ts | 76 +++++++++++++++ src/navigate.test.ts | 186 ++++++++++++++++++++++++++++++++++++ src/navigate.ts | 143 +++++++++++++++++++++++++++ src/providers.test.ts | 54 +++++++++++ src/providers.ts | 53 ++++++++++ 11 files changed, 792 insertions(+), 119 deletions(-) create mode 100644 src/__testStubs__/vscode.ts create mode 100644 src/log.test.ts create mode 100644 src/log.ts create mode 100644 src/navigate.test.ts create mode 100644 src/navigate.ts create mode 100644 src/providers.test.ts create mode 100644 src/providers.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6af70e3..3d2f9ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,15 @@ jobs: - name: Lint run: npm run lint + - name: Marketplace description length + # The marketplace truncates listing descriptions around 145 + # characters in search results. Anything longer loses signal + # before users click through. The current copy hugs the limit + # deliberately — this step keeps future edits honest. + if: matrix.os == 'ubuntu-latest' + run: | + node -e 'const d=require("./package.json").description; if(d.length>145){console.error("description is "+d.length+" chars (max 145):",d);process.exit(1)}' + - name: TypeScript compile run: npm run compile diff --git a/package.json b/package.json index 872a987..f633392 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,28 @@ "title": "Change Grouping", "category": "Pipeline-Check", "icon": "$(list-tree)" + }, + { + "command": "pipelineCheck.goToNextFinding", + "title": "Go to Next Finding", + "category": "Pipeline-Check" + }, + { + "command": "pipelineCheck.goToPreviousFinding", + "title": "Go to Previous Finding", + "category": "Pipeline-Check" + } + ], + "keybindings": [ + { + "command": "pipelineCheck.goToNextFinding", + "key": "alt+f8", + "when": "editorTextFocus" + }, + { + "command": "pipelineCheck.goToPreviousFinding", + "key": "shift+alt+f8", + "when": "editorTextFocus" } ], "menus": { diff --git a/src/__testStubs__/vscode.ts b/src/__testStubs__/vscode.ts new file mode 100644 index 0000000..838b62c --- /dev/null +++ b/src/__testStubs__/vscode.ts @@ -0,0 +1,113 @@ +// Shared `vscode` module stub for the vitest suite. Each test file +// registers it via: +// +// vi.mock("vscode", async () => { +// const { vscodeStub } = await import("./__testStubs__/vscode"); +// return vscodeStub(); +// }); +// +// `vi.mock` factories are hoisted above imports and must self-contain, +// so the async-import pattern is the only safe way to share. Returning +// a fresh object per call keeps tests isolated — none of the classes +// or stubs leak state between files. +// +// `getDiagnostics` reads from `globalThis.__stubDiagnostics`, which +// tests populate via the per-file `setStubDiagnostics` helper they +// keep close to their fixtures. + +export function vscodeStub(): Record { + class ThemeIcon { + constructor( + public readonly id: string, + public readonly color?: ThemeColor, + ) {} + } + class ThemeColor { + constructor(public readonly id: string) {} + } + class EventEmitter { + private listeners: Array<(e: T) => void> = []; + fire(e: T): void { + for (const l of this.listeners) l(e); + } + get event() { + return (listener: (e: T) => void) => { + this.listeners.push(listener); + return { dispose: () => undefined }; + }; + } + dispose(): void { + this.listeners = []; + } + } + class TreeItem { + iconPath?: unknown; + description?: string; + tooltip?: unknown; + command?: unknown; + contextValue?: string; + constructor( + public readonly label: string, + public readonly collapsibleState: number, + ) {} + } + class MarkdownString { + isTrusted = false; + supportThemeIcons = false; + constructor(public value: string) {} + appendMarkdown(s: string): this { + this.value += s; + return this; + } + } + const Uri = { + parse: (s: string) => { + const noScheme = s.replace(/^file:\/\//, ""); + return { + toString: () => s, + path: noScheme, + fsPath: noScheme, + }; + }, + }; + const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; + const StatusBarAlignment = { Left: 1, Right: 2 }; + + return { + ThemeIcon, + ThemeColor, + EventEmitter, + TreeItem, + MarkdownString, + TreeItemCollapsibleState, + StatusBarAlignment, + Uri, + workspace: { + asRelativePath: (uri: { fsPath?: string; path?: string }) => + uri.fsPath ?? uri.path ?? "", + }, + languages: { + // Two call shapes: + // - `getDiagnostics()` returns every [uri, diagnostic[]] pair + // - `getDiagnostics(uri)` returns just that uri's diagnostics + getDiagnostics: (uri?: { toString: () => string }) => { + const all = + ( + globalThis as { + __stubDiagnostics?: Array<[ + { toString: () => string }, + unknown[], + ]>; + } + ).__stubDiagnostics ?? []; + if (uri === undefined) return all; + const key = uri.toString(); + const match = all.find(([u]) => u.toString() === key); + return match ? match[1] : []; + }, + onDidChangeDiagnostics: () => ({ dispose: () => undefined }), + }, + commands: { executeCommand: () => Promise.resolve() }, + window: {}, + }; +} diff --git a/src/extension.ts b/src/extension.ts index d14cd0b..2cf26d7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,9 @@ import { TransportKind, } from "vscode-languageclient/node"; import { FindingsTreeProvider, GroupMode } from "./findingsView"; +import * as clientLog from "./log"; +import { goToFinding } from "./navigate"; +import { TRIGGER_DOCUMENT_SELECTOR } from "./providers"; import { filterByThreshold } from "./severityFilter"; import { registerStatusBar } from "./statusBar"; @@ -95,21 +98,11 @@ function buildClient(): LanguageClient { // in the first place — smaller cross-section, no dependency on whether // the user has the official GitHub Actions extension installed // (which would otherwise hijack the `github-actions-workflow` - // language ID for `.github/workflows/*.yml`). + // language ID for `.github/workflows/*.yml`). The pattern list itself + // lives in providers.ts so the documentSelector, activationEvents, + // and the workspace-scan command can't drift apart. const clientOptions: LanguageClientOptions = { - documentSelector: [ - { scheme: "file", pattern: "**/.github/workflows/*.{yml,yaml}" }, - { scheme: "file", pattern: "**/.gitlab-ci.yml" }, - { scheme: "file", pattern: "**/azure-pipelines.yml" }, - { scheme: "file", pattern: "**/bitbucket-pipelines.yml" }, - { scheme: "file", pattern: "**/.circleci/config.yml" }, - { scheme: "file", pattern: "**/cloudbuild.yaml" }, - { scheme: "file", pattern: "**/.buildkite/pipeline.yml" }, - { scheme: "file", pattern: "**/.drone.{yml,yaml}" }, - { scheme: "file", pattern: "**/Jenkinsfile" }, - { scheme: "file", pattern: "**/Dockerfile" }, - { scheme: "file", pattern: "**/Containerfile" }, - ], + documentSelector: [...TRIGGER_DOCUMENT_SELECTOR], synchronize: { configurationSection: "pipelineCheck", }, @@ -153,8 +146,14 @@ async function startClient(): Promise { // drop the broken client on failure (the "Open server log" action // below still needs to focus it to surface the server's traceback). const outputChannel: vscode.OutputChannel = client.outputChannel; + // Point the client-side logger at the same channel the LSP server + // writes to, so [client] and [server] lines interleave with shared + // timestamps — much easier to read when triaging a bug report. + clientLog.setLogChannel(outputChannel); try { + clientLog.info("language server: starting"); await client.start(); + clientLog.info("language server: started"); } catch (err) { // The most common cause is `python -m pipeline_check.lsp` failing: // either Python is not on PATH or the [lsp] extra is not installed. @@ -163,6 +162,7 @@ async function startClient(): Promise { // notification body. The notification chrome already shows the // extension name, so the message body doesn't repeat it. const message = err instanceof Error ? err.message : String(err); + clientLog.error(`language server: failed to start — ${message}`); const choice = await vscode.window.showErrorMessage( `Language server failed to start (${message}).`, "Copy install command", @@ -242,6 +242,12 @@ export async function activate( "pipelineCheck.findings.changeGrouping", () => changeGrouping(findingsProvider), ), + vscode.commands.registerCommand("pipelineCheck.goToNextFinding", () => + goToFinding("next"), + ), + vscode.commands.registerCommand("pipelineCheck.goToPreviousFinding", () => + goToFinding("previous"), + ), vscode.commands.registerCommand("pipelineCheck.restart", async () => { await stopClient(); await startClient(); diff --git a/src/findingsView.test.ts b/src/findingsView.test.ts index a98d150..6fd555d 100644 --- a/src/findingsView.test.ts +++ b/src/findingsView.test.ts @@ -1,110 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -// findingsView.ts imports `vscode` at the top, which is supplied by the -// editor at runtime and isn't installable from npm. We stub just the -// surface findingsView actually touches: classes it instantiates -// (`ThemeIcon`, `ThemeColor`, `EventEmitter`, `TreeItem`, -// `MarkdownString`, `Uri`) plus the static method it calls -// (`workspace.asRelativePath`, `languages.getDiagnostics`, -// `languages.onDidChangeDiagnostics`, `commands.executeCommand`). -// -// `vi.mock` must run before the SUT is imported. The factory must not -// reference outer-scope variables (vitest hoists it), so the mutable -// state (`stubDiagnostics`) lives on `globalThis` and the -// `getDiagnostics` stub reads from there. -vi.mock("vscode", () => { - class ThemeIcon { - constructor( - public readonly id: string, - public readonly color?: ThemeColor, - ) {} - } - class ThemeColor { - constructor(public readonly id: string) {} - } - class EventEmitter { - private listeners: Array<(e: T) => void> = []; - fire(e: T): void { - for (const l of this.listeners) l(e); - } - get event() { - return (listener: (e: T) => void) => { - this.listeners.push(listener); - return { dispose: () => undefined }; - }; - } - dispose(): void { - this.listeners = []; - } - } - class TreeItem { - iconPath?: unknown; - description?: string; - tooltip?: unknown; - command?: unknown; - contextValue?: string; - constructor( - public readonly label: string, - public readonly collapsibleState: number, - ) {} - } - class MarkdownString { - isTrusted = false; - supportThemeIcons = false; - constructor(public value: string) {} - appendMarkdown(s: string): this { - this.value += s; - return this; - } - } - const Uri = { - parse: (s: string) => { - const noScheme = s.replace(/^file:\/\//, ""); - return { - toString: () => s, - path: noScheme, - fsPath: noScheme, - }; - }, - }; - const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; - return { - ThemeIcon, - ThemeColor, - EventEmitter, - TreeItem, - MarkdownString, - TreeItemCollapsibleState, - Uri, - workspace: { - asRelativePath: (uri: { fsPath?: string; path?: string }) => - uri.fsPath ?? uri.path ?? "", - }, - languages: { - // Two call shapes: - // - `getDiagnostics()` returns every [uri, diagnostic[]] pair - // - `getDiagnostics(uri)` returns just that uri's diagnostics - // The provider's batch-skip path uses the second form; the - // collection path uses the first. - getDiagnostics: (uri?: { toString: () => string }) => { - const all = - ( - globalThis as { - __stubDiagnostics?: Array<[ - { toString: () => string }, - unknown[], - ]>; - } - ).__stubDiagnostics ?? []; - if (uri === undefined) return all; - const key = uri.toString(); - const match = all.find(([u]) => u.toString() === key); - return match ? match[1] : []; - }, - onDidChangeDiagnostics: () => ({ dispose: () => undefined }), - }, - commands: { executeCommand: () => Promise.resolve() }, - }; +// The shared vscode stub in src/__testStubs__/vscode.ts covers the +// surface findingsView reaches into. The async factory below is the +// only safe way to share it: vi.mock hoists above imports and the +// factory cannot reference outer-scope bindings synchronously. +vi.mock("vscode", async () => { + const { vscodeStub } = await import("./__testStubs__/vscode"); + return vscodeStub(); }); // Import after the mock is registered. diff --git a/src/log.test.ts b/src/log.test.ts new file mode 100644 index 0000000..52a24f2 --- /dev/null +++ b/src/log.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("vscode", () => ({})); + +import { + formatTimestamp, + info, + warn, + error, + setLogChannel, + withTiming, +} from "./log"; + +// Capture log lines by passing a fake OutputChannel. +function fakeChannel() { + const lines: string[] = []; + return { + lines, + channel: { + appendLine: (s: string) => { + lines.push(s); + }, + // Methods we don't exercise; provided so the type satisfies + // vscode.OutputChannel's minimal shape. + name: "Pipeline-Check", + append: () => undefined, + clear: () => undefined, + show: () => undefined, + hide: () => undefined, + replace: () => undefined, + dispose: () => undefined, + } as unknown as import("vscode").OutputChannel, + }; +} + +beforeEach(() => { + // Reset the module-scope channel so a test that doesn't call + // setLogChannel can verify the no-op path. + setLogChannel( + undefined as unknown as import("vscode").OutputChannel, + ); +}); + +describe("formatTimestamp", () => { + it("zero-pads the components", () => { + const d = new Date(2026, 0, 1, 3, 4, 5, 6); + expect(formatTimestamp(d)).toBe("03:04:05.006"); + }); + + it("uses millisecond precision", () => { + const d = new Date(2026, 0, 1, 23, 59, 59, 999); + expect(formatTimestamp(d)).toBe("23:59:59.999"); + }); +}); + +describe("log levels", () => { + it("info appends a line with the [client] prefix and level", () => { + const { lines, channel } = fakeChannel(); + setLogChannel(channel); + info("hello"); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("[client]"); + expect(lines[0]).toContain("info"); + expect(lines[0]).toContain("hello"); + }); + + it("warn level is preserved", () => { + const { lines, channel } = fakeChannel(); + setLogChannel(channel); + warn("careful"); + expect(lines[0]).toContain("warn"); + }); + + it("error level is preserved", () => { + const { lines, channel } = fakeChannel(); + setLogChannel(channel); + error("oops"); + expect(lines[0]).toContain("error"); + }); + + it("is a no-op before setLogChannel has been called", () => { + // setLogChannel was reset to undefined in beforeEach. + expect(() => info("nowhere to go")).not.toThrow(); + }); +}); + +describe("withTiming", () => { + it("logs start, ok, and the elapsed milliseconds on success", async () => { + const { lines, channel } = fakeChannel(); + setLogChannel(channel); + await withTiming("test op", async () => undefined); + expect(lines).toHaveLength(2); + expect(lines[0]).toMatch(/test op: start$/); + expect(lines[1]).toMatch(/test op: ok in \d+ms$/); + }); + + it("logs failure and re-throws the original error", async () => { + const { lines, channel } = fakeChannel(); + setLogChannel(channel); + await expect( + withTiming("doomed", async () => { + throw new Error("kaboom"); + }), + ).rejects.toThrow("kaboom"); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain("doomed: failed"); + expect(lines[1]).toContain("kaboom"); + }); +}); diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..81972d5 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,76 @@ +// Client-side structured logging that lands in the same +// "Pipeline-Check" output channel as the LSP server's +// `window/logMessage` traffic, distinguished by a `[client]` prefix. +// +// The point is to leave breadcrumbs when a user reports a bug: +// without these lines we have no way to tell whether the command +// fired, how long the work took, or where it failed. The output +// channel is the right home — users can already focus it via +// `Pipeline-Check: Show language server output`, and it's the +// natural place to look. + +import * as vscode from "vscode"; + +let channel: vscode.OutputChannel | undefined; + +/** + * Set the output channel logs are written to. Called once from + * activate() after the LanguageClient has constructed its channel, + * so client logs and server logs share the same surface. + */ +export function setLogChannel(c: vscode.OutputChannel): void { + channel = c; +} + +/** + * Append a single line, prefixed `[client] HH:MM:SS.mmm `, to + * the configured output channel. Silent no-op until `setLogChannel` + * has been called — keeps activation-order edge cases from throwing. + */ +export function log( + level: "info" | "warn" | "error", + message: string, +): void { + if (!channel) return; + channel.appendLine(`[client] ${timestamp()} ${level.padEnd(5)} ${message}`); +} + +export const info = (msg: string) => log("info", msg); +export const warn = (msg: string) => log("warn", msg); +export const error = (msg: string) => log("error", msg); + +/** + * Wraps a thunk so its start, end, and duration land in the log. + * Useful for commands the user fires — `command ran for 1.3s` + * is exactly the kind of breadcrumb that turns a vague bug report + * into an actionable one. + */ +export async function withTiming( + label: string, + fn: () => Promise, +): Promise { + const started = Date.now(); + info(`${label}: start`); + try { + const result = await fn(); + info(`${label}: ok in ${Date.now() - started}ms`); + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(`${label}: failed in ${Date.now() - started}ms — ${msg}`); + throw err; + } +} + +/** + * Render the current time as `HH:MM:SS.mmm` so log lines sort and + * align on the leading column. Exported for unit testing. + */ +export function formatTimestamp(d: Date): string { + const pad = (n: number, w = 2) => String(n).padStart(w, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} + +function timestamp(): string { + return formatTimestamp(new Date()); +} diff --git a/src/navigate.test.ts b/src/navigate.test.ts new file mode 100644 index 0000000..4461d0a --- /dev/null +++ b/src/navigate.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("vscode", () => ({ + // navigate.ts touches `vscode.languages` and `vscode.window` in the + // command path, but the pure helpers we test below don't need them. + // A minimal stub keeps the module-level import resolvable. + languages: {}, + window: {}, +})); + +import { collectFindingLocations, pickNextIndex, type Direction } from "./navigate"; + +// Helpers ---------------------------------------------------------------- + +const uri = (path: string) => + ({ + fsPath: path, + toString: () => `file://${path}`, + }) as unknown as import("vscode").Uri; + +const range = (line: number, ch = 0) => + ({ + start: { line, character: ch }, + end: { line, character: ch }, + }) as unknown as import("vscode").Range; + +const pos = (line: number, ch = 0) => + ({ line, character: ch }) as import("vscode").Position; + +const diag = (line: number, source = "pipeline-check") => ({ + source, + message: "", + range: range(line), + severity: 0, +}); + +const findings = (...rows: Array<[string, number]>) => + rows.map(([file, line]) => ({ + uri: uri(file), + range: range(line), + })); + +// collectFindingLocations ----------------------------------------------- + +describe("collectFindingLocations", () => { + it("ignores diagnostics whose source is not pipeline-check", () => { + const iter: Array<[import("vscode").Uri, import("vscode").Diagnostic[]]> = [ + [ + uri("/a/ci.yml"), + [ + diag(2, "eslint") as unknown as import("vscode").Diagnostic, + diag(5) as unknown as import("vscode").Diagnostic, + ], + ], + ]; + const out = collectFindingLocations(iter); + expect(out).toHaveLength(1); + expect(out[0].range.start.line).toBe(5); + }); + + it("sorts cross-file by fsPath then by line", () => { + const iter: Array<[import("vscode").Uri, import("vscode").Diagnostic[]]> = [ + [ + uri("/z/last.yml"), + [ + diag(0) as unknown as import("vscode").Diagnostic, + diag(2) as unknown as import("vscode").Diagnostic, + ], + ], + [ + uri("/a/first.yml"), + [ + diag(9) as unknown as import("vscode").Diagnostic, + diag(1) as unknown as import("vscode").Diagnostic, + ], + ], + ]; + const out = collectFindingLocations(iter); + expect(out.map((f) => [f.uri.fsPath, f.range.start.line])).toEqual([ + ["/a/first.yml", 1], + ["/a/first.yml", 9], + ["/z/last.yml", 0], + ["/z/last.yml", 2], + ]); + }); +}); + +// pickNextIndex --------------------------------------------------------- + +describe("pickNextIndex", () => { + const list = findings( + ["/a/x.yml", 5], + ["/a/x.yml", 15], + ["/b/y.yml", 0], + ); + + it("returns -1 when there are no findings", () => { + expect(pickNextIndex([], undefined, "next")).toBe(-1); + expect(pickNextIndex([], pos(0) as never, "next" satisfies Direction)).toBe( + -1, + ); + }); + + it("next: with no cursor returns the first finding", () => { + expect( + pickNextIndex( + list, + undefined as unknown as { uri: import("vscode").Uri; position: import("vscode").Position }, + "next", + ), + ).toBe(0); + }); + + it("previous: with no cursor returns the last finding", () => { + expect( + pickNextIndex( + list, + undefined as unknown as { uri: import("vscode").Uri; position: import("vscode").Position }, + "previous", + ), + ).toBe(2); + }); + + it("next: cursor before all findings → first", () => { + expect( + pickNextIndex(list, { uri: uri("/a/x.yml"), position: pos(0) }, "next"), + ).toBe(0); + }); + + it("next: cursor on a finding → the one after", () => { + expect( + pickNextIndex(list, { uri: uri("/a/x.yml"), position: pos(5) }, "next"), + ).toBe(1); + }); + + it("next: cursor at end → wraps to first", () => { + expect( + pickNextIndex( + list, + { uri: uri("/c/zzz.yml"), position: pos(99) }, + "next", + ), + ).toBe(0); + }); + + it("previous: cursor on a finding → the one before", () => { + expect( + pickNextIndex( + list, + { uri: uri("/a/x.yml"), position: pos(15) }, + "previous", + ), + ).toBe(0); + }); + + it("previous: cursor at start → wraps to last", () => { + expect( + pickNextIndex( + list, + { uri: uri("/0/nothing.yml"), position: pos(0) }, + "previous", + ), + ).toBe(2); + }); + + it("next: cursor in a file that sorts between the finding files lands on the next finding's file", () => { + // Cursor in /a/y_after.yml — sorts after /a/x.yml (same dir, + // 'y' > 'x') and before /b/y.yml. Next finding is index 2. + expect( + pickNextIndex( + list, + { uri: uri("/a/y_after.yml"), position: pos(0) }, + "next", + ), + ).toBe(2); + }); + + it("strict comparison: cursor on the SAME column as a finding still advances", () => { + // The finding sits at line 5 char 0. Cursor at line 5 char 0 should + // still move us past it on `next`, not pin. + const single = findings(["/a/x.yml", 5]); + expect( + pickNextIndex(single, { uri: uri("/a/x.yml"), position: pos(5, 0) }, "next"), + ).toBe(0); // wraps because single element and not strictly-after + }); +}); diff --git a/src/navigate.ts b/src/navigate.ts new file mode 100644 index 0000000..e2d9aad --- /dev/null +++ b/src/navigate.ts @@ -0,0 +1,143 @@ +// "Go to next / previous finding" navigation. Walks the workspace's +// pipeline-check diagnostics in a deterministic order (uri.fsPath +// ascending, then line ascending) and moves the cursor to the +// neighbouring one relative to wherever it sits now. +// +// The order matches the Findings tree's file-mode sort so jumping +// from the editor and clicking through the tree produce the same +// sequence — no surprise re-orderings between surfaces. + +import * as vscode from "vscode"; + +const DIAGNOSTIC_SOURCE = "pipeline-check"; + +interface Location { + readonly uri: vscode.Uri; + readonly range: vscode.Range; +} + +/** + * Enumerates every pipeline-check diagnostic in the workspace as a + * flat list of `(uri, range)` pairs, sorted by file path then by + * starting line. Exported for unit testing. + */ +export function collectFindingLocations( + iter: Iterable, +): Location[] { + const out: Location[] = []; + for (const [uri, diags] of iter) { + for (const d of diags) { + if (d.source !== DIAGNOSTIC_SOURCE) continue; + out.push({ uri, range: d.range }); + } + } + out.sort((a, b) => { + const lhs = a.uri.fsPath; + const rhs = b.uri.fsPath; + if (lhs !== rhs) return lhs.localeCompare(rhs); + return a.range.start.line - b.range.start.line; + }); + return out; +} + +export type Direction = "next" | "previous"; + +/** + * Given the sorted findings, the active editor's location, and a + * direction, return the index of the finding to jump to (or -1 when + * the workspace has no findings). + * + * Semantics: + * - `next` from a cursor sitting before any finding → 0 + * - `next` from after the last finding → wraps to 0 + * - `previous` from before all findings → wraps to last + * - `next` from EXACTLY on a finding → the one after + * - `previous` from EXACTLY on a finding → the one before + * + * Wrapping is the convention every navigation command in VS Code + * (Go to Next Problem, search results, etc.) follows; the user + * expects to keep walking the list, not hit an invisible wall. + */ +export function pickNextIndex( + findings: readonly Location[], + current: { uri: vscode.Uri; position: vscode.Position } | undefined, + direction: Direction, +): number { + if (findings.length === 0) return -1; + if (!current) { + return direction === "next" ? 0 : findings.length - 1; + } + + const cursorFs = current.uri.fsPath; + const cursorLine = current.position.line; + const cursorChar = current.position.character; + + if (direction === "next") { + for (let i = 0; i < findings.length; i++) { + const f = findings[i]; + if (isStrictlyAfter(f, cursorFs, cursorLine, cursorChar)) return i; + } + return 0; // wrap + } else { + for (let i = findings.length - 1; i >= 0; i--) { + const f = findings[i]; + if (isStrictlyBefore(f, cursorFs, cursorLine, cursorChar)) return i; + } + return findings.length - 1; // wrap + } +} + +function isStrictlyAfter( + f: Location, + cursorFs: string, + line: number, + ch: number, +): boolean { + const fFs = f.uri.fsPath; + if (fFs !== cursorFs) return fFs.localeCompare(cursorFs) > 0; + if (f.range.start.line !== line) return f.range.start.line > line; + return f.range.start.character > ch; +} + +function isStrictlyBefore( + f: Location, + cursorFs: string, + line: number, + ch: number, +): boolean { + const fFs = f.uri.fsPath; + if (fFs !== cursorFs) return fFs.localeCompare(cursorFs) < 0; + if (f.range.start.line !== line) return f.range.start.line < line; + return f.range.start.character < ch; +} + +/** + * Move the active editor's cursor to the next or previous finding, + * wrapping at the ends. Surfaces an information toast when the + * workspace has no pipeline-check diagnostics — silent failure on + * a deliberate keybinding press is confusing. + */ +export async function goToFinding(direction: Direction): Promise { + const findings = collectFindingLocations(vscode.languages.getDiagnostics()); + if (findings.length === 0) { + void vscode.window.showInformationMessage( + "Pipeline-Check: no findings to navigate.", + ); + return; + } + + const editor = vscode.window.activeTextEditor; + const current = editor + ? { uri: editor.document.uri, position: editor.selection.active } + : undefined; + + const idx = pickNextIndex(findings, current, direction); + if (idx < 0) return; + const target = findings[idx]; + + await vscode.window.showTextDocument(target.uri, { + selection: target.range, + preserveFocus: false, + preview: false, + }); +} diff --git a/src/providers.test.ts b/src/providers.test.ts new file mode 100644 index 0000000..8bcda5e --- /dev/null +++ b/src/providers.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +vi.mock("vscode", () => ({})); + +import { TRIGGER_PATTERNS, TRIGGER_DOCUMENT_SELECTOR } from "./providers"; + +describe("TRIGGER_PATTERNS", () => { + it("derives a `file`-scoped DocumentFilter for each pattern", () => { + expect(TRIGGER_DOCUMENT_SELECTOR).toHaveLength(TRIGGER_PATTERNS.length); + for (const f of TRIGGER_DOCUMENT_SELECTOR) { + expect(f.scheme).toBe("file"); + expect(typeof f.pattern).toBe("string"); + } + }); + + it("stays in sync with package.json#activationEvents", () => { + // The manifest cannot import this module (VS Code reads it before + // any code runs), so the activationEvents list duplicates these + // patterns. The test below catches the drift before it ships: + // every TRIGGER_PATTERNS entry must be reachable from at least + // one `workspaceContains:` event, and every `workspaceContains:` + // event must correspond to a pattern. + const pkg = JSON.parse( + readFileSync(resolve(__dirname, "..", "package.json"), "utf8"), + ) as { activationEvents: string[] }; + + const wsContains = pkg.activationEvents + .filter((e) => e.startsWith("workspaceContains:")) + .map((e) => e.slice("workspaceContains:".length)); + + // Brace-globs collapse to one DocumentFilter pattern but expand + // into multiple activationEvents (one per branch). Expand the + // TRIGGER_PATTERNS list the same way so the comparison is apples + // to apples. + const expanded = TRIGGER_PATTERNS.flatMap(expandBraces).sort(); + const events = [...wsContains].sort(); + expect(events).toEqual(expanded); + }); +}); + +/** + * Expand a single-brace pattern like `**\/foo.{yml,yaml}` into + * `["**\/foo.yml", "**\/foo.yaml"]`. Doesn't handle nested braces — + * good enough for our patterns and trivial to extend if a future + * pattern needs it. + */ +function expandBraces(pattern: string): string[] { + const match = /^(.*)\{([^{}]+)\}(.*)$/.exec(pattern); + if (!match) return [pattern]; + const [, head, body, tail] = match; + return body.split(",").map((alt) => `${head}${alt}${tail}`); +} diff --git a/src/providers.ts b/src/providers.ts new file mode 100644 index 0000000..8060ffb --- /dev/null +++ b/src/providers.ts @@ -0,0 +1,53 @@ +// Single source of truth for which files Pipeline-Check cares about. +// The same list is referenced from three surfaces that used to keep +// their own copies and drift apart: +// +// 1. `documentSelector` in extension.ts — tells the LSP which +// documents to scan when they open in the editor. +// 2. `activationEvents` in package.json — tells VS Code which +// workspace files should activate the extension. +// 3. (post-merge of scan-workspace) `SCAN_PATTERNS` for the +// workspace-wide scan command. +// +// Keeping them in lockstep is mechanical, so this module exports both +// the underlying pattern strings and the VS Code-shaped `DocumentFilter` +// records derived from them. A new provider goes here once; callers +// stay in sync automatically. The package.json `activationEvents` +// remains the only duplication — manifest contributions cannot be +// generated at runtime, so a comment in this file points at the +// strings that must be updated there too. + +// A structural shape rather than `vscode.DocumentFilter` — +// `vscode-languageclient` redeclares `DocumentFilter` and the two +// types don't unify. Plain objects flow into both APIs unchanged. +export interface TriggerSelector { + readonly scheme: "file"; + readonly pattern: string; +} + +/** + * Glob patterns matching every file the upstream `pipeline_check` + * rule registry knows how to analyse. Order is not load-bearing. + */ +export const TRIGGER_PATTERNS: readonly string[] = [ + "**/.github/workflows/*.{yml,yaml}", + "**/.gitlab-ci.yml", + "**/azure-pipelines.yml", + "**/bitbucket-pipelines.yml", + "**/.circleci/config.yml", + "**/cloudbuild.yaml", + "**/.buildkite/pipeline.yml", + "**/.drone.{yml,yaml}", + "**/Jenkinsfile", + "**/Dockerfile", + "**/Containerfile", +]; + +/** + * Document-selector form of `TRIGGER_PATTERNS`, suitable for the + * `LanguageClientOptions.documentSelector`. Each entry restricts the + * filter to the `file` scheme so untitled or memory-backed buffers + * never reach the server. + */ +export const TRIGGER_DOCUMENT_SELECTOR: readonly TriggerSelector[] = + TRIGGER_PATTERNS.map((pattern) => ({ scheme: "file" as const, pattern })); From a28b6dee17d0a43ed91575d8c5f5e1758c4d12bd Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 10:56:13 +0200 Subject: [PATCH 04/11] review-followups-3: CodeLens, per-provider toggles, pre-release tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on review-followups-batch-2 (#12). Lands R26, R25, R24. R26 — inline CodeLens summary: - src/codeLens.ts: FindingsCodeLensProvider pins a "Pipeline-Check: 2 critical · 1 high" lens at the top of every scanned file. Click navigates to the Findings panel. Re-emits on every onDidChangeDiagnostics; the lens text tracks the latest LSP publish. - summariseCounts and composeLensTitle are pure; tests pin the per-severity tally, severity-order rendering, and the "null when empty so the lens is omitted" contract. - src/extension.ts: registers the provider with TRIGGER_DOCUMENT_SELECTOR. R25 — per-provider toggles: - src/providers.ts: PROVIDERS map keyed by ProviderId ('github-actions', 'gitlab', 'azure', 'bitbucket', 'circleci', 'cloud-build', 'buildkite', 'drone', 'jenkins', 'dockerfile'). Dockerfile + Containerfile collapse to one 'dockerfile' id since they share syntax. TRIGGER_PATTERNS is now derived from the map so the two can't drift. - providerForPath(): glob-matches a path to a provider id. Tiny local glob matcher covers the dialect our patterns use (**, *, brace alternatives) — sufficient for the actual patterns and side-steps a runtime dependency. - package.json: new pipelineCheck.disabledProviders setting (array of provider ids, enum-constrained). The middleware drops every diagnostic for a URI whose provider sits in the disabled set, so the gutter / Problems / Findings / status bar / CodeLens all respect the toggle through one filter. - providers.test.ts: tests for every provider's path match, Dockerfile/Containerfile aliasing, Windows-backslash normalisation, and the "no match" return. R24 — pre-release channel via tag naming: - .github/workflows/publish.yml: new "Detect pre-release tag" step. Tags with a `-` after the semver core (e.g. v0.2.0-rc.1) ship as pre-release; stable tags (v0.2.0) ship to the stable channel. Detection sets PRERELEASE_FLAG (passed to vsce publish, vsce package, and ovsx publish) and GH_PRERELEASE (passed to gh release create). Header comment documents the convention. Total: 90 tests pass (was 75 on #12). +4 codeLens + +11 providers. Lint, compile, smoke all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 38 +++++++++-- package.json | 21 ++++++ src/codeLens.test.ts | 114 +++++++++++++++++++++++++++++++ src/codeLens.ts | 117 ++++++++++++++++++++++++++++++++ src/extension.ts | 33 +++++++-- src/providers.test.ts | 64 +++++++++++++++++- src/providers.ts | 123 ++++++++++++++++++++++++++++++---- 7 files changed, 487 insertions(+), 23 deletions(-) create mode 100644 src/codeLens.test.ts create mode 100644 src/codeLens.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5a13ec8..679b2e6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,13 @@ name: Publish # VS Code Marketplace and Open VSX, then attaches the .vsix to a # GitHub release. # +# Tag-naming convention: +# - `v0.1.0` (stable) → stable marketplace channel. +# - `v0.1.0-rc.1` (pre-release) → pre-release marketplace channel, +# GitHub release marked `prerelease`. +# Detection is by the presence of a `-` after the semver core; see +# the "Detect pre-release tag" step below. +# # Required repo secrets: # - VSCE_PAT — Azure DevOps Personal Access Token, scope # `Marketplace > Acquire and Manage`. Bound to the @@ -109,6 +116,26 @@ jobs: - name: Bundle smoke run: npm run smoke + - name: Detect pre-release tag + # A tag like `v0.2.0-rc.1` (anything with a `-` after the + # semver core) ships to the marketplace's pre-release channel + # and the GitHub release is marked prerelease. Stable tags + # (`v0.2.0`) ship to the stable channel. Detection is purely + # by the version string — keeps the convention discoverable + # via `git tag`. + run: | + set -euo pipefail + version=$(node -p "require('./package.json').version") + if [[ "$version" == *-* ]]; then + echo "Pre-release tag detected: $version" + echo "PRERELEASE_FLAG=--pre-release" >> "$GITHUB_ENV" + echo "GH_PRERELEASE=--prerelease" >> "$GITHUB_ENV" + else + echo "Stable tag: $version" + echo "PRERELEASE_FLAG=" >> "$GITHUB_ENV" + echo "GH_PRERELEASE=" >> "$GITHUB_ENV" + fi + - name: Package vsix # vsce and ovsx are pinned devDependencies in package.json, so # `npm ci` above installed the exact versions and Dependabot @@ -116,7 +143,7 @@ jobs: # the local binary — no fresh fetch with PATs in env. run: | version=$(node -p "require('./package.json').version") - npx vsce package --out "pipeline-check-${version}.vsix" + npx vsce package $PRERELEASE_FLAG --out "pipeline-check-${version}.vsix" ls -lh "pipeline-check-${version}.vsix" echo "VSIX_PATH=pipeline-check-${version}.vsix" >> $GITHUB_ENV @@ -124,7 +151,7 @@ jobs: env: VSCE_PAT: ${{ secrets.VSCE_PAT }} run: | - npx vsce publish \ + npx vsce publish $PRERELEASE_FLAG \ --packagePath "$VSIX_PATH" \ --pat "$VSCE_PAT" @@ -132,7 +159,10 @@ jobs: env: OVSX_PAT: ${{ secrets.OVSX_PAT }} run: | - npx ovsx publish "$VSIX_PATH" \ + # Open VSX (ovsx >= 0.10) honours --pre-release the same + # way vsce does. Older versions ignore the flag silently, + # so this stays safe across minor bumps. + npx ovsx publish $PRERELEASE_FLAG "$VSIX_PATH" \ --pat "$OVSX_PAT" - name: Create GitHub release @@ -140,6 +170,6 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | version=$(node -p "require('./package.json').version") - gh release create "v${version}" "$VSIX_PATH" \ + gh release create "v${version}" "$VSIX_PATH" $GH_PRERELEASE \ --title "v${version}" \ --notes-file <(awk '/^## \[/{n++} n==2{exit} n==1{print}' CHANGELOG.md) diff --git a/package.json b/package.json index f633392..05cfd3b 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,27 @@ "scope": "machine-overridable", "markdownDescription": "Arguments passed to the server command. Default invokes the module via `python -m pipeline_check.lsp`. Scope is `machine-overridable`: workspace overrides require an explicit prompt, since arbitrary args can execute arbitrary code (e.g. `-c \"...\"`)." }, + "pipelineCheck.disabledProviders": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "github-actions", + "gitlab", + "azure", + "bitbucket", + "circleci", + "cloud-build", + "buildkite", + "drone", + "jenkins", + "dockerfile" + ] + }, + "uniqueItems": true, + "default": [], + "markdownDescription": "Providers to silence. Diagnostics for files matching a disabled provider's path glob are dropped before they reach the editor — useful in a monorepo where Pipeline-Check would otherwise lint a Dockerfile from a sub-project that has its own lint pipeline. Empty by default; everything scans. `dockerfile` covers both `Dockerfile` and `Containerfile`." + }, "pipelineCheck.severityThreshold": { "type": "string", "enum": [ diff --git a/src/codeLens.test.ts b/src/codeLens.test.ts new file mode 100644 index 0000000..5c09adc --- /dev/null +++ b/src/codeLens.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("vscode", async () => { + const { vscodeStub } = await import("./__testStubs__/vscode"); + return vscodeStub(); +}); + +import { composeLensTitle, summariseCounts } from "./codeLens"; + +const diag = (severity?: string, source = "pipeline-check") => + ({ + source, + message: "", + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + severity: 0, + data: severity ? { severity } : undefined, + }) as unknown as import("vscode").Diagnostic; + +describe("summariseCounts", () => { + it("ignores diagnostics whose source is not pipeline-check", () => { + expect(summariseCounts([diag("HIGH", "eslint")])).toEqual({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }); + }); + + it("tallies pipeline-check diagnostics by severity", () => { + expect( + summariseCounts([ + diag("CRITICAL"), + diag("HIGH"), + diag("HIGH"), + diag("LOW"), + ]), + ).toEqual({ CRITICAL: 1, HIGH: 2, MEDIUM: 0, LOW: 1, INFO: 0 }); + }); + + it("falls back to INFO for missing/unknown severity", () => { + expect(summariseCounts([diag(), diag("BOGUS")])).toEqual({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 2, + }); + }); + + it("normalises lowercase severity", () => { + expect(summariseCounts([diag("high")])).toEqual({ + CRITICAL: 0, + HIGH: 1, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }); + }); +}); + +describe("composeLensTitle", () => { + it("returns null on an empty tally so the lens is omitted", () => { + expect( + composeLensTitle({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }), + ).toBeNull(); + }); + + it("lists only nonzero buckets, in severity order", () => { + expect( + composeLensTitle({ + CRITICAL: 2, + HIGH: 0, + MEDIUM: 0, + LOW: 3, + INFO: 0, + }), + ).toBe("Pipeline-Check: 2 critical · 3 low"); + }); + + it("renders a single-bucket tally without separators", () => { + expect( + composeLensTitle({ + CRITICAL: 0, + HIGH: 4, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }), + ).toBe("Pipeline-Check: 4 high"); + }); + + it("lowercases the severity name in the title", () => { + const t = composeLensTitle({ + CRITICAL: 1, + HIGH: 1, + MEDIUM: 1, + LOW: 1, + INFO: 1, + }); + expect(t).toBe( + "Pipeline-Check: 1 critical · 1 high · 1 medium · 1 low · 1 info", + ); + }); +}); diff --git a/src/codeLens.ts b/src/codeLens.ts new file mode 100644 index 0000000..a75e646 --- /dev/null +++ b/src/codeLens.ts @@ -0,0 +1,117 @@ +// File-level CodeLens summarising Pipeline-Check findings at the top +// of each scanned document. Pinned to line 1 so it's visible the +// moment the file opens — same surface that test runners use for +// "Run | Debug" above a test function. +// +// Reads strictly from already-published diagnostics (the LSP's +// stream); never triggers its own scan. The lens command opens the +// Findings panel so the user can drill in. +// +// `summariseCounts` and the lens-text composer are exported as pure +// functions so the test suite can pin the copy without booting the +// editor. + +import * as vscode from "vscode"; + +const DIAGNOSTIC_SOURCE = "pipeline-check"; + +const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] as const; +type SeverityName = (typeof SEVERITY_ORDER)[number]; + +export interface SeverityCounts { + readonly CRITICAL: number; + readonly HIGH: number; + readonly MEDIUM: number; + readonly LOW: number; + readonly INFO: number; +} + +/** + * Tally the per-severity counts of pipeline-check diagnostics in + * `diags`. Falls back to INFO for missing or unknown severity names, + * matching the policy in findingsView.ts. + */ +export function summariseCounts( + diags: readonly vscode.Diagnostic[], +): SeverityCounts { + const counts: { -readonly [K in SeverityName]: number } = { + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }; + for (const d of diags) { + if (d.source !== DIAGNOSTIC_SOURCE) continue; + counts[readSeverity(d)] += 1; + } + return counts; +} + +function readSeverity(diag: vscode.Diagnostic): SeverityName { + const data = (diag as vscode.Diagnostic & { + data?: { severity?: string }; + }).data; + const name = (data?.severity ?? "").toUpperCase(); + return (SEVERITY_ORDER as readonly string[]).includes(name) + ? (name as SeverityName) + : "INFO"; +} + +/** + * Render the lens title from per-severity counts. Examples: + * + * { CRITICAL: 2 } → "Pipeline-Check: 2 critical" + * { CRITICAL: 2, HIGH: 1 } → "Pipeline-Check: 2 critical · 1 high" + * { LOW: 5 } → "Pipeline-Check: 5 low" + * {} → null (caller omits the lens) + * + * Lists only nonzero buckets in severity order so the lens text reads + * top-to-bottom like the Findings tree. + */ +export function composeLensTitle(c: SeverityCounts): string | null { + const parts: string[] = []; + for (const sev of SEVERITY_ORDER) { + if (c[sev] > 0) { + parts.push(`${c[sev]} ${sev.toLowerCase()}`); + } + } + if (parts.length === 0) return null; + return `Pipeline-Check: ${parts.join(" · ")}`; +} + +/** + * CodeLens provider for scanned-document file-level summaries. The + * lens sits at the top of the file (line 0, col 0) and clicking it + * reveals the Findings panel. + * + * Re-emits on every onDidChangeDiagnostics so the lens text tracks + * the latest LSP publish. The vscode runtime debounces lens fetches, + * so we don't have to worry about thrash. + */ +export class FindingsCodeLensProvider implements vscode.CodeLensProvider { + private readonly _onDidChangeCodeLenses = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; + + constructor(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.languages.onDidChangeDiagnostics(() => + this._onDidChangeCodeLenses.fire(), + ), + ); + } + + provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { + const counts = summariseCounts( + vscode.languages.getDiagnostics(document.uri), + ); + const title = composeLensTitle(counts); + if (!title) return []; + return [ + new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), { + title, + command: "pipelineCheck.findings.focus", + }), + ]; + } +} diff --git a/src/extension.ts b/src/extension.ts index 2cf26d7..7098310 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,10 +18,15 @@ import { ServerOptions, TransportKind, } from "vscode-languageclient/node"; +import { FindingsCodeLensProvider } from "./codeLens"; import { FindingsTreeProvider, GroupMode } from "./findingsView"; import * as clientLog from "./log"; import { goToFinding } from "./navigate"; -import { TRIGGER_DOCUMENT_SELECTOR } from "./providers"; +import { + providerForPath, + type ProviderId, + TRIGGER_DOCUMENT_SELECTOR, +} from "./providers"; import { filterByThreshold } from "./severityFilter"; import { registerStatusBar } from "./statusBar"; @@ -117,9 +122,20 @@ function buildClient(): LanguageClient { // pass through unconditionally so the filter never hides // legitimate signal when the metadata is absent. handleDiagnostics: (uri, diagnostics, next) => { - const threshold = vscode.workspace - .getConfiguration("pipelineCheck") - .get("severityThreshold", "low"); + const config = vscode.workspace.getConfiguration("pipelineCheck"); + // Per-provider toggle: if this URI maps to a provider the + // user has disabled, drop every diagnostic for it. We still + // accept the publish (so a future "unset disable" causes a + // fresh publish to reach us), we just blank the list. + const disabled = new Set( + config.get("disabledProviders", []) as ProviderId[], + ); + const provider = providerForPath(uri.fsPath); + if (provider && disabled.has(provider)) { + next(uri, []); + return; + } + const threshold = config.get("severityThreshold", "low"); next(uri, filterByThreshold(diagnostics, threshold)); }, }, @@ -233,6 +249,15 @@ export async function activate( // severity tally. Click reveals the Findings panel. registerStatusBar // pushes the item onto context.subscriptions internally. registerStatusBar(context); + // CodeLens summary at the top of every scanned file. Reads from the + // same diagnostic stream the tree does; click navigates to the + // Findings panel for drill-down. + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + [...TRIGGER_DOCUMENT_SELECTOR], + new FindingsCodeLensProvider(context), + ), + ); context.subscriptions.push( findingsView, vscode.commands.registerCommand("pipelineCheck.findings.refresh", () => diff --git a/src/providers.test.ts b/src/providers.test.ts index 8bcda5e..4cdb625 100644 --- a/src/providers.test.ts +++ b/src/providers.test.ts @@ -4,7 +4,13 @@ import { resolve } from "node:path"; vi.mock("vscode", () => ({})); -import { TRIGGER_PATTERNS, TRIGGER_DOCUMENT_SELECTOR } from "./providers"; +import { + PROVIDER_IDS, + PROVIDERS, + TRIGGER_PATTERNS, + TRIGGER_DOCUMENT_SELECTOR, + providerForPath, +} from "./providers"; describe("TRIGGER_PATTERNS", () => { it("derives a `file`-scoped DocumentFilter for each pattern", () => { @@ -52,3 +58,59 @@ function expandBraces(pattern: string): string[] { const [, head, body, tail] = match; return body.split(",").map((alt) => `${head}${alt}${tail}`); } + +describe("PROVIDERS map", () => { + it("covers every entry in PROVIDER_IDS", () => { + for (const id of PROVIDER_IDS) { + expect(PROVIDERS[id]).toBeDefined(); + expect(PROVIDERS[id].length).toBeGreaterThan(0); + } + }); + + it("TRIGGER_PATTERNS is the union of every provider's patterns", () => { + const flattened = PROVIDER_IDS.flatMap((id) => PROVIDERS[id]); + expect([...TRIGGER_PATTERNS].sort()).toEqual([...flattened].sort()); + }); +}); + +describe("providerForPath", () => { + it("maps GitHub Actions workflow paths", () => { + expect(providerForPath("/repo/.github/workflows/release.yml")).toBe( + "github-actions", + ); + expect(providerForPath("/repo/.github/workflows/ci.yaml")).toBe( + "github-actions", + ); + }); + + it("maps the single-file providers", () => { + expect(providerForPath("/repo/.gitlab-ci.yml")).toBe("gitlab"); + expect(providerForPath("/repo/azure-pipelines.yml")).toBe("azure"); + expect(providerForPath("/repo/bitbucket-pipelines.yml")).toBe("bitbucket"); + expect(providerForPath("/repo/.circleci/config.yml")).toBe("circleci"); + expect(providerForPath("/repo/cloudbuild.yaml")).toBe("cloud-build"); + expect(providerForPath("/repo/.buildkite/pipeline.yml")).toBe("buildkite"); + expect(providerForPath("/repo/.drone.yml")).toBe("drone"); + expect(providerForPath("/repo/.drone.yaml")).toBe("drone"); + expect(providerForPath("/repo/Jenkinsfile")).toBe("jenkins"); + }); + + it("groups Dockerfile and Containerfile under the same id", () => { + expect(providerForPath("/repo/Dockerfile")).toBe("dockerfile"); + expect(providerForPath("/repo/Containerfile")).toBe("dockerfile"); + expect(providerForPath("/repo/build/Dockerfile")).toBe("dockerfile"); + }); + + it("normalises Windows backslashes before matching", () => { + expect(providerForPath("C:\\repo\\.github\\workflows\\ci.yml")).toBe( + "github-actions", + ); + expect(providerForPath("C:\\repo\\Dockerfile")).toBe("dockerfile"); + }); + + it("returns undefined for unmatched paths", () => { + expect(providerForPath("/repo/package.json")).toBeUndefined(); + expect(providerForPath("/repo/mkdocs.yml")).toBeUndefined(); + expect(providerForPath("/repo/values.yaml")).toBeUndefined(); + }); +}); diff --git a/src/providers.ts b/src/providers.ts index 8060ffb..cede47f 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -25,23 +25,49 @@ export interface TriggerSelector { readonly pattern: string; } +export type ProviderId = + | "github-actions" + | "gitlab" + | "azure" + | "bitbucket" + | "circleci" + | "cloud-build" + | "buildkite" + | "drone" + | "jenkins" + | "dockerfile"; + +/** + * Glob patterns indexed by provider id. A provider may map to more + * than one pattern (Dockerfile and Containerfile share the same + * syntax, so they live under "dockerfile"). The keys are what the + * `pipelineCheck.disabledProviders` setting accepts; spelling them + * out as a `Record` keeps the setting's enum in + * lockstep with the patterns. + */ +export const PROVIDERS: Readonly> = { + "github-actions": ["**/.github/workflows/*.{yml,yaml}"], + gitlab: ["**/.gitlab-ci.yml"], + azure: ["**/azure-pipelines.yml"], + bitbucket: ["**/bitbucket-pipelines.yml"], + circleci: ["**/.circleci/config.yml"], + "cloud-build": ["**/cloudbuild.yaml"], + buildkite: ["**/.buildkite/pipeline.yml"], + drone: ["**/.drone.{yml,yaml}"], + jenkins: ["**/Jenkinsfile"], + dockerfile: ["**/Dockerfile", "**/Containerfile"], +}; + +export const PROVIDER_IDS = Object.keys(PROVIDERS) as readonly ProviderId[]; + /** * Glob patterns matching every file the upstream `pipeline_check` - * rule registry knows how to analyse. Order is not load-bearing. + * rule registry knows how to analyse. Derived from `PROVIDERS` so the + * two stay in sync automatically. */ -export const TRIGGER_PATTERNS: readonly string[] = [ - "**/.github/workflows/*.{yml,yaml}", - "**/.gitlab-ci.yml", - "**/azure-pipelines.yml", - "**/bitbucket-pipelines.yml", - "**/.circleci/config.yml", - "**/cloudbuild.yaml", - "**/.buildkite/pipeline.yml", - "**/.drone.{yml,yaml}", - "**/Jenkinsfile", - "**/Dockerfile", - "**/Containerfile", -]; +export const TRIGGER_PATTERNS: readonly string[] = PROVIDER_IDS.flatMap( + (id) => PROVIDERS[id], +); /** * Document-selector form of `TRIGGER_PATTERNS`, suitable for the @@ -51,3 +77,72 @@ export const TRIGGER_PATTERNS: readonly string[] = [ */ export const TRIGGER_DOCUMENT_SELECTOR: readonly TriggerSelector[] = TRIGGER_PATTERNS.map((pattern) => ({ scheme: "file" as const, pattern })); + +/** + * Maps a workspace-relative path to the provider that handles it, or + * `undefined` if no provider matches. Used by the middleware to drop + * diagnostics for files whose provider has been disabled in settings. + * + * Matching is the same minimatch dialect VS Code's `findFiles` and + * `documentSelector` use. Implemented locally with a small glob + * matcher so the function works in both the editor and the unit + * test environment. + */ +export function providerForPath(path: string): ProviderId | undefined { + // Normalise Windows backslashes — globs are POSIX-shaped. + const normalised = path.replace(/\\/g, "/"); + for (const id of PROVIDER_IDS) { + for (const pattern of PROVIDERS[id]) { + if (globMatch(pattern, normalised)) { + return id; + } + } + } + return undefined; +} + +/** + * Tiny glob matcher covering exactly the dialect our patterns use: + * `**` (any number of path segments), `*` (anything but `/`), and + * brace alternatives `{a,b}`. Sufficient for `**\/.github/workflows/*.{yml,yaml}` + * and similar; not a general-purpose minimatch replacement. + */ +function globMatch(pattern: string, path: string): boolean { + // Expand brace alternatives into a list of plain globs. + const branches = expandBraces(pattern); + for (const branch of branches) { + if (toRegex(branch).test(path)) return true; + } + return false; +} + +function expandBraces(pattern: string): string[] { + const match = /^(.*)\{([^{}]+)\}(.*)$/.exec(pattern); + if (!match) return [pattern]; + const [, head, body, tail] = match; + return body + .split(",") + .flatMap((alt) => expandBraces(`${head}${alt}${tail}`)); +} + +function toRegex(pattern: string): RegExp { + // Walk the pattern char by char to translate `**`, `*`, and + // everything else (escaped). `**` matches zero-or-more path + // segments; `*` matches anything but `/`. + let re = ""; + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] === "*" && pattern[i + 1] === "*") { + re += ".*"; + i++; + // Eat an immediately-following `/` so `**/x` matches `x` too. + if (pattern[i + 1] === "/") i++; + } else if (pattern[i] === "*") { + re += "[^/]*"; + } else if (/[.+?^${}()|[\]\\]/.test(pattern[i])) { + re += "\\" + pattern[i]; + } else { + re += pattern[i]; + } + } + return new RegExp(`^${re}$`); +} From 3e8370bc523c53353196b351fadf2a48cd399e84 Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 11:07:13 +0200 Subject: [PATCH 05/11] review-followups-4: @vscode/test-electron integration tests (R17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on review-followups-batch-3 (#13). Boots a real VS Code extension host in CI and verifies the contracts that unit tests can only approximate. Scope deliberately bounded to client-side activation contracts so the suite doesn't depend on Python + pipeline-check[lsp] being installed in CI. Future tests can add an LSP stub if end-to-end diagnostic publishing is worth verifying. Wiring: - .vscode-test.mjs: workspace-folder is test-fixtures/sample-workflow, so `workspaceContains:` activation events fire. - tsconfig.integration.json: compiles src/test/integration/ to out-test/. Inherits the main tsconfig's strict flags; overrides noEmit (the production build is the only no-emit path) and pulls in the mocha + node global types. - tsconfig.json: excludes src/test/integration/ so the main build doesn't try to type-check the mocha suite without mocha globals. - vitest.config.ts: same exclusion so the unit-test suite doesn't pick up mocha-style describe/test files. - package.json: npm run test:integration:compile + test:integration. - ci.yml: Linux-only step `xvfb-run -a npm run test:integration`. Windows / macOS in the matrix still exercise the bundle + smoke + unit suite; the integration suite adds the genuine extension-host contract on top. - .vscodeignore: excludes out-test/, .vscode-test.mjs, tsconfig.integration.json, vitest.config.ts. Test infra never ships in the .vsix. - .gitignore: adds out-test/. (.vscode-test/ was already ignored.) Tests (src/test/integration/activation.test.ts, 5 cases): - Extension is installed and activates. - All six declared commands register (pipelineCheck.restart / showLog / findings.refresh / findings.changeGrouping / goToNextFinding / goToPreviousFinding). - Findings view registers under the activity-bar container (proxied by the auto-generated pipelineCheck.findings.focus command). - Configuration schema exposes every documented setting (serverCommand, serverArgs, severityThreshold, disabledProviders, trace.server). - untrustedWorkspaces capability is "limited" and virtualWorkspaces is false. DevDeps: - @vscode/test-cli, @vscode/test-electron, mocha@^11, @types/mocha. - mocha pinned to ^11 (npm chose mocha@12-beta by default; the beta has unfixed advisories in `diff` and `serialize-javascript`). CI's `npm audit --omit=dev` still returns clean — the remaining dev-only advisories don't reach users. Total: 90 unit tests (unchanged), 5 integration tests. Lint, compile, unit, integration-compile, smoke all green locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 11 + .gitignore | 1 + .vscode-test.mjs | 25 + .vscodeignore | 4 + package-lock.json | 1714 ++++++++++++++++++++++- package.json | 6 + src/test/integration/activation.test.ts | 102 ++ tsconfig.integration.json | 24 + tsconfig.json | 7 +- vitest.config.ts | 11 +- 10 files changed, 1871 insertions(+), 34 deletions(-) create mode 100644 .vscode-test.mjs create mode 100644 src/test/integration/activation.test.ts create mode 100644 tsconfig.integration.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d2f9ba..c398a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,17 @@ jobs: # asserts activate/deactivate are exported. run: npm run smoke + - name: Integration tests (real VS Code) + # @vscode/test-electron boots a real extension host and runs + # the mocha suite under src/test/integration/. Verifies the + # activation / command / view contracts that unit tests can + # only approximate. Linux-only — VS Code needs an X server + # (xvfb-run) on headless runners; Windows/macOS in this + # matrix already exercise the platform-specific paths via the + # unit suite + bundle smoke. + if: matrix.os == 'ubuntu-latest' + run: xvfb-run -a npm run test:integration + - name: npm audit (prod deps, high+) # Network-bound and platform-independent; one run is enough. if: matrix.os == 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index a98bb92..5fd5446 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ web_modules/ # Next.js build output .next out +out-test # Nuxt.js build / generate output .nuxt diff --git a/.vscode-test.mjs b/.vscode-test.mjs new file mode 100644 index 0000000..5650132 --- /dev/null +++ b/.vscode-test.mjs @@ -0,0 +1,25 @@ +// Configuration for `@vscode/test-cli`, which boots a real VS Code +// extension host so we can verify the contracts that unit tests can +// only approximate: that activation actually fires, commands really +// register, the view appears in the activity bar. The compiled tests +// live in `out-test/` (separate from the esbuild bundle in `dist/`) +// and use mocha-bdd-ui — same convention every official VS Code +// extension uses. + +import { defineConfig } from "@vscode/test-cli"; + +export default defineConfig({ + // Compiled test files; `tsconfig.integration.json` emits them. + files: "out-test/test/integration/**/*.test.js", + // Open the deliberately-vulnerable sample workflow as the workspace + // root so activation events fire (workspaceContains:**/.github/workflows/*). + workspaceFolder: "test-fixtures/sample-workflow", + // Tests need the extension's commands and views registered before + // they run; without a sane timeout, mocha may fail before the + // extension finishes activating in slow CI environments. + mocha: { + ui: "bdd", + timeout: 20000, + color: true, + }, +}); diff --git a/.vscodeignore b/.vscodeignore index 297829a..f3a1513 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -6,10 +6,14 @@ test-fixtures/** scripts/** docs/** out/** +out-test/** ROADMAP.md .gitignore .eslintrc.json +.vscode-test.mjs tsconfig.json +tsconfig.integration.json +vitest.config.ts **/*.map **/*.ts **/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 2881c2c..980690f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,17 @@ "vscode-languageclient": "^9.0.1" }, "devDependencies": { + "@types/mocha": "^10.0.10", "@types/node": "^25.9.0", "@types/vscode": "^1.85.0", "@typescript-eslint/eslint-plugin": "^8.59.4", "@typescript-eslint/parser": "^8.59.4", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.9.1", "esbuild": "^0.28.0", "eslint": "^8.56.0", + "mocha": "^11.7.5", "ovsx": "^0.10.12", "typescript": "^6.0.3", "vitest": "^4.1.6" @@ -237,6 +241,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -872,6 +886,26 @@ "node": ">=18" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -879,6 +913,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1208,6 +1253,17 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", @@ -1835,6 +1891,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", @@ -2217,6 +2287,241 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vscode/test-cli": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@vscode/test-cli/-/test-cli-0.0.12.tgz", + "integrity": "sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mocha": "^10.0.10", + "c8": "^10.1.3", + "chokidar": "^3.6.0", + "enhanced-resolve": "^5.18.3", + "glob": "^10.3.10", + "minimatch": "^9.0.3", + "mocha": "^11.7.4", + "supports-color": "^10.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "vscode-test": "out/bin.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vscode/test-cli/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@vscode/test-cli/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@vscode/test-cli/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vscode/test-cli/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/test-cli/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/test-cli/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/@vscode/test-cli/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vscode/test-cli/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/test-cli/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/test-cli/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vscode/test-cli/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@vscode/test-cli/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@vscode/test-cli/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@vscode/vsce": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", @@ -2589,6 +2894,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2662,6 +2981,19 @@ "license": "MIT", "optional": true }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/binaryextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", @@ -2727,6 +3059,13 @@ "node": ">=8" } }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2786,6 +3125,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2827,6 +3200,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2898,28 +3284,110 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cockatiel": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", - "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", - "engines": { + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { "node": ">=16" } }, @@ -2980,6 +3448,13 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3043,6 +3518,19 @@ } } }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3177,6 +3665,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3264,6 +3762,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3323,6 +3828,20 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.21.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.4.tgz", + "integrity": "sha512-wE4fDO8OjJhrPFH69HUQStq5oKvGRTNXEyW+k5C/pUQLASSsTu7obd2V3GvCDgPcY9AWjhJ4jz9Kh7iRvrxhJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3447,6 +3966,16 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3787,6 +4316,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -3919,6 +4458,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4150,6 +4712,16 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -4163,6 +4735,13 @@ "node": ">=10" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -4269,6 +4848,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4336,6 +4922,19 @@ "license": "ISC", "optional": true }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", @@ -4417,6 +5016,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-it-type": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.3.tgz", @@ -4450,6 +5062,29 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4466,6 +5101,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4473,6 +5115,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istextorbinary": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", @@ -4604,6 +5285,52 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4674,6 +5401,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5031,6 +5768,23 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -5054,16 +5808,32 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -5149,6 +5919,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -5231,6 +6014,270 @@ "license": "MIT", "optional": true }, + "node_modules/mocha": { + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mocha/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/mocha/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/mocha/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/mocha/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/mocha/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/mocha/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mocha/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5350,6 +6397,16 @@ "dev": true, "license": "ISC" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -5407,6 +6464,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -5444,6 +6517,140 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/ovsx": { "version": "0.10.12", "resolved": "https://registry.npmjs.org/ovsx/-/ovsx-0.10.12.tgz", @@ -5529,6 +6736,13 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5815,6 +7029,13 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -5884,6 +7105,16 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -5987,6 +7218,29 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6004,7 +7258,24 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/reusify": { @@ -6248,6 +7519,23 @@ "node": ">=10" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6504,6 +7792,19 @@ "dev": true, "license": "MIT" }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6530,6 +7831,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6543,6 +7860,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6637,6 +7968,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -6686,6 +8031,202 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/test-exclude/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/test-exclude/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/test-exclude/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/test-exclude/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/test-exclude/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -6979,8 +8520,22 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } }, "node_modules/validate-npm-package-license": { "version": "3.0.4", @@ -7332,6 +8887,50 @@ "node": ">=0.10.0" } }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -7379,6 +8978,16 @@ "node": ">=4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -7386,6 +8995,51 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/yauzl": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.0.tgz", diff --git a/package.json b/package.json index 05cfd3b..c2f6310 100644 --- a/package.json +++ b/package.json @@ -234,6 +234,8 @@ "lint": "eslint src --ext ts", "test": "vitest run", "test:watch": "vitest", + "test:integration:compile": "tsc -p tsconfig.integration.json", + "test:integration": "npm run bundle:dev && npm run test:integration:compile && vscode-test", "smoke": "node scripts/smoke.js", "package": "vsce package", "publish:vsce": "vsce publish --packagePath", @@ -243,13 +245,17 @@ "vscode-languageclient": "^9.0.1" }, "devDependencies": { + "@types/mocha": "^10.0.10", "@types/node": "^25.9.0", "@types/vscode": "^1.85.0", "@typescript-eslint/eslint-plugin": "^8.59.4", "@typescript-eslint/parser": "^8.59.4", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.9.1", "esbuild": "^0.28.0", "eslint": "^8.56.0", + "mocha": "^11.7.5", "ovsx": "^0.10.12", "typescript": "^6.0.3", "vitest": "^4.1.6" diff --git a/src/test/integration/activation.test.ts b/src/test/integration/activation.test.ts new file mode 100644 index 0000000..6aa17a5 --- /dev/null +++ b/src/test/integration/activation.test.ts @@ -0,0 +1,102 @@ +// Integration tests booted by @vscode/test-electron. These run inside +// a real extension host, so `vscode` is the genuine namespace — no +// stubs, no vi.mock. The unit-test suite (vitest, src/*.test.ts) +// covers pure logic; this file covers the contracts that only a live +// VS Code can verify: extension activation, command registration, +// view registration, configuration schema correctness. +// +// The workspace under test is `test-fixtures/sample-workflow/` (set +// in .vscode-test.mjs). Opening it triggers our `workspaceContains:` +// activation event because of the `.github/workflows/*.yml` fixture +// inside. + +import assert from "node:assert"; +import * as vscode from "vscode"; + +const EXTENSION_ID = "greylag-ci.pipeline-check"; + +suite("Pipeline-Check — activation", () => { + test("extension is installed and activates", async function () { + // VS Code's first boot in a CI environment can be slow. + this.timeout(15000); + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert(ext, `extension ${EXTENSION_ID} not found`); + await ext.activate(); + assert.strictEqual(ext.isActive, true, "extension failed to activate"); + }); + + test("contributes every command declared in package.json", async () => { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert(ext); + await ext.activate(); + const registered = await vscode.commands.getCommands(true); + const expected = [ + "pipelineCheck.restart", + "pipelineCheck.showLog", + "pipelineCheck.findings.refresh", + "pipelineCheck.findings.changeGrouping", + "pipelineCheck.goToNextFinding", + "pipelineCheck.goToPreviousFinding", + ]; + for (const cmd of expected) { + assert.ok( + registered.includes(cmd), + `command ${cmd} did not register`, + ); + } + }); + + test("Findings view is registered under the Pipeline-Check container", async () => { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert(ext); + await ext.activate(); + // VS Code's `.focus` command is auto-generated for every + // registered view. The presence of the command is a proxy for + // "the view registered" without needing private API. + const registered = await vscode.commands.getCommands(true); + assert.ok( + registered.includes("pipelineCheck.findings.focus"), + "Findings view did not register (pipelineCheck.findings.focus missing)", + ); + }); + + test("configuration schema exposes every documented setting", async () => { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert(ext); + await ext.activate(); + const config = vscode.workspace.getConfiguration("pipelineCheck"); + // `inspect` returns metadata about a setting; defaults from the + // package.json schema flow through this API. Used here as a + // smoke-test that the manifest contributions deserialised. + for (const key of [ + "serverCommand", + "serverArgs", + "severityThreshold", + "disabledProviders", + "trace.server", + ]) { + const info = config.inspect(key); + assert.ok( + info !== undefined, + `setting pipelineCheck.${key} is not in the schema`, + ); + } + }); + + test("untrustedWorkspaces capability is declared as 'limited'", () => { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert(ext); + const caps = ext.packageJSON.capabilities; + assert.ok(caps, "capabilities block missing from package.json"); + assert.strictEqual( + caps.untrustedWorkspaces?.supported, + "limited", + "untrustedWorkspaces.supported is not 'limited'", + ); + assert.strictEqual( + caps.virtualWorkspaces, + false, + "virtualWorkspaces should be false (extension spawns a child process)", + ); + }); +}); diff --git a/tsconfig.integration.json b/tsconfig.integration.json new file mode 100644 index 0000000..17afe4b --- /dev/null +++ b/tsconfig.integration.json @@ -0,0 +1,24 @@ +// Companion tsconfig for the @vscode/test-cli integration suite. +// Compiles `src/test/integration/**/*.ts` to `out-test/` as commonjs +// (the format the extension host's mocha runner expects). The +// main tsconfig.json stays noEmit so the esbuild bundle remains the +// single source of production output. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "out-test", + "rootDir": "src", + "noEmit": false, + "noEmitOnError": true, + "sourceMap": true, + // Pull in the mocha globals (`suite`, `test`, etc.) for the + // integration suite. The unit-test (vitest) suite doesn't see this + // tsconfig so its `describe`/`it` imports stay unaffected. + "types": ["mocha", "node"] + }, + "include": ["src/test/integration/**/*"], + // Override the base tsconfig's exclude list — that one excludes + // `src/test/integration/**` to keep the main esbuild bundle clean, + // but this tsconfig is precisely for compiling that directory. + "exclude": ["node_modules", ".vscode-test", "out-test"] +} diff --git a/tsconfig.json b/tsconfig.json index a57d34a..92b6b3a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,10 @@ "skipLibCheck": true }, "include": ["src/**/*"], - "exclude": ["node_modules", ".vscode-test"] + "exclude": [ + "node_modules", + ".vscode-test", + "out-test", + "src/test/integration/**" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 178d643..095bbc1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,10 +2,15 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - // Tests live next to the code they cover (`src/foo.ts` → - // `src/foo.test.ts`); the .vscodeignore strips `src/**` from the - // .vsix so tests never ship. + // Unit-test suite. Pure-logic tests live next to the code they + // cover (`src/foo.ts` → `src/foo.test.ts`); the .vscodeignore + // strips `src/**` from the .vsix so tests never ship. + // + // The integration suite under src/test/integration/ uses mocha + // (booted by @vscode/test-electron) so vitest must not try to + // run those files — different test framework, different runner. include: ["src/**/*.test.ts"], + exclude: ["node_modules", "src/test/integration/**"], // Pure-logic suite — no jsdom needed, and the `vscode` module is // mocked per-file with vi.mock when a test exercises code that // pulls it in. From 2735f77215ce25a96029722b2216920ee23fd30e Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 11:16:01 +0200 Subject: [PATCH 06/11] docs: refresh README, restore CHANGELOG Unreleased, tick ROADMAP Documentation pass consolidating everything across PRs #11-14 into a coherent story for v0.2.0. No code changes; lint, compile, tests, smoke all green. README.md: - New "Features" section above "What it scans" highlighting the surfaces that have landed: inline diagnostics, Findings panel, status bar item, CodeLens summary, keyboard navigation, tunable signal (severityThreshold + disabledProviders). - "Configuration" table gains pipelineCheck.disabledProviders and notes the machine-overridable scope on serverCommand/serverArgs. - New "Commands and keybindings" reference table. - New "Workspace trust" section documenting the limited-trust declaration and the machine-overridable rationale. - "Development" script list expanded to cover test, test:integration, smoke, lint. - "Releasing" section gains the vX.Y.Z-rc.N pre-release convention, the `production` GitHub Environment gate, and the three-OS CI matrix description. - Screenshot HTML comment updated to call out the four shots we want (inline / findings-panel / hover / status-bar). - New "Security" pointer to SECURITY.md. CHANGELOG.md: - Restored ## [Unreleased] block at the top. - Documented all the in-flight work for v0.2.0 under Added / Changed / Fixed, with R-tag cross-references to the roadmap. - Added a callout for the awk release-note extractor's first-`## [`-to-second behaviour, so future release commits don't accidentally ship the Unreleased boilerplate. ROADMAP.md: - "Status snapshot" table at the top mapping v0.1.0/v0.1.1/v0.2.0 state to closed item counts. - 19 of 29 R items ticked with PR cross-references (#11-#14). - Outstanding maintainer action items reorganised: stale v0.1.0-era items removed; current blockers (CodeQL setup, PVR/Discussions toggles, H4 manual smoke, screenshots) called out explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 101 +++++++++++++++++++++++ README.md | 77 +++++++++++++----- ROADMAP.md | 222 +++++++++++++++++++++++++++------------------------ 3 files changed, 275 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e031454..e092bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,107 @@ All notable changes to the Pipeline-Check VS Code extension. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/). +> ⚠ **Release note for publish.yml:** the release-notes extractor (an +> `awk` script in publish.yml) prints every line between the **first** +> and **second** `## [` headers. When cutting a release, fold the +> entries under `## [Unreleased]` into the new `## [X.Y.Z] — ` +> section **above** Unreleased, or remove the Unreleased block for the +> release commit. Otherwise the GitHub release ships boilerplate. + +## [Unreleased] + +PRs landing on `main` between releases append entries here. The +release commit collapses this section into `## [X.Y.Z] — `. + +The current run-up to v0.2.0 has the work below queued — landing +order depends on which PRs in the [#11–#14 stack](https://github.com/greylag-ci/pipeline-check-vscode/pulls) +merge first. + +### Added + +- **Inline CodeLens summary.** Each scanned file carries a + `Pipeline-Check: 2 critical · 1 high` lens at line 1. Click reveals + the Findings panel. Re-emits on every diagnostic publish so the + text tracks the latest scan. (R26) +- **Status bar item.** Bottom-left of the window, shows the top two + non-zero severities (e.g. `$(shield) 3C 1H`) with a tooltip that + breaks down the full per-severity tally. Click reveals the + Findings panel. (R9) +- **Keyboard navigation.** `Alt+F8` / `Shift+Alt+F8` jump between + Pipeline-Check findings in editor order (fsPath ascending, then by + line); wraps at both ends. Mirrors VS Code's `F8` muscle memory + for the global "Next Problem" command. (R12) +- **Per-provider toggles.** New `pipelineCheck.disabledProviders` + setting silences whole providers. `dockerfile` covers both + `Dockerfile` and `Containerfile` (same syntax). Useful in a + monorepo where Pipeline-Check would otherwise lint a sub-project's + Dockerfile that has its own lint pipeline. (R25) +- **Rule documentation link in leaf tooltip.** When the server + publishes a `Diagnostic.code.target` URL, the Findings tree's + leaf tooltip appends a clickable + `$(book) documentation` link below the message body. (R8) +- **Client-side structured logging.** The extension's output channel + now interleaves `[client] HH:MM:SS.mmm` lines around activation + and command invocations with the LSP's `window/logMessage` + traffic. Easier to triage bug reports — start/ok/failed + breadcrumbs land in the same surface users already focus via + *Show language server output*. (R16) +- **Pre-release channel.** Tags like `v0.2.0-rc.1` ship to the + marketplace pre-release channel; the matching GitHub release is + marked `prerelease`. Detection is by the presence of a `-` after + the semver core. (R24) + +### Changed + +- **`@vscode/test-electron` integration suite** now runs in CI + (Linux only, via `xvfb-run -a`). Five tests pin activation, the + command-registration contract, the Findings view registration, + the configuration schema completeness, and the workspace-trust + capability declarations. Catches what unit tests can only + approximate. (R17) +- **Three-OS test matrix** — `[ubuntu-latest, windows-latest, + macos-latest]`. The LSP child-process spawn path is + Windows-sensitive; matrix CI catches the LF/CRLF and + path-separator bugs single-OS CI silently misses. (R21) +- **Activation surface narrowed.** Triggers are + `workspaceContains:` patterns matching the providers we actually + scan (the `documentSelector` uses the same patterns). Opening an + unrelated `package.json` or `mkdocs.yml` no longer wakes up the + extension. (H4) +- **Trigger-pattern list lives in one place.** Extracted into + `src/providers.ts` as a single `PROVIDERS` map; the `documentSelector`, + `activationEvents`, and the LSP middleware's per-provider filter + all read from it. A regression test asserts the manifest's + `activationEvents` stay in lockstep with the patterns. (R14) +- **Shared `vi.mock("vscode")` factory** under `src/__testStubs__/`. + Unit tests now share a single stub instead of redefining the + surface per file. (R18) +- **Marketplace description length** gated in CI at the + 145-character truncation point so future edits don't blow it. (R20) + +### Fixed + +- **`Restart language server` toast no longer fires on failure** — + if the server failed to come up, the error notification already + carries the install hint; the success toast used to fire too, + giving the user contradictory signals. (R2) +- **`stopClient` has a 2-second hard ceiling** on the LSP child's + shutdown. A deadlocked server used to hold the deactivate path + indefinitely; VS Code reported "Window not responding" until the + user force-quit. (R3) +- **`groupByFile` no longer round-trips Uri through string** for + every group node. Bucket value carries the original Uri. (R4) +- **`compareByLocation` sorts on `fsPath`** instead of the full URI + string. Cross-scheme entries (file:// vs untitled://) no longer + bunch at one end. (R5) +- **`collectFindings` is memoised per refresh** — buildRoot and + updateBadge used to walk the global diagnostic store twice per + refresh. (R6) +- **`onDidChangeDiagnostics` skips refreshes from unrelated + publishers.** ESLint / mypy / redhat.yaml keystroke chatter no + longer rebuilds the tree. The skip-check also catches *clears* + (a stale leaf can't outlive a cleared file). (R7) + ## [0.1.1] — 2026-05-19 Production-readiness pass. v0.1.0 was effectively unusable on a clean diff --git a/README.md b/README.md index a8b0991..8353966 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,30 @@ Lint CI/CD pipelines for 22 providers against OWASP Top 10 CI/CD Risks and 14 other compliance frameworks. 810+ rules, inline in your editor: severity-graded gutter squiggles, hover descriptions with `--explain` prose, and recommended-action hints. Built on the same rule registry as the [pipeline-check](https://github.com/dmartinochoa/pipeline-check) CLI, so editor findings match `pipeline_check --output json` byte-for-byte (modulo position translation). +## Features + +- **Inline diagnostics** — gutter squiggles + the Problems panel get a row per finding, severity-graded so CRITICAL and HIGH read red, MEDIUM yellow, LOW info-blue. Hover shows the rule title, the `--explain` prose, and a link to the rule documentation. +- **Findings panel** — dedicated slot in the activity bar with a Pipeline-Check pipeline glyph. Re-groups findings by **severity** (default), **file**, or **rule** via the title-bar **Change Grouping** button; activity-bar icon carries a live count badge. +- **Status bar item** — bottom-left of the window, shows the top two severity counts at a glance (e.g. `🛡 3C 1H`). Click reveals the Findings panel. +- **CodeLens summary** — every scanned file carries a `Pipeline-Check: 2 critical · 1 high` lens at line 1. Click navigates to the Findings panel. +- **Keyboard navigation** — `Alt+F8` / `Shift+Alt+F8` jump between findings, with wrap at both ends. Mirrors VS Code's `F8` for "next problem" so muscle memory carries over. +- **Tunable signal** — `pipelineCheck.severityThreshold` quiets the editor surface (`low` / `medium` / `high` / `critical`) without restarting the server; `pipelineCheck.disabledProviders` silences whole providers in a monorepo where Pipeline-Check would otherwise lint files belonging to a sub-project. + ## What it scans Pilot provider coverage (single-file workflow providers plus Dockerfile): @@ -70,33 +81,50 @@ pip install "pipeline-check[lsp]" | Setting | Default | Description | |---|---|---| -| `pipelineCheck.serverCommand` | `python` | Command used to launch the language server. Override if `pipeline_check` is installed under a different interpreter. | -| `pipelineCheck.serverArgs` | `["-m", "pipeline_check.lsp"]` | Arguments passed to the server command. | +| `pipelineCheck.serverCommand` | `python` | Command used to launch the language server. Override if `pipeline_check` is installed under a different interpreter. Marked `machine-overridable`: workspace overrides require an explicit prompt. | +| `pipelineCheck.serverArgs` | `["-m", "pipeline_check.lsp"]` | Arguments passed to the server command. Marked `machine-overridable` for the same reason. | | `pipelineCheck.severityThreshold` | `low` | Lowest severity that produces a diagnostic. One of `low`, `medium`, `high`, `critical`. Mirrors the CLI's `--severity-threshold`. | +| `pipelineCheck.disabledProviders` | `[]` | Provider IDs to silence entirely. Diagnostics for files matching a disabled provider's path glob are dropped before they reach the editor. One of `github-actions`, `gitlab`, `azure`, `bitbucket`, `circleci`, `cloud-build`, `buildkite`, `drone`, `jenkins`, `dockerfile` (covers Containerfile too). | | `pipelineCheck.trace.server` | `off` | Traces LSP traffic. Set to `verbose` when debugging. | +## Commands and keybindings + +All commands appear in the Command Palette under the **Pipeline-Check** category. + +| Command | Default keybinding | +|---|---| +| **Restart language server** — kills and respawns the LSP process | | +| **Show language server output** — focuses the output channel (LSP server logs + `[client]` client-side breadcrumbs) | | +| **Go to Next Finding** | Alt+F8 | +| **Go to Previous Finding** | Shift+Alt+F8 | +| **Change Grouping** (Findings view) — Quick Pick: Severity / File / Rule | | +| **Refresh** (Findings view) — re-render from the current diagnostic stream | | + +## Workspace trust + +Pipeline-Check spawns the configured Python interpreter to analyze workflow files. To keep that subprocess from running on first-open of a freshly-cloned repository, the extension declares `capabilities.untrustedWorkspaces: "limited"` — it stays inactive until the workspace is trusted. The `serverCommand` / `serverArgs` settings are `machine-overridable`, so a malicious `.vscode/settings.json` can't silently swap the interpreter or inject arbitrary args even after trust is granted. + ## Development ```bash npm install -npm run compile # one-shot compile -npm run watch # rebuild on change +npm run compile # typecheck + esbuild dev bundle +npm run watch # bundle on change +npm test # vitest unit suite +npm run test:integration # @vscode/test-electron — boots a real extension host +npm run smoke # loads dist/extension.js with a vscode stub +npm run lint ``` -Press F5 in VS Code with this folder open to launch an extension-host instance with the extension loaded. Two debug profiles ship in `.vscode/launch.json`: +Press F5 in VS Code with this folder open to launch an extension-host instance with the extension loaded. Two debug profiles ship in [.vscode/launch.json](.vscode/launch.json): - **Run Extension**: opens a fresh window with no workspace. Use this when iterating on the client wiring against a checkout of your own code. - **Run Extension (sample workflow)**: opens `test-fixtures/sample-workflow/` as the workspace. The fixture is a deliberately-vulnerable GitHub Actions workflow and should produce four diagnostics (GHA-001, GHA-004, GHA-015, GHA-016) the moment you open the file. Quickest way to confirm the client → server round-trip works end-to-end. -Two commands are registered in the running extension: - -- **Pipeline-Check: Restart language server**: kills and respawns the LSP process. Useful after editing the Python server in a sibling checkout. -- **Pipeline-Check: Show language server output**: focuses the `Pipeline-Check` output channel where the server's `window/logMessage` traffic lands. - ## Packaging ```bash -npx @vscode/vsce package # produces pipeline-check-.vsix +npm run package # delegates to `vsce package`, produces pipeline-check-.vsix ``` ## Releasing @@ -104,18 +132,23 @@ npx @vscode/vsce package # produces pipeline-check-.vsix Publishing is fully automated by [.github/workflows/publish.yml](.github/workflows/publish.yml). Tag a commit with `vX.Y.Z` matching `package.json#version`, push the tag, and the workflow packages the `.vsix`, publishes to both the VS Code Marketplace and Open VSX, and attaches the artifact to a GitHub Release with the matching `CHANGELOG.md` section as release notes. ```bash -git tag v0.1.0 -git push origin v0.1.0 +git tag v0.1.2 +git push origin v0.1.2 ``` -Two repo secrets gate the publish jobs: +**Tag-naming convention:** + +- `vX.Y.Z` → stable marketplace channel. +- `vX.Y.Z-rc.N` (or any version with a `-` after the semver core) → pre-release channel; the GitHub release is also marked `prerelease`. + +Two repo secrets gate the publish jobs, both stored as **environment secrets** on the `production` GitHub Environment (required reviewer must approve before the publish steps run): | Secret | Where it comes from | |---|---| | `VSCE_PAT` | Azure DevOps PAT scoped to *Marketplace → Manage*, bound to the `greylag-ci` publisher. | | `OVSX_PAT` | Open VSX access token from the user-settings page, bound to the `greylag-ci` namespace. | -Every PR and every push to `main` is gated by [.github/workflows/ci.yml](.github/workflows/ci.yml) on the same three checks the release runs first (lint, type-compile, and a clean `vsce package`), so a contributor whose change cannot ship never makes it past review. +Every PR and every push to `main` is gated by [.github/workflows/ci.yml](.github/workflows/ci.yml) running across `[ubuntu-latest, windows-latest, macos-latest]` with: lint, typecheck, unit tests (vitest), bundle smoke (loads `dist/extension.js` against a `vscode` stub to verify the package is loadable), `npm audit --omit=dev --audit-level=high`, `vsce package`, and on Linux the `@vscode/test-electron` integration suite. Release-day surprises stay rare.
Architecture @@ -133,6 +166,10 @@ The extension spawns `python -m pipeline_check.lsp` as a child process and excha
+## Security + +Report vulnerabilities privately via GitHub's [Private vulnerability reporting](https://github.com/greylag-ci/pipeline-check-vscode/security/advisories/new) — see [SECURITY.md](SECURITY.md) for the response SLA and threat model. + ## License [MIT](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index a666534..be91bf3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,41 +1,44 @@ # Roadmap -Production-readiness work for the Pipeline-Check VS Code extension, queued -from the pre-marketplace security and packaging review. Items are grouped -by severity; tick the box when the change lands on `main`. - -The "must-haves before next publish" set is **C1, C2, H1, H2**. All four -are landed on `prod-ready-hardening`; the publish job is now gated on -the `production` GitHub Environment. - -### Maintainer action items before merging this branch - -1. **Confirm the `production` GitHub Environment is configured** with - required reviewers and that `VSCE_PAT` / `OVSX_PAT` live as - environment secrets (not repo secrets). The workflow now references - `environment: production`; the gate is only as strong as the - environment's review rules. -2. **Disable the default CodeQL setup** so the advanced - [.github/workflows/codeql.yml](.github/workflows/codeql.yml) (which - runs `security-extended`) can upload SARIF. Settings → Code security - → Code scanning → "CodeQL analysis" → click "Set up" / settings → - switch from "Default" to "Advanced" (or disable default outright). - Until this is done, the `analyze` check on PR #1 will fail with - "CodeQL analyses from advanced configurations cannot be processed - when the default setup is enabled". -2. **Enable Private Vulnerability Reporting** on the repo (Settings → - Code security). Without it, the link in [SECURITY.md](SECURITY.md) - 404s and external reporters have nowhere private to file. -3. **Enable Discussions** on the repo (Settings → General → Features). - Without it, the `qna` link in [package.json](package.json) 404s - from the marketplace listing. -4. **Smoke-test the activation narrowing (H4)** — open each provider's - sample workflow in the extension-host window (F5 with sample-workflow - profile) and confirm diagnostics still appear. The change drops - any custom workflow paths. -5. **Verify the published v0.1.0 actually fails to activate** in a - clean VS Code (the C1 hypothesis). If confirmed, this branch - becomes a 0.1.1 hotfix. +Production-readiness work for the Pipeline-Check VS Code extension. The +pre-marketplace security and packaging review (C/H/M/L items below) is +fully landed in v0.1.1. The in-depth code review of 2026-05-19 (R items +at the bottom) is two-thirds landed across PRs #11–14. + +### Status snapshot + +| Layer | State | +|---|---| +| **v0.1.0 → v0.1.1** | Shipped 2026-05-19. C1–C2, H1–H4, M1–M5, L1–L6 all closed. | +| **v0.1.1 → v0.2.0 (in flight)** | R1–R9, R12, R14, R16–R18, R20, R21, R24–R26 landed on stacked PRs #11–#14; merge them in order, then tag. | +| **Blocked** | R10/R15/R29 (need scan-workspace merged), R11 (need suppression-comment syntax), R13/R27 (server-side change), R19 (interactive screenshot session), R22 (eslint-flat-config WIP), R23 (CodeQL setup), R28 (telemetry decision). | + +### Maintainer action items (still outstanding) + +These cannot land from a branch and have been queued since the +production-readiness pass. Each one's failure mode is small enough +that v0.2.0 can ship without them, but the listing improves once +they're done. + +1. **Resolve the CodeQL default-setup conflict.** The advanced + [.github/workflows/codeql.yml](.github/workflows/codeql.yml) runs + `security-extended`; the org's default CodeQL setup conflicts and + the `analyze` check stays red. Settings → Code security → Code + scanning → switch CodeQL from "Default" to "Advanced". If org + policy forbids that, delete `codeql.yml` and lose + `security-extended`. +2. **Enable Private Vulnerability Reporting.** Settings → Code + security. Without it, the link in [SECURITY.md](SECURITY.md) 404s + for external reporters. +3. **Enable Discussions.** Settings → General → Features. Without it, + the `qna` link in [package.json](package.json) 404s on the + marketplace listing. +4. **Manual H4 smoke** — F5 with the sample-workflow profile, open + each provider's trigger file, confirm diagnostics still appear. + The activation narrowing drops custom workflow paths intentionally + but the regression risk is non-zero. +5. **Capture marketplace screenshots** ([R19](#review-pass-2026-05-19--improvements-from-in-depth-code-review)). + Highest-leverage conversion improvement still pending. --- @@ -399,97 +402,106 @@ on their own. The findings below came out of a holistic review of the codebase after v0.1.1 shipped. Categories cluster related work into reviewable PRs. +PR landing order (all stacked on `main`): +- **#11** `review-followups` — R1–R9, R21 +- **#12** `review-followups-batch-2` — R12, R14, R16, R18, R20 +- **#13** `review-followups-batch-3` — R24, R25, R26 +- **#14** `review-followups-batch-4` — R17 + +Total: 19 of 29 review items landed; the rest are blocked on external +inputs (suppression syntax, screenshots) or stacked branches +(scan-workspace). + ### Code-level fixes (cheap wins) -- [ ] **R1** Reorder the `filterByThreshold` import in - [src/extension.ts:72](src/extension.ts#L72) up to the rest of the - import block. Currently sits after a function declaration. -- [ ] **R2** "Restart language server" toast at - [src/extension.ts:223](src/extension.ts#L223) fires even when - `startClient()` failed. Guard with `if (client)`. -- [ ] **R3** Add a timeout to `stopClient()` - ([src/extension.ts:185-192](src/extension.ts#L185-L192)) — - a deadlocked LSP child holds the deactivate path indefinitely. -- [ ] **R4** `groupByFile` does `key = f.uri.toString()` then - `vscode.Uri.parse(key)` to get the Uri back - ([src/findingsView.ts:357-385](src/findingsView.ts#L357-L385)). - Use a `Map` keyed on the Uri directly. -- [ ] **R5** `compareByLocation` - ([src/findingsView.ts:413](src/findingsView.ts#L413)) sorts on - `uri.toString()` which includes the `file://` scheme prefix. - Sort on `fsPath` instead. +- [x] **R1** Reorder the `filterByThreshold` import in extension.ts up + to the rest of the import block. (PR #11) +- [x] **R2** "Restart language server" toast no longer fires when + `startClient()` failed. (PR #11) +- [x] **R3** `stopClient()` races the LSP shutdown against a 2-second + timer; dispose explicitly on timeout. (PR #11) +- [x] **R4** `groupByFile` carries the original Uri alongside the + string key; no `Uri.parse` round-trip. (PR #11) +- [x] **R5** `compareByLocation` sorts on `fsPath`. (PR #11) ### Performance -- [ ] **R6** Cache `collectFindings()` per refresh. Currently called - twice per refresh (`buildRoot` + `updateBadge`); each walks every - diagnostic in the workspace. -- [ ] **R7** Filter `onDidChangeDiagnostics` by the event's `uris` - payload — skip the refresh when none of the changed URIs carry a - pipeline-check diagnostic. +- [x] **R6** `collectFindings()` memoised behind a per-refresh cache. + (PR #11) +- [x] **R7** `onDidChangeDiagnostics` skips refreshes whose URI batch + doesn't touch a pipeline-check diagnostic — plus a + `lastFindingUris` set so cleared findings still trigger a + refresh. (PR #11) ### UX gaps -- [ ] **R8** Wire `Diagnostic.code.target` into the leaf tooltip. The - server publishes the rule's docs URL; we read `code.value` and - throw the URL away. Add a docs link at the bottom of the leaf - tooltip (set `isTrusted = true` on the `MarkdownString`). -- [ ] **R9** Status bar item showing critical/high counts. Click - reveals the Findings panel. -- [ ] **R10** Rename `pipelineCheck.findings.refresh` — re-renders - from current diagnostics, doesn't trigger a fresh scan. Once - scan-workspace lands, have refresh call `scanWorkspace()`. -- [ ] **R11** `CodeAction` provider for suppression comments. - Right-click a finding → "Suppress GHA-001 for this line" that - inserts the CLI's suppression syntax. -- [ ] **R12** "Go to next/previous finding" commands with keybindings. -- [ ] **R13** Set `Diagnostic.tags` for `Deprecated` / - `Unnecessary` where the rule indicates it. Server-side change. +- [x] **R8** Leaf tooltip appends a `$(book) documentation` + link when the server publishes `Diagnostic.code.target`. (PR #11) +- [x] **R9** Status bar item on the left at priority 100 showing the + top two non-zero severities (e.g. `$(shield) 3C 1H`). (PR #11) +- [ ] **R10** Rename / repurpose `pipelineCheck.findings.refresh` to + call `scanWorkspace()` once the scan-workspace branch lands. + *(Blocked on scan-workspace merging.)* +- [ ] **R11** `CodeAction` provider for suppression comments. *(Blocked + on the upstream pipeline-check CLI's suppression syntax.)* +- [x] **R12** Alt+F8 / Shift+Alt+F8 jump between findings, wrap at + both ends. (PR #12) +- [ ] **R13** Set `Diagnostic.tags` for `Deprecated` / `Unnecessary` + where the rule indicates it. *(Server-side change — file + upstream.)* ### Architecture -- [ ] **R14** Extract the candidate-file pattern list from - `activationEvents`, `documentSelector`, and (post-merge) - `SCAN_PATTERNS` into a single shared TS module. Today the list - lives in three places. -- [ ] **R15** Add `onCommand:pipelineCheck.scanWorkspace` to - `activationEvents` so opening an isolated file from outside the - workspace can still trigger activation. -- [ ] **R16** Client-side structured logging into the output channel - with a `[client]` prefix. Breadcrumbs for bug reports. +- [x] **R14** Trigger-pattern list extracted into `src/providers.ts` + (`PROVIDERS` map + `TRIGGER_PATTERNS`). A regression test asserts + the package.json `activationEvents` stay in lockstep. (PR #12) +- [ ] **R15** `onCommand:pipelineCheck.scanWorkspace` activation + event. *(Blocked on scan-workspace merging.)* +- [x] **R16** `[client] HH:MM:SS.mmm ` logging into the + LanguageClient's outputChannel. `withTiming(label, fn)` wraps + thunks with start/ok/failed breadcrumbs. (PR #12) ### Testing -- [ ] **R17** `@vscode/test-electron` integration tests covering real - LSP publish and tree render. -- [ ] **R18** Extract the `vi.mock("vscode", ...)` factory into a - shared `src/__testStubs__/vscode.ts`. +- [x] **R17** `@vscode/test-electron` integration suite covering + activation, command registration, view registration, settings + schema, and the workspace-trust capability. (PR #14) +- [x] **R18** `vi.mock("vscode")` factory extracted into + `src/__testStubs__/vscode.ts`. (PR #12) ### Marketplace -- [ ] **R19** **Ship the screenshots** the HTML comment at - [README.md:13-25](README.md#L13-L25) has been waiting for since - v0.1.0. Highest-leverage marketplace conversion improvement. -- [ ] **R20** CI check that `package.json#description` stays ≤ 145 - characters (the marketplace-search truncation limit). +- [ ] **R19** **Ship the screenshots** the HTML comment in README.md + has been waiting for since v0.1.0. *(Needs an interactive + VS Code session.)* See [docs/screenshots/README.md](docs/screenshots/README.md) + for the capture recipe. +- [x] **R20** CI fails the build if `package.json#description` + exceeds 145 characters. Today's description is 141 chars. (PR #12) ### CI / release -- [ ] **R21** Test matrix: - `[ubuntu-latest, windows-latest, macos-latest]`. The - `LF → CRLF` warnings on every git operation hint at the bugs. -- [ ] **R22** Finish the eslint flat-config migration so the - drift between eslint v8 and TS 6 / esbuild 0.28 stops widening. +- [x] **R21** Three-OS matrix: `[ubuntu-latest, windows-latest, + macos-latest]`. `npm audit` and the vsix upload pinned to + Linux. (PR #11) +- [ ] **R22** Finish the eslint flat-config migration so drift between + eslint v8 and TS 6 / esbuild 0.28 / @types/node 25 stops + widening. *(WIP stash on the maintainer's `pr10` checkout.)* - [ ] **R23** Resolve the CodeQL default-setup conflict — disable - default setup or delete codeql.yml. Don't ship with a - permanently-red required check. -- [ ] **R24** `vsce publish --pre-release` channel for the next - minor bump. + default setup or delete `codeql.yml`. *(Needs repo-settings + change.)* +- [x] **R24** Pre-release channel via tag naming + (`vX.Y.Z-rc.N` → pre-release). (PR #13) ### Strategic -- [ ] **R25** Per-provider toggles in settings. -- [ ] **R26** Inline `CodeLens` summarising findings per file. -- [ ] **R27** Workspace-level config file shared with the CLI. +- [x] **R25** `pipelineCheck.disabledProviders` setting silences + providers wholesale. (PR #13) +- [x] **R26** Inline `CodeLens` summary at the top of each scanned + file. (PR #13) +- [ ] **R27** Workspace-level config file (`.pipeline-check.toml`) + shared with the CLI. *(Needs upstream coordination.)* - [ ] **R28** Opt-in telemetry (`vscode.env.isTelemetryEnabled`). -- [ ] **R29** Scan-on-save mode. + *(Privacy-sensitive design decision; deferred pending product + direction.)* +- [ ] **R29** Scan-on-save mode. *(Depends on scan-workspace + merging.)* From 60ad37a9cb7deae6f93c14c8f4919c1e71c13a6c Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 11:44:05 +0200 Subject: [PATCH 07/11] ux-polish: discovery, accessibility, context menus, toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on docs-polish (#15). Lands the five UX changes from the usability review: 1. First-run discovery — `onStartupFinished` activation - package.json: adds `onStartupFinished` to activationEvents so the activity-bar slot appears in every workspace, not just ones with a `workspaceContains:` match. The LSP child still only spawns when the documentSelector matches an open document, so there's no idle-Python-process cost. - viewsWelcome rewritten as teaching content with progressive disclosure: what the extension does → Python install hint with a one-click Copy command → editor onboarding → Alt+F8 keyboard hint → `---` separator → recovery actions (Restart, Open Log) demoted. 2. Right-click context menu on Findings tree leaves - New commands pipelineCheck.findings.copyRuleId and pipelineCheck.findings.openRuleDocs. Both gated to the leaf context (viewItem == pipelineCheck.finding); commandPalette `when: false` so they don't show up palette-wide without a node. - New top-level pipelineCheck.copyInstallCommand exposed for the welcome-state link and the palette — re-findable after the first-run error toast is dismissed. - extension.ts: registers all three. LeafLike structural type declared locally so extension.ts stays independent of findingsView's internal LeafNode definition. 3. Status bar — accessibility + conditional show - formatStatusBarAccessibilityLabel: full-word per-severity tally ("3 critical, 1 high") read by screen readers instead of the codicon shortcode + letter-by-letter abbreviation. - registerStatusBar: hides the item until the workspace shows evidence of being CI-relevant (either a `findFiles` match for the trigger globs or an actual diagnostic publish). The `relevant` flag latches so a "clean" workspace that USED to have findings keeps the positive signal — it just doesn't shout at frontend projects that happen to have Pipeline-Check installed. - formatStatusBarTooltip: appends "Alt+F8 / Shift+Alt+F8 to step through findings" as the trailing line — primary discovery surface for the navigation keybindings (omitted when there are zero findings). 4. Command title-case consistency - "Restart language server" → "Restart Language Server" - "Show language server output" → "Show Language Server Output" - "Refresh" → "Refresh Findings" - Existing title-case commands (Go to Next/Previous Finding, Change Grouping) unchanged. Command IDs unchanged; settings, keybindings, automation keep working. 5. pipelineCheck.codeLens.enabled setting - Defaults true. Hides the line-1 file-summary CodeLens without disabling CodeLens globally. - codeLens.ts: reads the setting on every provideCodeLenses call so a flip takes effect on the next render. The provider also subscribes to onDidChangeConfiguration and re-fires the change event when the setting flips, so the editor drops the lens immediately. Test stub - src/__testStubs__/vscode.ts gains getConfiguration backed by globalThis.__stubConfig (same pattern as __stubDiagnostics), plus Range and CodeLens classes so the toggle test can construct the provider end-to-end. onDidChangeConfiguration stub added. Tests - statusBar.test.ts: 4 new tests for formatStatusBarAccessibilityLabel (clean / per-severity / single-bucket / no-codicons), 2 new for formatStatusBarTooltip (Alt+F8 keyboard hint present / absent). - codeLens.test.ts: 3 new tests for the FindingsCodeLensProvider toggle behaviour (enabled-default emits, false drops to [], no diagnostics drops to [] regardless). - Integration test activation.test.ts: command-registration list grows by the three new commands. Total: 99 unit tests pass (was 90). Lint, compile, smoke, integration- compile all clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 46 +++++++++++++ package.json | 49 ++++++++++++-- src/__testStubs__/vscode.ts | 39 +++++++++++ src/codeLens.test.ts | 69 ++++++++++++++++++- src/codeLens.ts | 16 +++++ src/extension.ts | 56 ++++++++++++++++ src/statusBar.test.ts | 75 +++++++++++++++++++++ src/statusBar.ts | 88 ++++++++++++++++++++++--- src/test/integration/activation.test.ts | 3 + 9 files changed, 427 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e092bbb..181d6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,9 +53,55 @@ merge first. marketplace pre-release channel; the matching GitHub release is marked `prerelease`. Detection is by the presence of a `-` after the semver core. (R24) +- **Right-click context menu on Findings tree leaves.** *Open Rule + Documentation* opens the URL the server published via + `Diagnostic.code.target` in the system browser; *Copy Rule ID* + writes the rule's identifier to the clipboard. Same data the leaf + tooltip already surfaces, now available without keeping the + tooltip open. +- **`pipelineCheck.codeLens.enabled` setting.** Defaults to `true`. + Hides the line-1 file-summary CodeLens for users who find it + intrusive without disabling CodeLens globally. Toggle takes effect + on the next render — no extension restart. +- **`pipelineCheck.copyInstallCommand` command.** Copies + `pip install "pipeline-check[lsp]"` to the clipboard. Surfaced + from the Findings welcome state and from the Command Palette so + users can re-find it after dismissing the first-run error toast. ### Changed +- **Welcome state of the Findings panel teaches.** Now leads with + what Pipeline-Check does + a *Copy install command* link for the + Python `[lsp]` extra, then onboarding ("open a workflow…"), then + the Alt+F8 / Shift+Alt+F8 keyboard hint, then a `---` separator + and the recovery actions (Restart, Open Log) demoted below. +- **`onStartupFinished` activation event.** The extension now wakes + up after VS Code's start-up barrier so the activity-bar slot is + visible in every workspace — not just ones with a + `workspaceContains:` match. The LSP child process still only + spawns when the `documentSelector` matches an open document, so + there's no idle-Python-process cost. +- **Status bar item hides in non-CI workspaces.** On activation we + do a one-shot `findFiles` for any of the trigger patterns; the + status bar item only shows once we've seen evidence the workspace + is CI-relevant (either a match or an actual diagnostic publish). + Stops `$(shield) clean` cluttering the bottom-left in frontend + projects that happen to have Pipeline-Check installed alongside + other linters. +- **Status bar accessibility label.** Screen readers now hear + "Pipeline-Check: 3 critical, 1 high" instead of the codicon + shortcode + letter-by-letter abbreviation. +- **Status bar tooltip teaches Alt+F8.** The trailing line of the + tooltip ("Alt+F8 / Shift+Alt+F8 to step through findings") is the + primary discovery surface for the navigation keybindings. +- **Command titles use title case** for VS Code's convention: + "Restart Language Server", "Show Language Server Output", + "Refresh Findings". Existing "Go to Next Finding" and "Change + Grouping" stay the same. Command IDs are unchanged — settings, + keybindings, and automation continue to work. + +### Changed (release tooling) + - **`@vscode/test-electron` integration suite** now runs in CI (Linux only, via `xvfb-run -a`). Five tests pin activation, the command-registration contract, the Findings view registration, diff --git a/package.json b/package.json index c2f6310..d2ed7ec 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "homepage": "https://github.com/greylag-ci/pipeline-check-vscode#readme", "main": "./dist/extension.js", "activationEvents": [ + "onStartupFinished", "workspaceContains:**/.github/workflows/*.yml", "workspaceContains:**/.github/workflows/*.yaml", "workspaceContains:**/.gitlab-ci.yml", @@ -84,23 +85,28 @@ "viewsWelcome": [ { "view": "pipelineCheck.findings", - "contents": "Pipeline-Check scans CI/CD configurations for OWASP Top 10 CI/CD risks and 14 other compliance frameworks.\n\nOpen a workflow, Dockerfile, or other supported config and findings will appear here.\n\nNot seeing findings on a file you expect to be scanned? [Restart the language server](command:pipelineCheck.restart) or [view the log](command:pipelineCheck.showLog)." + "contents": "Pipeline-Check scans CI/CD configurations against OWASP Top 10 CI/CD risks and 14 other compliance frameworks.\n\nRequires Python + `pipeline-check[lsp]` on PATH.\n[Copy install command](command:pipelineCheck.copyInstallCommand)\n\nOpen a workflow, Dockerfile, or other supported config and findings will appear here.\n\nPress `Alt+F8` (or `Shift+Alt+F8`) to step through findings in the editor.\n\n---\n\nNot seeing expected findings?\n[Restart Language Server](command:pipelineCheck.restart) · [Open Log](command:pipelineCheck.showLog)" } ], "commands": [ { "command": "pipelineCheck.restart", - "title": "Restart language server", + "title": "Restart Language Server", "category": "Pipeline-Check" }, { "command": "pipelineCheck.showLog", - "title": "Show language server output", + "title": "Show Language Server Output", + "category": "Pipeline-Check" + }, + { + "command": "pipelineCheck.copyInstallCommand", + "title": "Copy LSP Install Command", "category": "Pipeline-Check" }, { "command": "pipelineCheck.findings.refresh", - "title": "Refresh", + "title": "Refresh Findings", "category": "Pipeline-Check", "icon": "$(refresh)" }, @@ -110,6 +116,16 @@ "category": "Pipeline-Check", "icon": "$(list-tree)" }, + { + "command": "pipelineCheck.findings.copyRuleId", + "title": "Copy Rule ID", + "category": "Pipeline-Check" + }, + { + "command": "pipelineCheck.findings.openRuleDocs", + "title": "Open Rule Documentation", + "category": "Pipeline-Check" + }, { "command": "pipelineCheck.goToNextFinding", "title": "Go to Next Finding", @@ -146,6 +162,18 @@ "group": "navigation@9" } ], + "view/item/context": [ + { + "command": "pipelineCheck.findings.openRuleDocs", + "when": "view == pipelineCheck.findings && viewItem == pipelineCheck.finding", + "group": "navigation@1" + }, + { + "command": "pipelineCheck.findings.copyRuleId", + "when": "view == pipelineCheck.findings && viewItem == pipelineCheck.finding", + "group": "1_copy@1" + } + ], "commandPalette": [ { "command": "pipelineCheck.findings.refresh", @@ -154,6 +182,14 @@ { "command": "pipelineCheck.findings.changeGrouping", "when": "view == pipelineCheck.findings" + }, + { + "command": "pipelineCheck.findings.copyRuleId", + "when": "false" + }, + { + "command": "pipelineCheck.findings.openRuleDocs", + "when": "false" } ] }, @@ -200,6 +236,11 @@ "default": [], "markdownDescription": "Providers to silence. Diagnostics for files matching a disabled provider's path glob are dropped before they reach the editor — useful in a monorepo where Pipeline-Check would otherwise lint a Dockerfile from a sub-project that has its own lint pipeline. Empty by default; everything scans. `dockerfile` covers both `Dockerfile` and `Containerfile`." }, + "pipelineCheck.codeLens.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Show the `Pipeline-Check: N critical · M high` summary CodeLens at the top of each scanned file. Turn off if you find the line-1 lens intrusive — the editor gutter, the Findings panel, and the status bar still surface the same counts." + }, "pipelineCheck.severityThreshold": { "type": "string", "enum": [ diff --git a/src/__testStubs__/vscode.ts b/src/__testStubs__/vscode.ts index 838b62c..f2834e7 100644 --- a/src/__testStubs__/vscode.ts +++ b/src/__testStubs__/vscode.ts @@ -73,6 +73,27 @@ export function vscodeStub(): Record { const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; const StatusBarAlignment = { Left: 1, Right: 2 }; + class Range { + constructor( + public readonly startLine: number, + public readonly startCharacter: number, + public readonly endLine: number, + public readonly endCharacter: number, + ) {} + get start() { + return { line: this.startLine, character: this.startCharacter }; + } + get end() { + return { line: this.endLine, character: this.endCharacter }; + } + } + class CodeLens { + constructor( + public readonly range: Range, + public readonly command?: { title: string; command: string }, + ) {} + } + return { ThemeIcon, ThemeColor, @@ -81,10 +102,28 @@ export function vscodeStub(): Record { MarkdownString, TreeItemCollapsibleState, StatusBarAlignment, + Range, + CodeLens, Uri, workspace: { asRelativePath: (uri: { fsPath?: string; path?: string }) => uri.fsPath ?? uri.path ?? "", + // `getConfiguration(section).get(key, fallback)` reads from + // `globalThis.__stubConfig`, a `Record` keyed + // by `
.` (or just `` if no section was + // passed). Tests set the dictionary in beforeEach so each + // test's expectations are isolated. + getConfiguration: (section?: string) => ({ + get: (key: string, fallback?: T): T => { + const store = + (globalThis as { __stubConfig?: Record }) + .__stubConfig ?? {}; + const fullKey = section ? `${section}.${key}` : key; + if (fullKey in store) return store[fullKey] as T; + return fallback as T; + }, + }), + onDidChangeConfiguration: () => ({ dispose: () => undefined }), }, languages: { // Two call shapes: diff --git a/src/codeLens.test.ts b/src/codeLens.test.ts index 5c09adc..1b961d9 100644 --- a/src/codeLens.test.ts +++ b/src/codeLens.test.ts @@ -1,11 +1,15 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("vscode", async () => { const { vscodeStub } = await import("./__testStubs__/vscode"); return vscodeStub(); }); -import { composeLensTitle, summariseCounts } from "./codeLens"; +import { + FindingsCodeLensProvider, + composeLensTitle, + summariseCounts, +} from "./codeLens"; const diag = (severity?: string, source = "pipeline-check") => ({ @@ -112,3 +116,64 @@ describe("composeLensTitle", () => { ); }); }); + +describe("FindingsCodeLensProvider — pipelineCheck.codeLens.enabled toggle", () => { + // The provider reads `pipelineCheck.codeLens.enabled` on every + // `provideCodeLenses` call so a settings flip takes effect on the + // next render — no extension restart, no editor reopen. These + // tests pin that behaviour with the shared vscode stub's + // `getConfiguration` reading from `globalThis.__stubConfig`. + + const ctx = { + subscriptions: [] as Array<{ dispose: () => void }>, + } as unknown as import("vscode").ExtensionContext; + + const document = { + uri: { + toString: () => "file:///a.yml", + fsPath: "/a.yml", + path: "/a.yml", + }, + } as unknown as import("vscode").TextDocument; + + beforeEach(() => { + (globalThis as { __stubDiagnostics?: unknown }).__stubDiagnostics = [ + [ + { toString: () => "file:///a.yml" }, + [ + { + source: "pipeline-check", + message: "", + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + severity: 0, + data: { severity: "CRITICAL" }, + }, + ], + ], + ]; + (globalThis as { __stubConfig?: Record }).__stubConfig = {}; + }); + + it("emits a lens when codeLens.enabled is true (default)", () => { + const p = new FindingsCodeLensProvider(ctx); + const lenses = p.provideCodeLenses(document) as unknown[]; + expect(lenses).toHaveLength(1); + }); + + it("emits no lens when codeLens.enabled is false", () => { + (globalThis as { __stubConfig?: Record }).__stubConfig = { + "pipelineCheck.codeLens.enabled": false, + }; + const p = new FindingsCodeLensProvider(ctx); + expect(p.provideCodeLenses(document)).toEqual([]); + }); + + it("emits no lens when there are no pipeline-check diagnostics, even if enabled", () => { + (globalThis as { __stubDiagnostics?: unknown }).__stubDiagnostics = []; + const p = new FindingsCodeLensProvider(ctx); + expect(p.provideCodeLenses(document)).toEqual([]); + }); +}); diff --git a/src/codeLens.ts b/src/codeLens.ts index a75e646..d989681 100644 --- a/src/codeLens.ts +++ b/src/codeLens.ts @@ -98,10 +98,26 @@ export class FindingsCodeLensProvider implements vscode.CodeLensProvider { vscode.languages.onDidChangeDiagnostics(() => this._onDidChangeCodeLenses.fire(), ), + // The `pipelineCheck.codeLens.enabled` setting toggles whether + // we emit lenses at all. When it flips, re-fire so the editor + // either picks up or drops the lens immediately, with no + // restart required. + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("pipelineCheck.codeLens.enabled")) { + this._onDidChangeCodeLenses.fire(); + } + }), ); } provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { + // Honour the per-extension toggle. Users who find the line-1 lens + // intrusive can hide it without disabling CodeLens globally. + const enabled = vscode.workspace + .getConfiguration("pipelineCheck") + .get("codeLens.enabled", true); + if (!enabled) return []; + const counts = summariseCounts( vscode.languages.getDiagnostics(document.uri), ); diff --git a/src/extension.ts b/src/extension.ts index 7098310..29f931e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -83,6 +83,17 @@ const LANGUAGE_ID = "pipelineCheck"; const LANGUAGE_NAME = "Pipeline-Check"; const OUTPUT_CHANNEL = "Pipeline-Check"; +// Structural shape of a Findings-tree leaf node, used by the +// context-menu commands. The real LeafNode lives in findingsView.ts; +// duplicating just the fields the commands read keeps extension.ts +// independent of the tree's internal type definitions. +type LeafLike = { + readonly finding?: { + readonly ruleId?: string; + readonly docsUrl?: string; + }; +}; + let client: LanguageClient | undefined; function buildClient(): LanguageClient { @@ -267,6 +278,51 @@ export async function activate( "pipelineCheck.findings.changeGrouping", () => changeGrouping(findingsProvider), ), + // Context-menu entries on a Findings tree leaf. VS Code passes the + // TreeNode as the first argument; we read the `finding` shape off + // it. Both commands are gated behind `viewItem == pipelineCheck.finding` + // in package.json so the node is always a leaf when these fire. + vscode.commands.registerCommand( + "pipelineCheck.findings.copyRuleId", + async (node: LeafLike | undefined) => { + const id = node?.finding?.ruleId?.trim(); + if (!id) { + void vscode.window.showInformationMessage( + "Pipeline-Check: this finding has no rule ID.", + ); + return; + } + await vscode.env.clipboard.writeText(id); + void vscode.window.showInformationMessage(`Copied ${id} to clipboard.`); + }, + ), + vscode.commands.registerCommand( + "pipelineCheck.findings.openRuleDocs", + async (node: LeafLike | undefined) => { + const url = node?.finding?.docsUrl?.trim(); + if (!url) { + void vscode.window.showInformationMessage( + "Pipeline-Check: no documentation URL was published for this rule.", + ); + return; + } + await vscode.env.openExternal(vscode.Uri.parse(url)); + }, + ), + // Copy-install-command also lives in the welcome-state and is + // promoted to a top-level command so users can re-find it after + // dismissing the first-run notification. + vscode.commands.registerCommand( + "pipelineCheck.copyInstallCommand", + async () => { + await vscode.env.clipboard.writeText( + 'pip install "pipeline-check[lsp]"', + ); + void vscode.window.showInformationMessage( + 'Copied: pip install "pipeline-check[lsp]"', + ); + }, + ), vscode.commands.registerCommand("pipelineCheck.goToNextFinding", () => goToFinding("next"), ), diff --git a/src/statusBar.test.ts b/src/statusBar.test.ts index b8d5a48..7bed029 100644 --- a/src/statusBar.test.ts +++ b/src/statusBar.test.ts @@ -12,6 +12,7 @@ vi.mock("vscode", () => ({ import { countDiagnostics, + formatStatusBarAccessibilityLabel, formatStatusBarText, formatStatusBarTooltip, } from "./statusBar"; @@ -143,3 +144,77 @@ describe("countDiagnostics", () => { ).toEqual({ CRITICAL: 1, HIGH: 1, MEDIUM: 0, LOW: 0, INFO: 0 }); }); }); + +describe("formatStatusBarAccessibilityLabel", () => { + it("returns a clean message when there are no findings", () => { + expect( + formatStatusBarAccessibilityLabel({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }), + ).toBe("Pipeline-Check: no findings"); + }); + + it("spells out the per-severity tally with full words", () => { + expect( + formatStatusBarAccessibilityLabel({ + CRITICAL: 3, + HIGH: 1, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }), + ).toBe("Pipeline-Check: 3 critical, 1 high"); + }); + + it("omits zero buckets so the label stays scannable", () => { + expect( + formatStatusBarAccessibilityLabel({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 5, + INFO: 0, + }), + ).toBe("Pipeline-Check: 5 low"); + }); + + it("contains no codicon shortcodes (screen readers can't read $(shield))", () => { + const label = formatStatusBarAccessibilityLabel({ + CRITICAL: 1, + HIGH: 1, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }); + expect(label).not.toMatch(/\$\(/); + }); +}); + +describe("formatStatusBarTooltip", () => { + it("teaches the Alt+F8 keyboard shortcut on the trailing line", () => { + const tip = formatStatusBarTooltip({ + CRITICAL: 1, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }); + expect(tip).toContain("Alt+F8"); + expect(tip).toContain("Shift+Alt+F8"); + }); + + it("does not include the keyboard hint when there are no findings", () => { + const tip = formatStatusBarTooltip({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }); + expect(tip).not.toContain("Alt+F8"); + }); +}); diff --git a/src/statusBar.ts b/src/statusBar.ts index f818613..705437a 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -69,6 +69,9 @@ export function formatStatusBarText(c: SeverityCounts): string { * Render the tooltip — a longer breakdown shown on hover. Always * lists every nonzero bucket, so the abbreviated bar text never * hides information; just makes it less prominent. + * + * The trailing hint ("Click… Alt+F8…") doubles as keyboard-shortcut + * discovery: most users find Alt+F8 here, not by reading the README. */ export function formatStatusBarTooltip(c: SeverityCounts): string { const total = c.CRITICAL + c.HIGH + c.MEDIUM + c.LOW + c.INFO; @@ -84,9 +87,37 @@ export function formatStatusBarTooltip(c: SeverityCounts): string { } } lines.push("Click to open the Findings panel."); + lines.push("Alt+F8 / Shift+Alt+F8 to step through findings."); return lines.join("\n"); } +/** + * Render a screen-reader-friendly version of the status bar text. + * Codicons like ``$(shield)`` are read aloud as "shield" by some + * narrators and skipped by others; the abbreviation "3C 1H" reads + * as letter-by-letter spelling. The accessible label uses full + * words for both the icon role and the counts. + */ +export function formatStatusBarAccessibilityLabel(c: SeverityCounts): string { + const total = c.CRITICAL + c.HIGH + c.MEDIUM + c.LOW + c.INFO; + if (total === 0) { + return "Pipeline-Check: no findings"; + } + const parts: string[] = []; + for (const [name, count] of [ + ["critical", c.CRITICAL], + ["high", c.HIGH], + ["medium", c.MEDIUM], + ["low", c.LOW], + ["info", c.INFO], + ] as const) { + if (count > 0) { + parts.push(`${count} ${name}`); + } + } + return `Pipeline-Check: ${parts.join(", ")}`; +} + /** * Tally pipeline-check diagnostics across the workspace by severity. * Falls back to INFO when ``data.severity`` is missing or unknown @@ -123,11 +154,28 @@ function readSeverity(diag: vscode.Diagnostic): SeverityName { } } +// File patterns that suggest the current workspace is worth showing +// the status bar in. Mirrors providers.ts's TRIGGER_PATTERNS — kept +// inline here so the status bar can ship without a circular import +// (providers.ts is consumed by extension.ts which orchestrates this +// module). +const WORKSPACE_HAS_CI_GLOB = + "{**/.github/workflows/*.yml,**/.github/workflows/*.yaml,**/.gitlab-ci.yml,**/azure-pipelines.yml,**/bitbucket-pipelines.yml,**/.circleci/config.yml,**/cloudbuild.yaml,**/.buildkite/pipeline.yml,**/.drone.yml,**/.drone.yaml,**/Jenkinsfile,**/Dockerfile,**/Containerfile}"; + /** - * Wire up the status bar item. Returns a Disposable the caller pushes - * onto the extension's subscriptions so the item is removed on - * deactivate. The item itself rewires on every diagnostic change and - * navigates to the Findings panel on click. + * Wire up the status bar item. Returns the item the caller pushes + * onto the extension's subscriptions so it's removed on deactivate. + * The item rewires on every diagnostic change and navigates to the + * Findings panel on click. + * + * Visibility policy: the item is hidden until we observe either at + * least one scannable file in the workspace or at least one + * pipeline-check diagnostic. This keeps the status bar quiet for + * users who installed Pipeline-Check but currently have a frontend + * project (or anything else without CI files) open — common in + * monorepo / multi-window setups. Once the workspace has been + * "seen" as relevant, the item stays visible even when findings + * fall to zero (so the "clean" signal earns its keep). */ export function registerStatusBar( context: vscode.ExtensionContext, @@ -136,19 +184,43 @@ export function registerStatusBar( vscode.StatusBarAlignment.Left, 100, ); - // Click reveals the Findings panel; same action as the activity-bar - // icon, just from a different surface. item.command = "pipelineCheck.findings.focus"; item.name = "Pipeline-Check"; + // Latches once: as soon as we've seen evidence this workspace is + // CI-relevant, we keep showing the item. + let relevant = false; + const update = () => { const counts = countDiagnostics(vscode.languages.getDiagnostics()); item.text = formatStatusBarText(counts); item.tooltip = formatStatusBarTooltip(counts); - item.show(); + item.accessibilityInformation = { + label: formatStatusBarAccessibilityLabel(counts), + }; + const total = + counts.CRITICAL + counts.HIGH + counts.MEDIUM + counts.LOW + counts.INFO; + if (total > 0) relevant = true; + if (relevant) { + item.show(); + } else { + item.hide(); + } }; - // Seed and subscribe. + // One-shot scan to learn whether the workspace has any candidate + // files at all. If yes, the item is allowed to show immediately + // (with `clean` text until the first publish arrives). If not, we + // wait — first diagnostic publish flips `relevant` and unblocks. + void vscode.workspace + .findFiles(WORKSPACE_HAS_CI_GLOB, "**/{node_modules,.git}/**", 1) + .then((uris) => { + if (uris.length > 0) { + relevant = true; + update(); + } + }); + update(); context.subscriptions.push( item, diff --git a/src/test/integration/activation.test.ts b/src/test/integration/activation.test.ts index 6aa17a5..351b6dd 100644 --- a/src/test/integration/activation.test.ts +++ b/src/test/integration/activation.test.ts @@ -33,8 +33,11 @@ suite("Pipeline-Check — activation", () => { const expected = [ "pipelineCheck.restart", "pipelineCheck.showLog", + "pipelineCheck.copyInstallCommand", "pipelineCheck.findings.refresh", "pipelineCheck.findings.changeGrouping", + "pipelineCheck.findings.copyRuleId", + "pipelineCheck.findings.openRuleDocs", "pipelineCheck.goToNextFinding", "pipelineCheck.goToPreviousFinding", ]; From d037d5e08b5967ba9a48b30df8cfee92c3730d96 Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 11:57:57 +0200 Subject: [PATCH 08/11] chore(release): v0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps package.json from 0.1.1 → 0.2.0 and folds the CHANGELOG [Unreleased] block into [0.2.0] — 2026-05-19 so publish.yml's awk release-note extractor picks the right section. v0.2.0 closes 24 of 29 items from the 2026-05-19 review. Headline features: status bar item, inline CodeLens summary, keyboard navigation (Alt+F8 / Shift+Alt+F8), right-click context menus on Findings tree leaves, per-provider toggles, pre-release publish channel, @vscode/test-electron integration tests. One borderline-breaking change called out in the release notes: the extension's activationEvents are narrower than v0.1.x. Files outside the standard CI paths no longer auto-scan; onStartupFinished was added so the activity-bar slot stays visible regardless. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 30 ++++++++++++++++++++---------- package.json | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 181d6ce..c3ba549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,26 @@ versions follow [SemVer](https://semver.org/). > section **above** Unreleased, or remove the Unreleased block for the > release commit. Otherwise the GitHub release ships boilerplate. -## [Unreleased] - -PRs landing on `main` between releases append entries here. The -release commit collapses this section into `## [X.Y.Z] — `. - -The current run-up to v0.2.0 has the work below queued — landing -order depends on which PRs in the [#11–#14 stack](https://github.com/greylag-ci/pipeline-check-vscode/pulls) -merge first. +## [0.2.0] — 2026-05-19 + +Closes 24 of 29 items from the 2026-05-19 in-depth UX/code review. +Adds the activity-bar Findings tree's missing affordances (status bar, +CodeLens, navigation, context menus, per-provider toggles), the +release-tooling polish (`production` environment gate, pre-release +channel, three-OS CI, integration tests), and the discovery / +accessibility pass. + +**Heads-up for users with non-standard workflow paths:** the +extension's `activationEvents` now match only the +`workspaceContains:` patterns shared with the LSP `documentSelector` +(plus `onStartupFinished` so the activity-bar slot is always +visible). If your repo keeps CI definitions outside the standard +locations (e.g. `pipelines/build.yml` instead of +`.github/workflows/*.yml`), the extension still activates on +`onStartupFinished`, but the LSP only scans files matching the +document selector. Use `pipelineCheck.serverArgs` to point the LSP +at a different path or symlink your custom config into a standard +location. ### Added @@ -100,8 +112,6 @@ merge first. Grouping" stay the same. Command IDs are unchanged — settings, keybindings, and automation continue to work. -### Changed (release tooling) - - **`@vscode/test-electron` integration suite** now runs in CI (Linux only, via `xvfb-run -a`). Five tests pin activation, the command-registration contract, the Findings view registration, diff --git a/package.json b/package.json index d2ed7ec..3b72605 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pipeline-check", "displayName": "Pipeline-Check", "description": "Lint CI/CD pipelines for 22 providers against OWASP Top 10 CI/CD Risks and 14 other compliance frameworks. 810+ rules, inline in your editor.", - "version": "0.1.1", + "version": "0.2.0", "publisher": "greylag-ci", "license": "MIT", "icon": "icon.png", From c3271225aabe1848a1518c16cc04af64d6f22c6f Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 12:01:56 +0200 Subject: [PATCH 09/11] ci: trigger checks on retargeted PR From 10b61f67af6c53e4e0e72cb8dbf5b5604c1efb5f Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 12:15:32 +0200 Subject: [PATCH 10/11] fix: detach LSP-failure error notification from activate's await chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by the integration tests hanging in CI: when `python -m pipeline_check.lsp` fails to launch (Python missing, [lsp] extra not installed, server crash on import), the catch path used to await the result of `vscode.window.showErrorMessage`. That promise only resolves when the user clicks a button or closes the toast — so any context that doesn't dismiss it (CI, automation, hidden host window, user who minimised VS Code) deadlocked activate() forever. The fix detaches the notification: fire it, route the button choice through a `.then` handler that still does the copy / log-open work, and return from startClient as soon as the error is logged. Activate now completes in a bounded time regardless of whether anyone clicks the toast. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/extension.ts | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 29f931e..08d135d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -188,21 +188,33 @@ async function startClient(): Promise { // actions so the user can act on either without re-reading the // notification body. The notification chrome already shows the // extension name, so the message body doesn't repeat it. + // + // The notification is fire-and-forget: `showErrorMessage` resolves + // only when the user clicks a button or closes the toast, and + // `activate()` already awaits this path. Awaiting here would block + // activation indefinitely whenever nobody is around to click + // (CI, automation, headless extension host). Detaching keeps the + // user's buttons live while letting startClient return. const message = err instanceof Error ? err.message : String(err); clientLog.error(`language server: failed to start — ${message}`); - const choice = await vscode.window.showErrorMessage( - `Language server failed to start (${message}).`, - "Copy install command", - "Open server log", - ); - if (choice === "Copy install command") { - await vscode.env.clipboard.writeText('pip install "pipeline-check[lsp]"'); - vscode.window.showInformationMessage( - 'Copied: pip install "pipeline-check[lsp]"', - ); - } else if (choice === "Open server log") { - outputChannel.show(); - } + void vscode.window + .showErrorMessage( + `Language server failed to start (${message}).`, + "Copy install command", + "Open server log", + ) + .then(async (choice) => { + if (choice === "Copy install command") { + await vscode.env.clipboard.writeText( + 'pip install "pipeline-check[lsp]"', + ); + void vscode.window.showInformationMessage( + 'Copied: pip install "pipeline-check[lsp]"', + ); + } else if (choice === "Open server log") { + outputChannel.show(); + } + }); // Drop the broken client so a subsequent restart starts fresh // rather than trying to recover from a half-initialised state. client = undefined; From 4a7f305f71919eb08a52f262fd092b98ac209175 Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 12:20:05 +0200 Subject: [PATCH 11/11] fix(test): switch integration tests to mocha TDD ui `suite` / `test` are mocha's TDD-interface exports; `describe` / `it` are BDD. `.vscode-test.mjs` was set to `ui: "bdd"` which leaves the TDD globals undefined, so loading activation.test.js threw `ReferenceError: suite is not defined` and the integration job failed before any test ran. Co-Authored-By: Claude Opus 4.7 (1M context) --- .vscode-test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 5650132..64f10aa 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -18,7 +18,11 @@ export default defineConfig({ // they run; without a sane timeout, mocha may fail before the // extension finishes activating in slow CI environments. mocha: { - ui: "bdd", + // TDD ui exports `suite` / `test` (matching what every official + // VS Code extension uses). BDD's `describe` / `it` would need a + // re-write of the test files; staying with TDD keeps the + // convention familiar. + ui: "tdd", timeout: 20000, color: true, },