diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee7bb80..c398a32 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 @@ -31,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 @@ -44,17 +63,31 @@ 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' 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/.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/.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..64f10aa --- /dev/null +++ b/.vscode-test.mjs @@ -0,0 +1,29 @@ +// 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: { + // 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, + }, +}); 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/CHANGELOG.md b/CHANGELOG.md index e031454..c3ba549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,163 @@ 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. + +## [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 + +- **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) +- **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. + +- **`@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 3051f2a..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. --- @@ -391,3 +394,114 @@ 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. + +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) + +- [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 + +- [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 + +- [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 + +- [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 + +- [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 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 + +- [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`. *(Needs repo-settings + change.)* +- [x] **R24** Pre-release channel via tag naming + (`vX.Y.Z-rc.N` → pre-release). (PR #13) + +### Strategic + +- [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`). + *(Privacy-sensitive design decision; deferred pending product + direction.)* +- [ ] **R29** Scan-on-save mode. *(Depends on scan-workspace + merging.)* 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 872a987..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", @@ -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)" }, @@ -109,6 +115,38 @@ "title": "Change Grouping", "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", + "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": { @@ -124,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", @@ -132,6 +182,14 @@ { "command": "pipelineCheck.findings.changeGrouping", "when": "view == pipelineCheck.findings" + }, + { + "command": "pipelineCheck.findings.copyRuleId", + "when": "false" + }, + { + "command": "pipelineCheck.findings.openRuleDocs", + "when": "false" } ] }, @@ -157,6 +215,32 @@ "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.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": [ @@ -191,6 +275,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", @@ -200,13 +286,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/__testStubs__/vscode.ts b/src/__testStubs__/vscode.ts new file mode 100644 index 0000000..f2834e7 --- /dev/null +++ b/src/__testStubs__/vscode.ts @@ -0,0 +1,152 @@ +// 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 }; + + 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, + EventEmitter, + TreeItem, + 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: + // - `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/codeLens.test.ts b/src/codeLens.test.ts new file mode 100644 index 0000000..1b961d9 --- /dev/null +++ b/src/codeLens.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("vscode", async () => { + const { vscodeStub } = await import("./__testStubs__/vscode"); + return vscodeStub(); +}); + +import { + FindingsCodeLensProvider, + 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", + ); + }); +}); + +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 new file mode 100644 index 0000000..d989681 --- /dev/null +++ b/src/codeLens.ts @@ -0,0 +1,133 @@ +// 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(), + ), + // 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), + ); + 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 32ad27f..08d135d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,7 +18,17 @@ 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 { + providerForPath, + type ProviderId, + TRIGGER_DOCUMENT_SELECTOR, +} from "./providers"; +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,12 +79,21 @@ 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"; +// 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 { @@ -95,21 +114,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", }, @@ -124,9 +133,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)); }, }, @@ -153,8 +173,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. @@ -162,33 +188,67 @@ 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); - 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(); - } + clientLog.error(`language server: failed to start — ${message}`); + 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; } } +// 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 +268,19 @@ 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); + // 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", () => @@ -217,10 +290,66 @@ 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"), + ), + vscode.commands.registerCommand("pipelineCheck.goToPreviousFinding", () => + goToFinding("previous"), + ), 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..6fd555d 100644 --- a/src/findingsView.test.ts +++ b/src/findingsView.test.ts @@ -1,87 +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 { - constructor(public readonly value: string) {} - } - 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: { - getDiagnostics: () => - (globalThis as { __stubDiagnostics?: unknown[] }).__stubDiagnostics ?? - [], - 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. @@ -99,6 +24,7 @@ type FakeFinding = { rule: string; severity?: string; line?: number; + docsUrl?: string; }; function setStubDiagnostics(findings: FakeFinding[]): void { @@ -109,7 +35,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 +410,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/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..4cdb625 --- /dev/null +++ b/src/providers.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +vi.mock("vscode", () => ({})); + +import { + PROVIDER_IDS, + PROVIDERS, + TRIGGER_PATTERNS, + TRIGGER_DOCUMENT_SELECTOR, + providerForPath, +} 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}`); +} + +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 new file mode 100644 index 0000000..cede47f --- /dev/null +++ b/src/providers.ts @@ -0,0 +1,148 @@ +// 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; +} + +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. Derived from `PROVIDERS` so the + * two stay in sync automatically. + */ +export const TRIGGER_PATTERNS: readonly string[] = PROVIDER_IDS.flatMap( + (id) => PROVIDERS[id], +); + +/** + * 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 })); + +/** + * 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}$`); +} diff --git a/src/statusBar.test.ts b/src/statusBar.test.ts new file mode 100644 index 0000000..7bed029 --- /dev/null +++ b/src/statusBar.test.ts @@ -0,0 +1,220 @@ +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, + formatStatusBarAccessibilityLabel, + 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 }); + }); +}); + +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 new file mode 100644 index 0000000..705437a --- /dev/null +++ b/src/statusBar.ts @@ -0,0 +1,231 @@ +// 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. + * + * 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; + 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."); + 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 + * (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"; + } +} + +// 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 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, +): vscode.StatusBarItem { + const item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + 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.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(); + } + }; + + // 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, + vscode.languages.onDidChangeDiagnostics(update), + ); + + return item; +} diff --git a/src/test/integration/activation.test.ts b/src/test/integration/activation.test.ts new file mode 100644 index 0000000..351b6dd --- /dev/null +++ b/src/test/integration/activation.test.ts @@ -0,0 +1,105 @@ +// 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.copyInstallCommand", + "pipelineCheck.findings.refresh", + "pipelineCheck.findings.changeGrouping", + "pipelineCheck.findings.copyRuleId", + "pipelineCheck.findings.openRuleDocs", + "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.