diff --git a/.bulwark.yml b/.bulwark.yml index 43785a3..b2676c6 100644 --- a/.bulwark.yml +++ b/.bulwark.yml @@ -12,11 +12,11 @@ # directories on disk, and does not read dependabot.yml. Nothing enforces the # Dependabot entry, so a pin can still be added that no bot ever bumps. rust: - exclude: ["eslint-pin", "biome-pin", "cargo-audit-pin", "cargo-deny-pin", "go-pin"] + exclude: ["biome-pin", "cargo-audit-pin", "cargo-deny-pin", "go-pin"] typescript: - exclude: ["eslint-pin", "biome-pin", "cargo-audit-pin", "cargo-deny-pin", "go-pin"] + exclude: ["biome-pin", "cargo-audit-pin", "cargo-deny-pin", "go-pin"] go: - exclude: ["eslint-pin", "biome-pin", "cargo-audit-pin", "cargo-deny-pin", "go-pin"] + exclude: ["biome-pin", "cargo-audit-pin", "cargo-deny-pin", "go-pin"] # Who produces the coverage this repo is gated on. # diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 12c3b26..fef5fed 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,7 +39,7 @@ updates: default-days: 7 open-pull-requests-limit: 25 - # The ESLint stack (internal/typescript). + # Biome (internal/typescript), the only TypeScript linter. # # One of the tool version pins. bulwark pins every tool it runs, and these # manifests exist so those pins are visible to Dependabot rather than frozen @@ -50,22 +50,6 @@ updates: # Each pin directory is also excluded in .bulwark.yml: they are real # manifests, so detection would otherwise treat them as packages to lint or # modules to scan. - - package-ecosystem: "npm" - directory: "/internal/typescript/eslint-pin" - schedule: - interval: "weekly" - # Dependabot's commit message is also its PR title, and PRs are - # squash-merged, so the prefix is what keeps dependency updates inside - # Conventional Commits. `include: scope` appends the dependency scope, - # producing e.g. `build(deps): bump …`. - commit-message: - prefix: "build" - include: scope - cooldown: - default-days: 7 - open-pull-requests-limit: 25 - - # Biome (internal/typescript), used when a repo sets typescript.linter: biome. - package-ecosystem: "npm" directory: "/internal/typescript/biome-pin" schedule: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb275e5..fc460c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,11 +44,39 @@ jobs: go-version-file: go.mod cache: true + # goreleaser derives the release body from commit subjects, which can say + # what landed but not what it means: a change to how a coverage number is + # computed, or an upgrade step a consumer has to take, has no commit + # subject that conveys it. docs/release-notes/.md is where that gets + # written, and this is what puts it in front of the changelog rather than + # in a file nobody visits. + # + # A missing notes file is not an error. Most releases are patches whose + # commit list genuinely is the whole story, and failing a release over a + # file the release did not need would make every release depend on + # remembering a step — the exact failure mode the major-alias move below + # was automated to fix. + - name: Assemble the release header + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + cat docs/release-notes/_header.md > release-header.md + notes="docs/release-notes/${TAG}.md" + if [[ -f "$notes" ]]; then + printf '\n---\n\n' >> release-header.md + cat "$notes" >> release-header.md + echo "Release body carries ${notes}." >> "$GITHUB_STEP_SUMMARY" + else + echo "No ${notes}; release body is the header plus the generated changelog." >> "$GITHUB_STEP_SUMMARY" + fi + - name: Run goreleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: version: "~> v2" - args: release --clean --parallelism 1 + args: release --clean --parallelism 1 --release-header=release-header.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ffd662b..34c9bd9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ go.work.sum # goreleaser build artifacts dist/ +# Release body assembled by release.yml from docs/release-notes/ +/release-header.md + # Agentic toolkit generated config /.claude/ /CLAUDE.md diff --git a/.goreleaser.yml b/.goreleaser.yml index ab81d78..9769a4e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -62,16 +62,9 @@ release: name: bulwark extra_files: - glob: scripts/install.sh - header: | - ## bulwark - - Unified code-quality and security scanning for Rust, TypeScript, and Go — - one CLI, run identically locally and in CI. - - **Install / update** - - ```sh - curl -fsSL https://github.com/wardnet/bulwark/releases/latest/download/install.sh | sh - ``` - - `bulwark update` self-updates the CLI in place. + # No `header:` here. The release body's header is assembled by release.yml + # and passed with --release-header, so it can carry that version's notes from + # docs/release-notes/.md above the generated commit changelog. A commit + # list cannot explain a change in what a number means, which is the whole + # reason those files exist; docs/release-notes/_header.md holds the invariant + # preamble the assembled header starts with. diff --git a/.gt-repo.yaml b/.gt-repo.yaml index c196e33..ba2c7a2 100644 --- a/.gt-repo.yaml +++ b/.gt-repo.yaml @@ -22,9 +22,9 @@ dependabot: - ecosystem: github-actions directory: / - ecosystem: npm - directory: /internal/typescript/eslint-pin + directory: /internal/typescript/biome-pin note: | - The ESLint stack (internal/typescript). + Biome (internal/typescript), the only TypeScript linter. One of the tool version pins. bulwark pins every tool it runs, and these manifests exist so those pins are visible to Dependabot rather than frozen @@ -35,10 +35,6 @@ dependabot: Each pin directory is also excluded in .bulwark.yml: they are real manifests, so detection would otherwise treat them as packages to lint or modules to scan. - - ecosystem: npm - directory: /internal/typescript/biome-pin - note: | - Biome (internal/typescript), used when a repo sets typescript.linter: biome. - ecosystem: cargo directory: /internal/rust/cargo-audit-pin note: | diff --git a/AGENTS.md b/AGENTS.md index 1286e9a..1b613fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,13 +26,14 @@ internal/detect/ # ecosystem + TS-package detection (walks for Ca internal/config/ # .bulwark.yml loading (opt-outs + pipeline shape — see Configuration below) internal/toolchain/ # ensures the Go/Rust/Node runtime each detected ecosystem needs (see Toolchains below) internal/rust/ # clippy, cargo-audit, cargo-deny -internal/typescript/ # pinned ESLint + eslint-plugin-security; opt-in Biome (see Linters) +internal/typescript/ # pinned Biome, the only TS linter (see Linters) internal/golang/ # gosec, govulncheck (installed into a version-keyed GOBIN dir) internal/semgrep/ # pinned Semgrep, installed via pipx internal/coverage/ # per-language coverage percentage (see Coverage below) internal/gitstate/ # bulwark-state branch read/write (see Coverage below) internal/executil/ # shared external-command runner every scanner package uses assets/bulwark-logo.png # logo — used by README and the action's PR comment (see below) +docs/release-notes/ # _header.md + one .md per release that needs one (see Release notes) .goreleaser.yml # build/release config (v2 schema) .golangci.yml # lint config (v2 schema) .github/workflows/{ci,release}.yml @@ -103,8 +104,8 @@ rust: typescript: enabled: true exclude: ["legacy-app"] - linter: eslint # or "biome" — which engine backs the TS check (see Linters - # below). Mutually exclusive; there is deliberately no "both". + linter: biome # the only accepted value. The retired "eslint" is rejected with an + # error rather than silently run under Biome (see Linters below). install: "" # override coverage's install-command auto-detection, e.g. # "corepack enable && yarn install --immutable" (see Coverage below) go: @@ -146,6 +147,11 @@ coverage: # baseline 86.1%, regressed 0.0%"). Compared at display precision # (tenths); 0 = fail any dip the report can show. Must be finite and # non-negative — Load rejects anything else. + floor: 0 # minimum coverage any single measured unit (Go module, Rust crate/ + # workspace root, TS package) must reach. 0 = off, which is the default: + # it is opt-in, so upgrading never starts failing a repo over a gap it + # has always had. This is the signal line-weighting removes — see + # Aggregation below. patch: tolerance: 0.1 # the patch gate's own dip allowance — deliberately independent, so # loosening the aggregate knob never weakens the untested-new-code check @@ -154,49 +160,91 @@ coverage: Omitting the file, or omitting a section/key within it, keeps that value at its default — see `internal/config/config_test.go` for the exact merge semantics. -## Linters: ESLint (default) and Biome (opt-in) +## Linters: Biome, and only Biome -`typescript.linter` selects which engine backs the TypeScript check: `eslint` (the default, -and byte-for-byte what every repo had before the key existed) or `biome`. They are mutually -exclusive and there is deliberately no `both` — a repo is migrating, and the key names where -it has got to. An unknown value is rejected by `config.validateLinter` rather than falling -back, because a misspelled opt-in that silently keeps running the old linter is the one -outcome nobody can detect. +Biome backs the TypeScript check. `typescript.linter` accepts `biome` and nothing else; the +retired `eslint` value is **rejected** by `config.validateLinter` with an error naming the +removal, rather than accepted and quietly run under Biome. A repo that set that key stated +which rule set it gates on, and switching it silently would change what the scan measures +while every run still reported `[PASS]` — the same reason an unknown value is rejected rather +than defaulted. `LinterESLint` stays defined for no purpose but to be recognised and refused. -**The two are not interchangeable rule sets, and switching changes what bulwark gates on.** -`eslint-plugin-security` is Node/backend heuristics (`detect-child-process`, -`detect-object-injection`, `detect-non-literal-fs-filename`, `detect-unsafe-regex`, …); +**Biome is not a replacement for `eslint-plugin-security` — Semgrep is, and it already ran.** Biome's `security` group is six JSX/eval/secret rules (`noBlankTarget`, `noDangerouslySetInnerHtml`, `noDangerouslySetInnerHtmlWithChildren`, `noGlobalEval`, -`noScriptUrl`, `noSecrets`). Only `noGlobalEval` genuinely coincides with anything ESLint's -plugin has. Under Biome, bulwark additionally gates on the **`correctness`** group, so a repo -that opts in is gating on more than security. That is accepted because it is opt-in per repo, -and Semgrep still runs across every ecosystem either way. See [ADR 0005](docs/adr/0005-optional-biome-linter.md). - -Four things about the Biome integration were established against Biome 2.5.8 directly rather -than from its docs, and all four are load-bearing: - -- **`files.includes` negations work from an out-of-tree config.** Biome resolves config globs +`noScriptUrl`, `noSecrets`), of which only `noGlobalEval` coincided with anything the plugin +had; bulwark additionally gates on the **`correctness`** group, so the TypeScript check is not +"security findings only" the way the others are. Measured against a fixture carrying all four +classes, Semgrep at `config: auto` covers `detect-child-process` (with taint — it names the +tainted argument) and `detect-non-literal-fs-filename` (as a path-traversal finding), partly +covers `detect-unsafe-regex` (dynamic patterns via `detect-non-literal-regexp`, but no static +ReDoS analysis of a literal), and does not cover `detect-object-injection` at all. Several of +those Semgrep rules are ports of the ESLint ones and are stronger than the originals. The real +gap is `detect-object-injection` — the plugin's most commonly disabled rule — plus +literal-pattern ReDoS. Do not rebuild either as a Biome GritQL plugin: GritQL matches syntax +against the CST with no dataflow or taint, so it could only produce a weaker copy of what +Semgrep already does. See [ADR 0008](docs/adr/0008-biome-as-the-only-typescript-linter.md), +and [ADR 0005](docs/adr/0005-optional-biome-linter.md) for the opt-in it supersedes. + +**Why it went.** ESLint's TypeScript support is a *compiler* dependency, not a linter one: +`@typescript-eslint/parser` declares `typescript` as a peer with an +upper bound (`>=4.8.4 <6.1.0`), so bulwark's own pin manifest had to carry a `typescript` +inside that window, and the window moves only when upstream ships support for a new compiler. +`internal/typescript`'s package doc had recorded exactly this hazard — and a Dependabot PR +bumped `typescript` across the ceiling anyway and merged, after which `npm ci` on the +committed lockfile failed with `ERESOLVE` and every consumer with TypeScript got an install +error instead of a lint result. CI stayed green throughout, because bulwark's own repo has no +TypeScript to scan: its only `package.json` files are the pin manifests, which `.bulwark.yml` +excludes by name, so `self-scan` cannot reach that code path. Biome parses TypeScript with its +own Rust parser and depends on no compiler package, so `biome-pin` has no peer range to cross +and the failure class does not exist for it. + +Four things about the Biome integration were established against Biome directly rather than +from its docs. The pin is now **2.5.10**, and `biome.json`'s `$schema` URL must be bumped with +it — `TestBiomePinMatchesConfigSchema` fails the build otherwise, the same mirror guard +`internal/golang/pins_test.go` provides for the Go constants. Re-verified on 2.5.10 where +noted: + +- **`files.includes` negations work from an out-of-tree config** (re-verified on 2.5.10: an + `eval` inside `dist/` is not reported). Biome resolves config globs "relative to the folder the configuration file is in", and bulwark stages `biome.json` in its cache directory — so this looked like it would silently ignore nothing. It doesn't: `**`-prefixed negations are depth-agnostic and match correctly. But they are doing real work — with them - removed, Biome lints `dist/` and reports findings inside a minified production bundle, the exact - incident `TestEslintConfigIgnoresMatchesDefaultSkipDirs` was written for. `TestBiomeConfigIgnoresMatchesDefaultSkipDirs` - guards it. -- **`--config-path` beats the scanned project's own root `biome.json`.** A project config setting + removed, Biome lints `dist/` and reports findings inside a minified production bundle. + `TestBiomeConfigIgnoresMatchesDefaultSkipDirs` guards it. +- **`--config-path` beats the scanned project's own root `biome.json`** (re-verified on 2.5.10). + A project config setting `linter.enabled: false` is ignored, so bulwark's verdict doesn't vary with what the target repo - declares — the same stance `internal/typescript`'s doc comment takes for ESLint. -- **A `biome.json` in a *subdirectory* aborts the whole run.** Biome reports "Found a nested root - configuration" and produces no report at all, regardless of what bulwark's own config sets - `root` to. `lintDirBiome` detects that specific failure and reports it as a configuration - conflict naming the fix (add `"root": false` to the nested file, or exclude the directory), - because it must read as neither a pass nor a findings failure. + declares — the same stance `internal/typescript`'s doc comment takes generally. +- **A `biome.json` in a *subdirectory* does NOT abort bulwark's run**, and the earlier claim that + it does was a mis-scoped reproduction. Biome errors with "Found a nested root configuration, but + there's already a root configuration" **only when it resolves configuration from the tree + itself**. bulwark always passes `--config-path`, and under that flag a nested `biome.json` — + with `"root": true`, or with no `root` key at all — is simply ignored: Biome exits 0, lints + normally, and the report is clean. Confirmed on 2.5.8 and 2.5.10 with bulwark's exact + invocation, so this was never version drift. + The consequence is that `lintDirBiome`'s `biomeNestedRootConfig` branch is unreachable as long + as `--config-path` is passed. It stays, because it stops being unreachable the moment anyone + drops that flag — and dropping it is a plausible change, since it is what would let a project's + own Biome config be honoured. What must not survive is the claim that it fires today. - **A nested config declaring `"root": false` is *merged* into bulwark's config.** Its rules then fire in our run. `reportableBiome`'s category allowlist contains that — merged `lint/style/*` is dropped. The same merge is a real limitation in the other direction that cannot be closed from here: a nested config could set `"security": "off"` and silently narrow what bulwark checks. +**A check whose findings never stream must set `executil.Result.Detail`.** `executil.Run` +streams every tool's stdout/stderr live, so for gosec, clippy, cargo-audit and Semgrep the +findings are on the terminal and in the log `action.yml` captures before anyone reads the +`Result`. Biome is the exception: its report goes to a file via `--reporter-file` so its own +chatter cannot corrupt the JSON, so nothing streams and `Output` holds no findings. +`cmd/bulwark/scan.go`'s `report` prints `Detail` under a failing check — without it a +developer sees `[FAIL] biome(.)` and has to re-run the pinned toolchain by hand to learn why, +and the PR comment carries nothing at all. Detail lines are **indented**, and that is +load-bearing rather than cosmetic: `action.yml`'s `tool_result()` matches +`^\[(PASS|FAIL)\] $` anchored at both ends, so an indented line cannot be mistaken for +a status line even when a finding's own message contains one. + `recommended: false` in the bundled `biome.json` is not tidiness — without it Biome's default preset enables `style` and `suspicious` too, and bulwark would start failing PRs over opinions it never agreed to enforce. The formatter and assist are explicitly disabled for the same reason: @@ -212,7 +260,6 @@ Dependabot watches, colocated with the package that uses it: | Tool(s) | Manifest | Runtime source | |---|---|---| -| eslint, eslint-plugin-security, @typescript-eslint/parser, typescript | `internal/typescript/eslint-pin/package.json` + lock | the manifest itself (`npm ci`) | | @biomejs/biome | `internal/typescript/biome-pin/package.json` + lock | the manifest itself (`npm ci`) | | cargo-audit | `internal/rust/cargo-audit-pin/Cargo.toml` | parsed by `internal/rust/pins.go` | | cargo-deny | `internal/rust/cargo-deny-pin/Cargo.toml` | parsed by `internal/rust/pins.go` | @@ -253,7 +300,7 @@ entry. See [ADR 0006](docs/adr/0006-tool-pins-as-dependabot-manifests.md). ## Toolchains bulwark provisions every tool it *runs* — gosec/govulncheck via `go install` into a version-keyed -cache, cargo-audit/cargo-deny via `cargo install`, ESLint via npm, Semgrep via pipx — under the +cache, cargo-audit/cargo-deny via `cargo install`, Biome via npm, Semgrep via pipx — under the "pin the exact toolchain, don't reuse ambient installs" principle in `internal/golang`. The one thing it long did *not* provision was the language toolchain it does that provisioning **with**: `go`, `cargo` and `node` were simply assumed to be on PATH. `internal/toolchain` closes that. @@ -397,7 +444,8 @@ generated cache data, not source, needs no PR/review and never pollutes main's h hole (wardnet/wardnet#899). "No coverage measured" is only printed when there was truly nothing to record: nothing measured *and* no priors to carry. - `internal/gitstate.BaseSHA` resolves `git merge-base HEAD origin/main`. -- `internal/gitstate.ReadBaseline` fetches `bulwark-state` and reads `.json` via `git show` +- `internal/gitstate.ReadBaseline` fetches `bulwark-state` and reads `v2/.json` (see + `gitstate.StatePath`, and Aggregation below for why it is versioned) via `git show` (no checkout) — a missing branch or missing file is a cache miss, not an error. - On a cache miss, `cmd/bulwark/coverage.go`'s `computeBaselineAt` checks out `origin/main` at that SHA into a throwaway `git worktree` (never disturbing the caller's own working tree/branch), @@ -412,8 +460,9 @@ generated cache data, not source, needs no PR/review and never pollutes main's h "recorded" for a baseline that was lost — that exact silent loss (stale ref → non-fast-forward rejection → swallowed) is how wardnet's main runs kept recording nothing while every PR re-hit a cache miss. -- `internal/coverage.Compute` gets the actual number per detected ecosystem: `go tool cover -func`'s - total line for Go, `cargo llvm-cov --json`'s `data[0].totals.lines.percent` for Rust, and — for +- `internal/coverage.Compute` gets the actual number per detected ecosystem: covered-over-total + statements read from the profile for Go, `cargo llvm-cov --json`'s + `data[0].totals.lines.{count,covered}` for Rust, and — for TypeScript, best-effort only — a package's own `test:coverage` script plus Vitest/Istanbul's `coverage-summary.json`, since unlike a linter there's no single canonical coverage-invocation convention to standardize on across arbitrary TS packages. A language whose coverage can't be @@ -421,8 +470,8 @@ generated cache data, not source, needs no PR/review and never pollutes main's h - Rust never assumes `--dir` itself is the crate/workspace root — `internal/detect.RustCrateDirs` discovers every independent Cargo crate/workspace root under `--dir` (deduping a workspace member's own `Cargo.toml` under its ancestor workspace root), and both `internal/rust.Check` and - `internal/coverage.rustCoverage` iterate every discovered root, averaging coverage across them the - same way TypeScript averages across packages. Rust's report overrides are therefore keyed by + `internal/coverage.rustCoverage` iterate every discovered root, summing line counts across them + the same way TypeScript sums across packages (see Aggregation below). Rust's report overrides are therefore keyed by crate directory (relative to `--dir`) rather than a single path — `coverage.rust.report` and `coverage.rust.lcov` accept a mapping of crate dir to path, and the `--rust-report`/ `--rust-lcov-report` flags are repeatable with the same `=` syntax. A bare value @@ -431,7 +480,7 @@ generated cache data, not source, needs no PR/review and never pollutes main's h - Go never assumes `--dir` itself is a module root either, for the same reason and by the same shape (see [ADR 0002](docs/adr/0002-go-multi-module-coverage.md)) — `internal/detect.GoModuleDirs` discovers every module under `--dir` and `internal/coverage.goCoverage` measures each in turn, - averaging across them. `coverage.go.report` and `--go-report` are likewise keyed by module dir + summing across them. `coverage.go.report` and `--go-report` are likewise keyed by module dir (`--go-report =`). This one bit for real: `go test`, `go list -m` and `go tool cover -func` are all module-scoped, so running them at a monorepo root measured *nothing* — and said so only in a warning, leaving Go absent from wardnet's gate, aggregate and patch both, while @@ -472,6 +521,79 @@ generated cache data, not source, needs no PR/review and never pollutes main's h (wardnet/wardnet#892 showed a Rust-only PR as "typescript: no longer measured" when the TS code was untouched — only the frontend coverage job had been skipped). Neither fails on its own. +### Aggregation: line-weighted, plus a per-unit floor + +A language's figure is the ratio of its units' **summed line counts** — `Σ covered / Σ total` +across every discovered Go module, Rust crate/workspace root and TypeScript package — never the +mean of the units' percentages. Each per-unit measurement returns an `internal/coverage.LineCount` +(Go counts statements, since that is what a Go profile records; the ratio is the same quantity), +and `Compute` returns the `Unit` list alongside the per-language percentages. + +The mean is not a mild approximation, which is why the counts are carried all the way rather than +reduced early. On a nine-package pnpm monorepo, one commit that added a 39-line untested file to +the smallest package (230 lines) while adding ~1,250 well-tested lines elsewhere read as **−2.2** +under the mean and **+0.44** by line count: the gate failed a change that improved coverage, by +five times the true magnitude, in the opposite direction. A 4,494-line library and a 230-line app +had equal votes. Widening `coverage.tolerance` to absorb that is explicitly not the fix — it exists +for sub-tenth instrumentation noise, and a tolerance wide enough to hide a 2.2-point artefact hides +a genuine two-point regression too. + +**A unit with no measurable lines is unmeasured, never 0%.** An empty Go profile, a crate llvm-cov +reports zero lines for, an Istanbul summary with `total.lines.total == 0` — each is returned as a +`Unit` with a zero `LineCount`, which `Unit.Measured` reports false for. `aggregate` skips it, so no +0/0 reaches a language's figure, and the floor below never fails it as a unit at 0%. + +It is returned rather than dropped because dropping it is invisible. A language reports a percentage +as soon as **one** of its units measures, so `warnUnmeasured` — which works per language — stays +silent while the floor gate covers fewer units than the repo holds: a repo whose CI path-filtered +eight of nine TypeScript packages has a fully measured language and a gate that saw one package. +`floorReport` therefore names each one as `[UNMEASURED]` with a stderr warning and prints its pass +line as `N of M unit(s)`, the same "a gate that didn't run must be visibly distinct from a gate that +passed" rule Patch coverage below states. + +A TypeScript package that declares no `test:coverage` script is the exception: under `SourceRun` it +has opted out of being measured, which is not the same as a report bulwark expected and did not +find, so it is not a discovered unit at all. One `[UNMEASURED]` line and one warning per types-only +package on every run trains readers to ignore the tag that exists to be noticed. + +**`floorReport` filters to enabled ecosystems, and it is the only gate that does.** `Compute` +measures every language `detect.Ecosystems` finds without consulting `rust/typescript/go.enabled`, +so its units — and `current` itself — can carry a language the repo opted out of. For the aggregate +that has always been one surprising `[FAIL]` line; for a per-unit gate it would be one build failure +per crate. Closing it at the source (having `Compute` skip disabled languages, which is what this +document's Coverage section already claims happens) is the better fix and is not this change. + +**`coverage.floor` is what line-weighting removes, put back deliberately.** Weighting by lines is +blind to a small unit nobody tests: a package with 0 of 8 lines covered is 0.1% of an 8,305-line +repo, so the headline barely moves. The two metrics answer different questions — "did this change +leave code untested?" is the aggregate, "is there a unit nobody tests at all?" is the floor — and +neither bounds the other. `cmd/bulwark/coverage.go`'s `floorReport` gates every measured unit +against it and prints one `[FAIL]` line per unit below, in the same bracketed vocabulary +`diffReport`/`patchReport` use. An unmeasured unit is `[UNMEASURED]`, never a failure and never +folded into the passing count. + +**It is also the one gate that runs on main.** The aggregate and patch gates compare against a +baseline, and on a push to main the current commit *is* the baseline, so they have nothing to +compare and the record-on-main path returns before them. A floor has no baseline: a unit is above +the bar or below it, and that reads the same on main as on a pull request. Skipping it there would +leave main ungated on a unit that arrived through a path no pull request measured. The baseline is +recorded first and unconditionally — it is worth keeping whether or not a unit is below the floor, +and losing it would push every later pull request into a recompute-nothing cache miss over an +unrelated failure. It defaults to `0` (off), because upgrading bulwark must not start +failing a repo over a gap it has always had, and it has **no baseline and no +compare-against-last-time**: a floor is an absolute standard, and ratcheting it against a prior +value would make a unit that has never had tests permanently acceptable. + +**Cached baselines from before this live at a different path and are simply missed.** Entries +recorded under the mean are a different quantity, so `gitstate.StatePath` writes and reads +`v2/.json` on `bulwark-state` and every consumer takes one clean cache miss and re-records. +The version is the *metric's*, not the file format's — the entries stay a plain +language → percentage object, readable by hand on the branch — and the old entries stay in place +rather than being overwritten. `PriorBaselines`' `git ls-tree` needs `-r` for the same reason, or +it lists the directory instead of the entries in it. Consumers still see a step change on upgrade +(+9 points on the repo above); say so in the release notes. See +[ADR 0007](docs/adr/0007-line-weighted-coverage-aggregation.md). + ### `coverage.source`: who produces the coverage Unlike Codecov or Sonar — which never execute your tests, only ingest a coverage report your build @@ -722,6 +844,24 @@ shell metacharacters, regardless of how trusted the input value looks today. `if `with:` blocks on a `uses:` step are fine to interpolate directly — only `run:` script bodies are the risk, since that's the only place text gets spliced into something a shell then executes. +## Release notes + +`release.yml` assembles the GitHub release body's header from +`docs/release-notes/_header.md` (the invariant install block — it lives there +rather than in `.goreleaser.yml`'s `header:` so the workflow can extend it) plus +`docs/release-notes/.md` when that file exists, passes it with +`--release-header`, and goreleaser appends its commit-derived changelog beneath. + +A missing per-version file is deliberately not an error. Most patch releases are +fully described by their commit subjects, and failing a release over a file it +never needed would make every release depend on remembering a step — the same +failure the major-alias move in that workflow was automated away to prevent. +Write one when the release carries what a commit subject cannot: a change in +what an existing number or verdict *means*, an upgrade step consumers must take, +or a new major. `docs/release-notes/v2.0.0.md` is the worked example, and the +file has to be on `main` before the tag is pushed — the workflow reads it out of +the tagged tree. + ## Conventions - **Version injection:** `cmd/bulwark` exposes `var version = "dev"`, overridden at release via diff --git a/CONTEXT.md b/CONTEXT.md index 2ebcdd6..4b178c0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,9 +18,9 @@ The aggregate coverage value cached on the `bulwark-state` branch for a specific A source line that a language's own coverage tool (`go tool cover`, `cargo llvm-cov`, Istanbul) reports an entry for. Comments, blank lines, imports, and braces are never coverable — they simply never appear in a coverage report, so patch coverage's denominator (coverable changed lines) excludes them automatically, without bulwark doing any language-aware filtering itself. **Linter**: -Which engine backs bulwark's TypeScript check for a given repo — ESLint (the default) or Biome — selected by `typescript.linter` in `.bulwark.yml`. The two are mutually exclusive; there is no "both". A repo's value is a **migration state**, not a per-invocation choice: it is a fact about that repo, the same for every run in it. -_Note_: the two are not interchangeable rule sets. Switching changes which findings bulwark gates on, and under Biome the check gates on **correctness** as well as security — so the TypeScript check is "security findings only" under ESLint but not under Biome. -_Avoid_: "linting mode", "the TS linter setting". +The engine backing bulwark's TypeScript check: Biome, and only Biome. `typescript.linter` in `.bulwark.yml` accepts `biome` alone; the retired `eslint` value is rejected with an error rather than accepted and quietly run under Biome. +_Note_: the TypeScript check gates on **correctness** as well as security, so it is not "security findings only" the way the other language checks are. The ESLint stack it replaced covered a different set of security rules — Node/backend heuristics with no Biome equivalent — so this is a change in what is gated, not only in what runs it. See [ADR 0008](docs/adr/0008-biome-as-the-only-typescript-linter.md). +_Avoid_: "linting mode", "the TS linter setting", describing Biome as opt-in. **Pin**: The exact version of a tool bulwark installs and runs, recorded in a real package-manager manifest (`package.json`, `Cargo.toml`, `go.mod`, `requirements.txt`) so Dependabot can see and bump it — never only in a Go constant. The distinguishing property of a pin is that something must be able to *age it out*: a pin nothing can bump is indistinguishable from a scanner that has silently stopped being current. diff --git a/README.md b/README.md index 4422596..6da5735 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ against a lazily-computed baseline — no manual setup, no "works on my machine. | Ecosystem | Checks | |---|---| | Rust | `cargo fmt --check`, `cargo clippy` (pedantic/restriction groups come from the target repo's own `Cargo.toml`), `cargo-audit` (CVEs), `cargo-deny` (licenses + bans) | -| TypeScript | ESLint + `eslint-plugin-security`, using a toolchain `bulwark` bundles and pins itself — independent of whatever (if anything) the target package declares in its own `devDependencies`. Projects migrating to [Biome](https://biomejs.dev) can opt in with `typescript.linter: biome` | +| TypeScript | [Biome](https://biomejs.dev)'s `security` and `correctness` rules, using a toolchain `bulwark` bundles and pins itself — independent of whatever (if anything) the target package declares in its own `devDependencies`. Biome parses TypeScript with its own parser, so no compiler version can constrain it | | Go | `gosec`, `govulncheck` | | All of the above | [Semgrep](https://semgrep.dev) | @@ -94,7 +94,6 @@ rust: typescript: enabled: true exclude: ["legacy-app"] - linter: eslint # or "biome" — which linter backs the TypeScript check go: enabled: true exclude: [] @@ -106,8 +105,14 @@ toolchain: coverage: source: run # or "report" — a prior CI job produces coverage, bulwark only parses it tolerance: 0.1 # pp the aggregate gate tolerates below baseline (patch gate: coverage.patch.tolerance) + floor: 0 # minimum coverage any single module/crate/package must reach; 0 = off ``` +A language's aggregate is its units' summed line counts (`Σ covered / Σ total`), so a big package +and a small one contribute in proportion to their size. That is the honest headline, and it is +deliberately blind to a small package with no tests at all — `coverage.floor` is the opt-in gate +for that second question. + See [AGENTS.md](AGENTS.md#configuration) for the full schema and merge semantics. Note there are no toolchain *versions* in there. bulwark makes sure the Go, Rust and Node runtimes diff --git a/cmd/bulwark/coverage.go b/cmd/bulwark/coverage.go index 688450b..34f66ec 100644 --- a/cmd/bulwark/coverage.go +++ b/cmd/bulwark/coverage.go @@ -76,7 +76,7 @@ func newCoverageCmd() *cobra.Command { return err } - current, sources, cleanup, err := coverage.Compute(ctx, dir, cfg, source, reports, patchWanted) + current, units, sources, cleanup, err := coverage.Compute(ctx, dir, cfg, source, reports, patchWanted) defer cleanup() if err != nil { return err @@ -136,15 +136,31 @@ func newCoverageCmd() *cobra.Command { if err := gitstate.WriteBaseline(ctx, dir, key, record); err != nil { // Best-effort, as everywhere else: a write race with a concurrent // main build must not fail the build. - _, printErr := fmt.Fprintf(cmd.ErrOrStderr(), "warning: failed to record coverage baseline for %s: %v\n", key, err) - return printErr - } - note := "" - if len(carried) > 0 { - note = fmt.Sprintf(" (%s carried forward from a prior baseline — detected but not measured this run)", strings.Join(carried, ", ")) + if _, printErr := fmt.Fprintf(cmd.ErrOrStderr(), "warning: failed to record coverage baseline for %s: %v\n", key, err); printErr != nil { + return printErr + } + } else { + note := "" + if len(carried) > 0 { + note = fmt.Sprintf(" (%s carried forward from a prior baseline — detected but not measured this run)", strings.Join(carried, ", ")) + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "recorded coverage baseline for %s: %s%s\n", sha, formatReport(record), note); err != nil { + return err + } } - _, err = fmt.Fprintf(cmd.OutOrStdout(), "recorded coverage baseline for %s: %s%s\n", sha, formatReport(record), note) - return err + // The floor is the one gate with something to say on main. The + // aggregate and patch gates compare against a baseline, and on + // main the current commit IS the baseline, so there is nothing + // to compare; the floor is an absolute standard that every unit + // meets or does not, and it reads the same here as on a pull + // request. Skipping it would leave main ungated on a unit that + // arrived through a path no pull request measured. + // + // Recording comes first and is unconditional: the baseline is + // worth keeping whether or not a unit is below the floor, and + // losing it would push every later pull request into a + // recompute-nothing cache miss over an unrelated failure. + return floorReport(cmd, units, cfg.Coverage.Floor, ecosystems) } if len(current) == 0 { @@ -239,11 +255,18 @@ func newCoverageCmd() *cobra.Command { // directly rather than recomputing "git merge-base HEAD origin/main" // a second time. patchErr := patchReport(cmd, ctx, dir, patchWanted, sources, sha, baseline, cfg.Coverage.Patch.Tolerance, ecosystems) + // The per-unit floor is what the line-weighted aggregate above + // cannot see: a unit small enough that having no tests at all + // barely moves the total. It gates on the units this run + // measured, with no baseline of its own — the floor is an + // absolute standard, not a ratchet, which is also why the + // record-on-main path above runs it and the other two gates. + floorErr := floorReport(cmd, units, cfg.Coverage.Floor, ecosystems) // errors.Join keeps both messages when aggregate AND patch coverage // regress in the same run — AGENTS.md's documented "compute and gate // on both, not either/or" contract must hold for the returned error // too, not just for what gets printed to stdout above. - return errors.Join(aggregateErr, patchErr) + return errors.Join(aggregateErr, patchErr, floorErr) }, } // --dir stays a flag and cannot move into .bulwark.yml: the file lives AT @@ -413,7 +436,7 @@ func computeBaselineAt(ctx context.Context, cmd *cobra.Command, dir, sha string, // source — patch coverage always compares against the current tree's // baseline lookup, never a baseline-of-a-baseline — so PatchWanted is the // zero value here, and the resolved sources/cleanup are discarded. - report, _, cleanup, err := coverage.Compute(ctx, tmp, cfg, coverage.SourceRun, coverage.ReportPaths{}, coverage.PatchWanted{}) + report, _, _, cleanup, err := coverage.Compute(ctx, tmp, cfg, coverage.SourceRun, coverage.ReportPaths{}, coverage.PatchWanted{}) defer cleanup() if err != nil { return nil, err @@ -878,6 +901,116 @@ func diffReport(cmd *cobra.Command, current, baseline map[string]float64, tolera return nil } +// floorReport gates every measured unit against coverage.floor, printing one +// bracketed line per unit that falls below it and a single summary line when +// they all clear. A floor of 0 disables the gate entirely and prints nothing +// — it is opt-in, so a repo that never configured one sees no new output and +// no new failure. +// +// It has no baseline and no tolerance-against-a-previous-value. Aggregate and +// patch coverage both ask "is this worse than it was"; the floor asks "is +// this below the bar", which the repo states once and every unit meets or +// does not. Gating it against a prior value would ratchet a unit that has +// never had tests into permanent acceptability, which is the exact gap it +// exists to close. +// +// A unit that produced no measurement is named as [UNMEASURED], never folded +// into the passing count, for the reason patchReport does the same: a gate +// that did not run must be visibly distinct from a gate that passed. The +// language-level warnUnmeasured cannot cover this — a language reports a +// percentage as soon as one of its units measures, so a repo whose CI +// path-filtered eight of nine TypeScript packages has a fully measured +// language and a floor gate that saw one package. Like diffReport's +// [UNMEASURED], it never fails the gate on its own, and a stderr warning +// names what is missing. +// +// The report is scoped to enabled ecosystems. coverage.Compute measures every +// language it detects without consulting `enabled:` in .bulwark.yml, so its +// units can include a language the repo opted out of gating — and a per-unit +// gate turns that into one build failure per crate for a language nobody +// asked to be gated on. enabled is the caller's answer, so the filter belongs +// here, next to the other two gates that already take it. +// +// Units are reported in a stable order (language, then directory) so the line +// set doesn't reshuffle between runs, and the bracketed vocabulary matches +// diffReport/patchReport because action.yml's PR-comment builder greps for +// exactly that. +func floorReport(cmd *cobra.Command, units []coverage.Unit, floor float64, enabled []detect.Ecosystem) error { + if floor <= 0 || len(units) == 0 { + return nil + } + enabledSet := make(map[string]bool, len(enabled)) + for _, e := range enabled { + enabledSet[string(e)] = true + } + sorted := make([]coverage.Unit, 0, len(units)) + for _, u := range units { + if enabledSet[u.Lang] { + sorted = append(sorted, u) + } + } + if len(sorted) == 0 { + return nil + } + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Lang != sorted[j].Lang { + return sorted[i].Lang < sorted[j].Lang + } + return sorted[i].Dir < sorted[j].Dir + }) + + below, cleared := 0, 0 + for _, u := range sorted { + if !u.Measured() { + detail := fmt.Sprintf("%s floor: %s not measured this run (floor %.1f%% not applied)", + u.Lang, unitLabel(u), floor) + if _, err := fmt.Fprintln(cmd.OutOrStdout(), statusPrefix("UNMEASURED")+detail); err != nil { + return err + } + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), + "warning: no coverage report for %s unit %s, so the %.1f%% floor was not applied to it — its coverage step didn't run, or its report isn't where bulwark looks\n", + u.Lang, unitLabel(u), floor); err != nil { + return err + } + continue + } + pct := u.Lines.Percent() + // Display precision, like regressedBeyond: a unit printed as meeting + // the floor must never be failed for a difference the report can't + // show. + if math.Round(pct*10) >= math.Round(floor*10) { + cleared++ + continue + } + below++ + detail := fmt.Sprintf("%s floor: %s at %.1f%% (%d/%d lines, floor %.1f%%)", + u.Lang, unitLabel(u), pct, u.Lines.Covered, u.Lines.Total, floor) + if _, err := fmt.Fprintln(cmd.OutOrStdout(), statusPrefix("FAIL")+detail); err != nil { + return err + } + } + if below == 0 { + // "N of M" rather than a bare count: the two numbers differ exactly + // when some unit went ungated, so the PR comment cannot read as + // repo-wide coverage of a partial run. + detail := fmt.Sprintf("floor: %d of %d unit(s) at or above %.1f%%", cleared, len(sorted), floor) + if _, err := fmt.Fprintln(cmd.OutOrStdout(), statusPrefix("PASS")+detail); err != nil { + return err + } + return nil + } + return fmt.Errorf("%d unit(s) below the %.1f%% per-unit coverage floor", below, floor) +} + +// unitLabel names a unit for a report line. A unit rooted at --dir itself has +// an empty relative directory, which would print as nothing at all. +func unitLabel(u coverage.Unit) string { + if u.Dir == "" { + return "." + } + return u.Dir +} + // printNoCoverage reports a run that measured nothing and — on main — had no // prior baseline entries to carry forward either: there is nothing to gate // and nothing worth recording. diff --git a/cmd/bulwark/coverage_test.go b/cmd/bulwark/coverage_test.go index a94785b..a3deaac 100644 --- a/cmd/bulwark/coverage_test.go +++ b/cmd/bulwark/coverage_test.go @@ -78,7 +78,11 @@ func TestCoverageOnMainRecordsFullyCarriedBaselineWhenNothingMeasured(t *testing run(seed, "init", "-b", gitstate.BranchName, ".") run(seed, "config", "user.email", "t@t") run(seed, "config", "user.name", "t") - if err := os.WriteFile(filepath.Join(seed, c1+".json"), []byte(`{"typescript":93.8}`), 0o600); err != nil { + seeded := filepath.Join(seed, filepath.FromSlash(gitstate.StatePath(c1))) + if err := os.MkdirAll(filepath.Dir(seeded), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(seeded, []byte(`{"typescript":93.8}`), 0o600); err != nil { t.Fatal(err) } run(seed, "add", "-A") @@ -105,7 +109,7 @@ func TestCoverageOnMainRecordsFullyCarriedBaselineWhenNothingMeasured(t *testing if err != nil { t.Fatalf("resolve tree for %s: %v", c2, err) } - r := executil.Run(ctx, repo, "git", "show", "origin/"+gitstate.BranchName+":"+tree+".json") + r := executil.Run(ctx, repo, "git", "show", "origin/"+gitstate.BranchName+":"+gitstate.StatePath(tree)) if !r.Ok() { t.Fatalf("no baseline recorded for tree %s (commit %s): %v\nstdout: %s\nstderr: %s", tree, c2, r.Err, out.String(), errOut.String()) } @@ -790,3 +794,213 @@ func TestFormatReport(t *testing.T) { t.Errorf("formatReport(empty) = %q, want \"\"", got) } } + +func runFloorReport(t *testing.T, floor float64, units ...coverage.Unit) (string, error) { + t.Helper() + cmd := &cobra.Command{} + var buf bytes.Buffer + cmd.SetOut(&buf) + err := floorReport(cmd, units, floor, []detect.Ecosystem{detect.Go, detect.Rust, detect.TypeScript}) + return buf.String(), err +} + +// The floor is opt-in: a repo that never set one must see no new line and no +// new failure when it upgrades, however bad its worst unit is. +func TestFloorReportDisabledByDefault(t *testing.T) { + out, err := runFloorReport(t, config.Default().Coverage.Floor, + coverage.Unit{Lang: "typescript", Dir: "packages/layout", Lines: coverage.LineCount{Covered: 0, Total: 8}}) + if err != nil { + t.Fatalf("floor 0 must gate nothing, got error: %v", err) + } + if out != "" { + t.Errorf("floor 0 printed %q, want nothing at all", out) + } +} + +// What the line-weighted aggregate cannot see: an untested package that is +// 8 lines of 8,305 moves the headline by 0.1%, so only a per-unit floor +// reports it. +func TestFloorReportFailsAUnitBelowTheFloor(t *testing.T) { + out, err := runFloorReport(t, 50, + coverage.Unit{Lang: "typescript", Dir: "packages/expressions", Lines: coverage.LineCount{Covered: 4300, Total: 4494}}, + coverage.Unit{Lang: "typescript", Dir: "packages/layout", Lines: coverage.LineCount{Covered: 0, Total: 8}}) + if err == nil { + t.Fatal("a unit at 0% against a 50% floor must fail the gate") + } + if !strings.Contains(out, "[FAIL]") || !strings.Contains(out, "packages/layout") { + t.Errorf("expected a [FAIL] line naming packages/layout, got: %q", out) + } + if !strings.Contains(out, "0/8 lines") { + t.Errorf("expected the unit's counts in the line, got: %q", out) + } + if strings.Contains(out, "packages/expressions") { + t.Errorf("a unit above the floor must not be listed individually, got: %q", out) + } +} + +func TestFloorReportPassesWhenEveryUnitClears(t *testing.T) { + out, err := runFloorReport(t, 50, + coverage.Unit{Lang: "go", Dir: "", Lines: coverage.LineCount{Covered: 9, Total: 10}}, + coverage.Unit{Lang: "go", Dir: "sdk", Lines: coverage.LineCount{Covered: 6, Total: 10}}) + if err != nil { + t.Fatalf("every unit clears the floor, got error: %v", err) + } + if !strings.Contains(out, "[PASS]") || !strings.Contains(out, "floor: 2 of 2 unit(s)") { + t.Errorf("expected a single [PASS] summary line naming cleared-of-total, got: %q", out) + } +} + +// A unit with no coverage report must be named, never folded into the passing +// count. The language-level warning cannot cover this: a language reports a +// percentage as soon as one of its units measures, so a repo whose CI +// path-filtered most of its packages has a fully measured language and a +// floor gate that saw one package — and a bare "[PASS] floor: N unit(s)" +// reads in the PR comment as if the gate covered the repo. +func TestFloorReportNamesUnmeasuredUnits(t *testing.T) { + cmd := &cobra.Command{} + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + err := floorReport(cmd, []coverage.Unit{ + {Lang: "typescript", Dir: "packages/measured", Lines: coverage.LineCount{Covered: 90, Total: 100}}, + {Lang: "typescript", Dir: "packages/skipped"}, + }, 50, []detect.Ecosystem{detect.TypeScript}) + if err != nil { + t.Fatalf("an unmeasured unit must not fail the gate on its own, got: %v", err) + } + if !strings.Contains(out.String(), "[UNMEASURED]") || !strings.Contains(out.String(), "packages/skipped") { + t.Errorf("expected an [UNMEASURED] line naming packages/skipped, got: %q", out.String()) + } + if !strings.Contains(out.String(), "floor: 1 of 2 unit(s)") { + t.Errorf("the PASS line must show cleared-of-total so a partial run is visible, got: %q", out.String()) + } + // Stderr, not stdout: action.yml's PR-comment builder scrapes bracketed + // tags from stdout with a pattern that is not line-anchored. + if !strings.Contains(errOut.String(), "packages/skipped") { + t.Errorf("expected a stderr warning naming the missing report, got: %q", errOut.String()) + } +} + +// An unmeasured unit is not a unit at 0%: it must never be failed against the +// floor, which is the whole reason Compute returns it with a zero LineCount +// rather than dropping it. +func TestFloorReportDoesNotFailAnUnmeasuredUnit(t *testing.T) { + _, err := runFloorReport(t, 90, + coverage.Unit{Lang: "rust", Dir: "daemon"}, + coverage.Unit{Lang: "rust", Dir: "cli", Lines: coverage.LineCount{Covered: 95, Total: 100}}) + if err != nil { + t.Errorf("an unmeasured unit must not be gated as 0%%, got: %v", err) + } +} + +// The floor is compared at the report's display precision, like the +// tolerances: a unit shown as meeting the floor must never be failed over a +// difference the report cannot show. +func TestFloorReportComparesAtDisplayPrecision(t *testing.T) { + // 8524/10000 = 85.24%, displayed as 85.2%, against a floor of 85.2%. + if _, err := runFloorReport(t, 85.2, + coverage.Unit{Lang: "rust", Dir: "daemon", Lines: coverage.LineCount{Covered: 8524, Total: 10000}}); err != nil { + t.Errorf("a unit displayed at exactly the floor must pass, got error: %v", err) + } +} + +// A unit rooted at --dir itself has an empty relative directory, which would +// otherwise print as nothing at all and leave the line unattributable. +func TestFloorReportNamesTheRootUnit(t *testing.T) { + out, _ := runFloorReport(t, 90, + coverage.Unit{Lang: "go", Dir: "", Lines: coverage.LineCount{Covered: 1, Total: 10}}) + if !strings.Contains(out, "go floor: . at 10.0%") { + t.Errorf("expected the root unit named \".\", got: %q", out) + } +} + +// coverage.Compute measures every language it detects without consulting +// `enabled:` in .bulwark.yml, so its units can carry a language the repo +// opted out of gating. A per-unit gate must not turn that into one build +// failure per crate for a language nobody asked to be gated on. +func TestFloorReportSkipsDisabledLanguages(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + err := floorReport(cmd, []coverage.Unit{ + {Lang: "go", Dir: "", Lines: coverage.LineCount{Covered: 90, Total: 100}}, + {Lang: "rust", Dir: "daemon", Lines: coverage.LineCount{Covered: 0, Total: 500}}, + }, 60, []detect.Ecosystem{detect.Go}) + if err != nil { + t.Fatalf("a crate in a disabled language must not fail the floor gate, got: %v", err) + } + if strings.Contains(out.String(), "rust") { + t.Errorf("a disabled language must not appear in the floor report, got: %q", out.String()) + } + if !strings.Contains(out.String(), "floor: 1 of 1 unit(s)") { + t.Errorf("the enabled language's unit must still be counted, got: %q", out.String()) + } +} + +// The floor is an absolute standard with no baseline, so unlike the aggregate +// and patch gates it is meaningful on main too: the current commit IS the +// baseline there, but a unit is still either above the bar or below it. +// Skipping it would leave main ungated on a unit that arrived through a path +// no pull request measured. The baseline is still recorded first, and +// unconditionally — losing it over a floor failure would push every later +// pull request into a recompute-nothing cache miss. +func TestCoverageOnMainRecordsBaselineThenGatesOnTheFloor(t *testing.T) { + ctx := context.Background() + run := func(dir string, args ...string) { + t.Helper() + if r := executil.Run(ctx, dir, "git", args...); !r.Ok() { + t.Fatalf("git %v: %v\n%s", args, r.Err, r.Output) + } + } + + origin := t.TempDir() + run(origin, "init", "--bare", "-b", "main", ".") + + repo := t.TempDir() + run(repo, "init", "-b", "main", ".") + run(repo, "config", "user.email", "t@t") + run(repo, "config", "user.name", "t") + // One Go module, entirely uncovered, against a floor it cannot meet. + for name, body := range map[string]string{ + "go.mod": "module fixture\n\ngo 1.26\n", + "main.go": "package fixture\n\nfunc Foo() int { return 1 }\n", + "coverage.out": "mode: set\nfixture/main.go:3.16,3.28 1 0\n", + config.FileName: "coverage:\n source: report\n floor: 50\n", + } { + if err := os.WriteFile(filepath.Join(repo, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + run(repo, "add", "-A") + run(repo, "commit", "-m", "fixture") + run(repo, "remote", "add", "origin", origin) + run(repo, "push", "origin", "main") + run(repo, "fetch", "origin") + + cmd := newCoverageCmd() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"--dir", repo}) + err := cmd.Execute() + + if !strings.Contains(out.String(), "recorded coverage baseline") { + t.Errorf("the baseline must be recorded even when the floor fails, got stdout: %q", out.String()) + } + if err == nil { + t.Errorf("a unit at 0%% against a 50%% floor must fail the main run too, got nil\nstdout: %s", out.String()) + } + if !strings.Contains(out.String(), "[FAIL]") || !strings.Contains(out.String(), "floor") { + t.Errorf("expected a [FAIL] floor line on the main run, got stdout: %q", out.String()) + } + + // The recorded baseline must have landed on the branch regardless. + run(repo, "fetch", "origin", gitstate.BranchName) + tree, treeErr := gitstate.TreeSHA(ctx, repo, "HEAD") + if treeErr != nil { + t.Fatalf("resolve tree: %v", treeErr) + } + if r := executil.Run(ctx, repo, "git", "show", "origin/"+gitstate.BranchName+":"+gitstate.StatePath(tree)); !r.Ok() { + t.Errorf("no baseline recorded for tree %s despite the floor failure: %v", tree, r.Err) + } +} diff --git a/cmd/bulwark/linter_test.go b/cmd/bulwark/linter_test.go deleted file mode 100644 index 7197beb..0000000 --- a/cmd/bulwark/linter_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "testing" - - "wardnet/bulwark/internal/config" - "wardnet/bulwark/internal/typescript" -) - -// TestLinterConstantsAgree ties the two Linter vocabularies together. -// -// scan.go bridges them with an unchecked string conversion -// (typescript.Linter(cfg.TypeScript.Linter)), and typescript.Check treats -// anything that isn't Biome as ESLint. So if the two constant sets ever diverge -// — renaming config.LinterBiome's value without renaming typescript.Biome's — -// validateLinter still accepts the config, the repo believes it opted in, and -// bulwark silently runs ESLint. That is precisely the failure validateLinter -// exists to prevent, moved one layer down where nothing was watching. -// -// internal/typescript deliberately does not import internal/config (it takes a -// plain parameter rather than depending on the config package), so this -// agreement can only be checked from a package that sees both. -func TestLinterConstantsAgree(t *testing.T) { - for _, tc := range []struct { - name string - cfg config.Linter - ts typescript.Linter - }{ - {"eslint", config.LinterESLint, typescript.ESLint}, - {"biome", config.LinterBiome, typescript.Biome}, - } { - if string(tc.cfg) != string(tc.ts) { - t.Errorf("config.Linter %q and typescript.Linter %q disagree for %s; "+ - "scan.go's string conversion would silently fall back to ESLint", - tc.cfg, tc.ts, tc.name) - } - } -} - -// TestBiomeLinterSurvivesConversion is the same guard stated as behavior: the -// value a repo writes in .bulwark.yml must still select Biome after crossing the -// package boundary. -func TestBiomeLinterSurvivesConversion(t *testing.T) { - if got := typescript.Linter(config.LinterBiome); got != typescript.Biome { - t.Errorf("typescript.Linter(config.LinterBiome) = %q, want %q — opting into Biome would silently run ESLint", got, typescript.Biome) - } -} diff --git a/cmd/bulwark/scan.go b/cmd/bulwark/scan.go index 59ab337..8904c2b 100644 --- a/cmd/bulwark/scan.go +++ b/cmd/bulwark/scan.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -63,7 +64,7 @@ func newScanCmd() *cobra.Command { if !cfg.TypeScript.Enabled { continue } - tsResults, err := typescript.Check(ctx, dir, cfg.TypeScript.Exclude, typescript.Linter(cfg.TypeScript.Linter)) + tsResults, err := typescript.Check(ctx, dir, cfg.TypeScript.Exclude) if err != nil { return err } @@ -126,6 +127,20 @@ func resolveDiffBase(ctx context.Context, dir, diffBase string) (string, error) // report prints a pass/fail line per check and returns an error if any // check failed, so the process exit code reflects the aggregate result. +// +// A failing check also prints its Detail, which is the only place some +// findings exist. Most tools stream their own output live through +// executil.Run, so it is already on the terminal and in the log the action +// captures; Biome does not, because bulwark sends its report to a file so the +// JSON cannot be corrupted by Biome's own chatter. Printing only "[FAIL] +// biome(.)" left the developer to re-run the pinned toolchain by hand to find +// out what was wrong, and put nothing in the PR comment either. +// +// Detail lines are indented, which is not cosmetic: action.yml's tool_result() +// matches "^\[(PASS|FAIL)\] $" anchored at both ends, so an indented +// line can never be mistaken for a status line even when a finding's message +// happens to contain one. The action's emit_error_output then inlines the tail +// of this same stream into the PR comment, so the findings travel with it. func report(cmd *cobra.Command, results []executil.Result) error { failed := 0 for _, r := range results { @@ -137,6 +152,14 @@ func report(cmd *cobra.Command, results []executil.Result) error { if _, err := fmt.Fprintf(cmd.OutOrStdout(), "[%s] %s\n", status, r.Name); err != nil { return err } + if r.Ok() || strings.TrimSpace(r.Detail) == "" { + continue + } + for _, line := range strings.Split(strings.TrimRight(r.Detail, "\n"), "\n") { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %s\n", line); err != nil { + return err + } + } } if failed > 0 { return fmt.Errorf("%d check(s) failed", failed) diff --git a/cmd/bulwark/scan_test.go b/cmd/bulwark/scan_test.go index 4a0668c..3ebc66e 100644 --- a/cmd/bulwark/scan_test.go +++ b/cmd/bulwark/scan_test.go @@ -1,9 +1,15 @@ package main import ( + "bytes" "context" + "errors" + "strings" "testing" + "github.com/spf13/cobra" + + "wardnet/bulwark/internal/executil" "wardnet/bulwark/internal/semgrep" ) @@ -35,3 +41,68 @@ func TestResolveDiffBase(t *testing.T) { }) } } + +// A failing check whose findings never streamed must print them. Biome sends +// its report to a file so the JSON cannot be corrupted by its own chatter, +// which means nothing reaches the terminal on its own — printing a bare +// "[FAIL] biome(.)" left the developer to re-run the pinned toolchain by hand +// to find out what was wrong, and put nothing in the PR comment either. +func TestReportPrintsDetailForFailingChecks(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + err := report(cmd, []executil.Result{ + { + Name: "biome(.)", + Detail: "src/bad.ts:1 lint/security/noGlobalEval eval() is dangerous\nsrc/bad.ts:4 lint/correctness/noUnusedVariables unused", + Err: errors.New("2 finding(s)"), + }, + }) + if err == nil { + t.Fatal("a failing check must still return an error") + } + for _, want := range []string{"[FAIL] biome(.)", "noGlobalEval", "eval() is dangerous", "noUnusedVariables"} { + if !strings.Contains(out.String(), want) { + t.Errorf("report output missing %q:\n%s", want, out.String()) + } + } +} + +// action.yml's tool_result() matches "^\[(PASS|FAIL)\] $" anchored at +// both ends to decide a tool's verdict. A finding whose message happens to +// contain that shape must not be able to forge one, which is what indenting +// every detail line buys. +func TestReportDetailCannotForgeAStatusLine(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + _ = report(cmd, []executil.Result{ + {Name: "biome(.)", Detail: "[PASS] biome(.)", Err: errors.New("1 finding(s)")}, + }) + statusLines := 0 + for _, line := range strings.Split(out.String(), "\n") { + if strings.HasPrefix(line, "[PASS] ") || strings.HasPrefix(line, "[FAIL] ") { + statusLines++ + } + } + if statusLines != 1 { + t.Errorf("got %d unindented status lines, want exactly 1 — a finding forged one:\n%s", statusLines, out.String()) + } +} + +// Passing checks print no detail, and a tool that streamed its own output is +// not reprinted: doing either would duplicate the log or bury the summary. +func TestReportPrintsNoDetailForPassingOrStreamingChecks(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + _ = report(cmd, []executil.Result{ + {Name: "biome(.)", Detail: "should not appear"}, + {Name: "semgrep", Output: "already streamed to the terminal", Err: errors.New("findings")}, + }) + for _, unwanted := range []string{"should not appear", "already streamed"} { + if strings.Contains(out.String(), unwanted) { + t.Errorf("report printed %q:\n%s", unwanted, out.String()) + } + } +} diff --git a/docs/adr/0005-optional-biome-linter.md b/docs/adr/0005-optional-biome-linter.md index 41cd7a8..9e078b2 100644 --- a/docs/adr/0005-optional-biome-linter.md +++ b/docs/adr/0005-optional-biome-linter.md @@ -1,5 +1,10 @@ # TypeScript projects may opt into Biome, one repo at a time +> **Superseded by [ADR 0008](0008-biome-as-the-only-typescript-linter.md).** Biome is now the +> only TypeScript linter; ESLint is removed and `typescript.linter: eslint` is rejected. The +> rule-set comparison below still holds and is the record of what that removal costs. + + The consuming projects are migrating from ESLint to Biome, so `.bulwark.yml` grows `typescript.linter: eslint|biome` — a validated enum defaulting to `eslint`, mirroring `coverage.source`'s precedent of naming the decision the repo makes rather than bulwark's diff --git a/docs/adr/0007-line-weighted-coverage-aggregation.md b/docs/adr/0007-line-weighted-coverage-aggregation.md new file mode 100644 index 0000000..2104c7e --- /dev/null +++ b/docs/adr/0007-line-weighted-coverage-aggregation.md @@ -0,0 +1,87 @@ +# A language's coverage is its units' summed line counts, plus a per-unit floor + +`internal/coverage` reduces a language's per-unit measurements to one figure by +summing counts — `Σ covered / Σ total` across every discovered Go module, Rust +crate/workspace root and TypeScript package — rather than by taking the mean of +the units' percentages. Each unit is therefore weighted by its size. The +per-unit measurement functions return a `LineCount` (Go counts statements, +because that is what a Go profile records; the ratio is the same quantity) and +`Compute` returns the `Unit` list alongside the per-language percentages. + +ADR 0001 and ADR 0002 decided that Rust and Go coverage must span every +discovered crate and module, and each chose the mean because it was the obvious +reduction and because the repos in front of them had one unit. This is the +document that contradicts that half of them. + +The mean is not a mild approximation. On `pedromvgomes/hatua`, a nine-package +pnpm monorepo, one commit added a 39-line untested file to `apps/playground` +(230 lines) while adding ~1,250 well-tested lines elsewhere: + +| Aggregation | baseline | after | reported change | +| --- | --- | --- | --- | +| Unweighted mean | 85.31% | 83.13% | **−2.2** | +| `Σ covered / Σ total` | 94.18% | 94.62% | **+0.44** | + +The gate failed a change that improved line coverage, and it failed it by five +times the true magnitude in the opposite direction. `packages/expressions` is +4,494 lines and `apps/playground` is 230, and the mean gave them equal votes. +Widening `coverage.tolerance` to hide this was rejected outright: its whole +purpose is absorbing sub-tenth instrumentation noise, and a tolerance wide +enough to swallow a 2.2-point artefact swallows a genuine two-point regression +with it. + +## What line-weighting costs, and `coverage.floor` + +Weighting by lines is deliberately blind to a small unit nobody tests. In that +same repo `packages/layout` has zero tests — 0 of 8 lines. Under the mean that +cost about 11 points and was most of why the baseline read 85% rather than 94%; +under line-weighting it is 8 lines in 8,305, so the headline moves by 0.1% and +the gate says nothing at all about a package with no tests. + +So the old number was accidentally answering a second, different question, and +losing it silently would have been the worse trade: + +* *"Did this change leave code untested?"* → `Σ covered / Σ total` +* *"Is there a unit nobody tests at all?"* → the minimum per-unit percentage + +`coverage.floor` in `.bulwark.yml` is the second question, stated directly. +It is the minimum percentage any single measured unit must reach, it defaults +to `0` (off), and `cmd/bulwark/coverage.go`'s `floorReport` prints one `[FAIL]` +line per unit below it. Opt-in, because upgrading bulwark must not start failing +a repo over a gap it has always had. + +Three properties follow from a floor having no baseline, and all three separate +it from the other two gates: + +* **No ratchet.** A floor is an absolute standard; comparing it against a prior + value would make a unit that has never had tests permanently acceptable, + which is the gap it exists to close. +* **It runs on a push to main.** The aggregate and patch gates compare against + a baseline, and on main the current commit *is* that baseline, so they have + nothing to compare and return early. A floor compares against a number the + repo stated once, which reads the same on either side — and skipping it there + would leave main ungated on a unit that arrived through a path no pull request + measured. The baseline is still recorded first, and unconditionally. +* **A unit whose report is missing is `[UNMEASURED]`, not passing and not 0%.** + A per-unit gate that quietly covers only the units that happened to measure is + the same silent-pass failure this repo already fixed once for patch coverage. + +## Cached baselines are invalidated by moving them, not by reinterpreting them + +Every entry on a consumer's `bulwark-state` branch was recorded under the mean. +Comparing today's line-weighted figure against one of those compares two +different quantities, and in the repo above the difference is +9 points — large +enough to read as a step change in either direction depending on which way a +repo's small units lean. + +Baselines therefore move to `v2/.json` on the same branch +(`gitstate.StatePath`). Every consumer takes one clean cache miss and re-records +under the new metric. The alternatives were both worse: a marker inside the file +would have turned the entries from a plain language → percentage object into a +wrapper, losing the property that they can be read by hand on the branch; and +leaving the path alone would have every consumer silently compare across a +metric change for at least one PR. Moving rather than deleting also leaves the +old entries in place to be inspected. + +Release notes must say this, because the step change is visible whatever bulwark +does about it. diff --git a/docs/adr/0008-biome-as-the-only-typescript-linter.md b/docs/adr/0008-biome-as-the-only-typescript-linter.md new file mode 100644 index 0000000..ca8ae83 --- /dev/null +++ b/docs/adr/0008-biome-as-the-only-typescript-linter.md @@ -0,0 +1,99 @@ +# Biome is the only TypeScript linter + +`internal/typescript` runs Biome and nothing else. The ESLint stack — +`eslint`, `eslint-plugin-security`, `@typescript-eslint/parser`, and the +`typescript` package the parser needs to read `.ts` at all — is removed, along +with `eslint-pin/`, the bundled `eslint.config.mjs`, and the Dependabot +ecosystem watching them. `typescript.linter` survives as a key with one +accepted value, `biome`. + +This supersedes [ADR 0005](0005-optional-biome-linter.md), which made Biome +opt-in per repo on the reasoning that a repo migrating off ESLint should flip +the key when it is ready. That was right while both engines worked. It shipped +as part of v2.0.0, alongside the other breaking changes, because it is one. + +## Why now + +ESLint's TypeScript support is not a linter dependency, it is a compiler +dependency. `@typescript-eslint/parser` declares `typescript` as a peer with an +upper bound — `>=4.8.4 <6.1.0` at the time of writing — so bulwark's own pin +manifest had to carry a `typescript` version inside that window. That ceiling +moves whenever @typescript-eslint ships support for a new compiler, which is +always after the compiler is released. + +The package doc in `internal/typescript` had already recorded the hazard in as +many words: "which is why the typescript pin is 5.x and not the 7.x now on npm +latest. A Dependabot PR bumping typescript across that ceiling must bump the +parser too." A Dependabot PR then bumped `typescript` to 7.0.2, crossed the +ceiling, and merged. `npm ci` on the committed lockfile has failed with +`ERESOLVE` ever since, which means `installNPMToolchain` fails and every +consumer with TypeScript gets an error instead of a lint result. + +Two things about that are worse than the outage: + +* **CI stayed green through it.** bulwark's own repository has no TypeScript to + scan — its only `package.json` files are the pin manifests, which + `.bulwark.yml` excludes by name — so `self-scan` never exercises the install + path. The one job that dogfoods the scanner cannot reach this code. +* **Documenting the constraint was not enough to hold it.** The doc was correct, + specific, and ignored, because nothing executable enforced it. + +Biome has no such coupling. It parses TypeScript with its own Rust parser and +depends on no compiler package, so `biome-pin` is a single dependency with no +peer range to cross. The failure class does not exist for it. + +## What this costs, measured rather than assumed + +Biome is not a replacement for `eslint-plugin-security` — its `security` group +is six JSX/eval/secret rules, and only `noGlobalEval` coincides with anything +the plugin had. But Biome was never where that coverage had to come from. +**Semgrep is**, and bulwark already runs it across every ecosystem at +`config: auto`. + +Checked directly, by scanning a TypeScript fixture containing all four +vulnerability classes with the pinned Semgrep this repo ships: + +| `eslint-plugin-security` rule | Covered by Semgrep at `config: auto` | +|---|---| +| `detect-child-process` | Yes — `javascript.lang.security.detect-child-process`, and with taint tracking: it names the tainted argument rather than flagging the import | +| `detect-non-literal-fs-filename` | Yes — as `...audit.path-traversal.path-join-resolve-traversal`, which reports the vulnerability class the ESLint rule is a proxy for | +| `detect-unsafe-regex` | Partly — `...audit.detect-non-literal-regexp` catches a pattern built from a variable; static ReDoS analysis of a *literal* pattern has no equivalent | +| `detect-object-injection` | No | + +Several of the Semgrep rules are named after the ESLint ones because they are +ports of them, and the taint-tracking versions are stronger than the syntactic +heuristics they replace. + +So the real loss is **`detect-object-injection`, plus literal-pattern ReDoS +analysis** — not the four rules. `detect-object-injection` flags every +`obj[key]` where the key is not a literal, which in ordinary TypeScript is +most property access through a variable; it is the most commonly disabled rule +in the plugin, and the consuming repos were not shown to depend on it. + +The alternative was keeping ESLint working, which means tracking +@typescript-eslint's compiler ceiling forever: every TypeScript major release +starts a window in which bulwark's pin cannot advance, and closing that window +is upstream's decision, not ours. Paying that indefinitely, for one noisy rule +and one regex analysis, is the trade this rejects. + +Biome's own plugin system was considered and rejected as the place to rebuild +any of this. GritQL plugins match syntax patterns against the CST and can emit +diagnostics and fixes, but they carry no dataflow or taint analysis — Biome +2.5's cross-file linting is CSS class matching, explicitly not data +propagation — and there is no distribution mechanism for sharing rules yet. +`detect-object-injection` is syntactic enough to hand-write as a plugin; the +ReDoS analysis is not expressible at all. Rebuilding a weaker copy of what +Semgrep already does with taint would be the wrong direction. + +## Migrating + +A `.bulwark.yml` carrying `typescript.linter: eslint` is **rejected**, with an +error naming the removal, rather than accepted and silently run under Biome. +A repo that set that key stated which rule set it gates on; switching it without +saying so would change what the scan measures while every run still reported +`[PASS]`, which is the failure this repo rejects unknown config values to +prevent. A value that used to be valid earns the same treatment and a better +sentence. + +`internal/config.validateLinter` owns that, and `LinterESLint` remains defined +for no other purpose than to be recognised and refused. diff --git a/docs/release-notes/_header.md b/docs/release-notes/_header.md new file mode 100644 index 0000000..96df3e7 --- /dev/null +++ b/docs/release-notes/_header.md @@ -0,0 +1,12 @@ +## bulwark + +Unified code-quality and security scanning for Rust, TypeScript, and Go — +one CLI, run identically locally and in CI. + +**Install / update** + +```sh +curl -fsSL https://github.com/wardnet/bulwark/releases/latest/download/install.sh | sh +``` + +`bulwark update` self-updates the CLI in place. diff --git a/docs/release-notes/v2.0.0.md b/docs/release-notes/v2.0.0.md new file mode 100644 index 0000000..3350a29 --- /dev/null +++ b/docs/release-notes/v2.0.0.md @@ -0,0 +1,144 @@ +# v2.0.0 — line-weighted coverage, and Biome as the only TypeScript linter + +Two breaking changes. **`bulwark coverage`'s headline number means something +different** — every repo's figure moves on upgrade, upward in every case +measured so far, by as much as 9 percentage points. And **ESLint is removed**; +Biome is now the only engine behind the TypeScript check, which both fixes a +currently-broken install and reduces security coverage for TypeScript repos. + +Consumers pinned to `wardnet/bulwark@v1` stay on v1.9.0 and are unaffected +until they move the pin. + +## Upgrading + +```yaml +- uses: wardnet/bulwark@v2 # was @v1 +``` + +If your `.bulwark.yml` sets `typescript.linter: eslint`, remove the key — +bulwark now **rejects** that value rather than quietly running Biome instead. +Nothing else is required. The first run against each main commit takes one cache +miss on the `bulwark-state` branch and records a fresh baseline; from the second +run on, the gate compares normally. There is no branch to delete and no +migration to run — v1's entries stay where they are, are simply never read, and +can be removed at leisure. + +Two things are worth knowing before the first run: + +- **The first pull request after the upgrade reports every language as `[NEW]`** + and gates on nothing, because the baseline it would compare against does not + exist yet. This is the same behaviour as a repo's very first bulwark run. Merge + it, let the main build record the baseline, and the next pull request gates + normally. +- **Consumers whose coverage is produced by a multi-job pipeline + (`coverage.source: report`) must already be running `bulwark coverage` on + pushes to main**, not only on pull requests. That was true before this release + and is unchanged, but the cache miss above makes it visible: such a repo cannot + reconstruct a historical baseline, so if main never records one, every pull + request stays stuck at `[NEW]`. + +## What changed + +### ESLint is removed; Biome is the only TypeScript linter + +`typescript.linter` accepts `biome` and nothing else. A config still carrying +`eslint` fails to load, with an error naming the removal — deliberately, rather +than being accepted and run under Biome, because the two do not check the same +things and a silent switch would change what you gate on while every run still +printed `[PASS]`. + +**Most of what `eslint-plugin-security` checked is still checked — by Semgrep, +which bulwark already runs on every ecosystem.** Verified by scanning a fixture +containing all four vulnerability classes: + +| Old ESLint rule | Still covered? | +|---|---| +| `detect-child-process` | Yes — Semgrep, with taint tracking (names the tainted argument) | +| `detect-non-literal-fs-filename` | Yes — Semgrep, as a path-traversal finding | +| `detect-unsafe-regex` | Partly — dynamic patterns yes; ReDoS analysis of a literal pattern, no | +| `detect-object-injection` | **No** | + +So the genuine loss is `detect-object-injection` and literal-pattern ReDoS +analysis. `detect-object-injection` flags every `obj[key]` with a non-literal +key, which is most variable property access in ordinary TypeScript; it is the +plugin's most commonly disabled rule. If you relied on it, Semgrep has no +equivalent and you will want a custom rule. + +Expect *different* TypeScript findings either way: Biome gates on its +**`correctness`** group as well as `security`, so there will likely be more of +them, and they will be about different things. + +What you get for it: bulwark's TypeScript linting no longer depends on the +TypeScript compiler at all. `@typescript-eslint/parser` needed `typescript` as a +peer with an upper bound, so bulwark had to pin a compiler inside a window that +only moves when upstream ships support for a new release — and on v1.9.0 that +window is already crossed: `npm ci` on the shipped ESLint pin fails with +`ERESOLVE`, so **TypeScript linting is broken in v1.9.0** and any repo hitting it +gets an install error rather than a lint result. Biome parses TypeScript with its +own parser and pins no compiler, so the failure cannot recur. See +[ADR 0008](../adr/0008-biome-as-the-only-typescript-linter.md). + +### A language's coverage is now its units' summed line counts + +A language's figure is `Σ covered / Σ total` across every discovered Go module, +Rust crate/workspace root and TypeScript package. It was the unweighted mean of +those units' percentages, which gave a 230-line app the same vote as a +4,494-line library. + +That is not a rounding difference. On a nine-package monorepo, one commit that +added a 39-line untested file to the smallest package while adding ~1,250 +well-tested lines elsewhere read as **−2.2** under the mean and **+0.44** by line +count. The gate failed a change that improved coverage, by five times the true +magnitude, in the opposite direction. + +Expect your reported coverage to rise, and to become less volatile: a small +package can no longer swing the headline on its own. + +A unit whose report is missing is now reported as unmeasured rather than dropped +silently, so a partially-measured language is visible instead of looking fully +gated. + +### New: `coverage.floor`, an opt-in per-unit gate + +Weighting by lines is deliberately blind to a small unit nobody tests — a +package with 0 of 8 lines covered is 0.1% of an 8,305-line repo, so the headline +barely moves. That signal was previously supplied by accident, by the mean. +`coverage.floor` supplies it deliberately: + +```yaml +coverage: + floor: 60 # every module/crate/package must reach 60%; 0 (the default) is off +``` + +It defaults to off, so upgrading never starts failing a repo over a gap it has +always had. It has no baseline and no ratchet — a floor is an absolute standard, +and comparing it against a prior value would make a unit that has never had +tests permanently acceptable. It is also the only gate that runs on pushes to +main, for the same reason: the other two need a baseline to compare against, and +on main the current commit is that baseline. + +Turn it on gradually. Run once with a floor at or just below your worst unit to +see the `[FAIL]` lines, then raise it. + +### Baselines moved to `v2/` on the `bulwark-state` branch + +Entries are written to and read from `v2/.json`. A figure is only comparable +to one produced by the same metric, so any future change to how a percentage is +derived bumps this directory again — which is why the version is the metric's, +not the file format's. The entries themselves are unchanged: still a plain +language → percentage object, still readable by hand on the branch. + +## Compatibility + +| | | +|---|---| +| CLI flags | unchanged | +| `.bulwark.yml` schema | new optional `coverage.floor`; **`typescript.linter: eslint` now rejected** | +| `action.yml` inputs | unchanged | +| `bulwark-state` entries | new path, old entries never read | +| Reported coverage figures | **changed** — expect a step up | +| TypeScript findings | **changed** — Biome's security + correctness, not eslint-plugin-security | + +Background and the rejected alternatives are in +[ADR 0007](../adr/0007-line-weighted-coverage-aggregation.md) and +[ADR 0008](../adr/0008-biome-as-the-only-typescript-linter.md). diff --git a/internal/config/config.go b/internal/config/config.go index eb1e390..929f328 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,37 +36,29 @@ type Language struct { Exclude []string `yaml:"exclude"` } -// Linter names which engine backs the TypeScript check. Like Source, it is -// named for the decision the repo makes rather than for bulwark's behavior: -// which linter this repo has migrated to. The answer is a property of the repo -// and holds for every invocation in it. -// -// The two are mutually exclusive by design, and there is deliberately no -// "both". They are also not interchangeable rule sets — eslint-plugin-security -// is a set of Node/backend heuristics (detect-child-process, -// detect-object-injection, detect-non-literal-fs-filename, …) while Biome's -// security group is six JSX/eval/secret rules, and only noGlobalEval genuinely -// coincides with anything ESLint's plugin has. Switching therefore changes what -// bulwark gates on. That is accepted because it is opt-in per repo, and Semgrep -// still runs across every ecosystem either way. See docs/adr/0005. +// Linter names which engine backs the TypeScript check. Biome is the only +// value, and the key survives a single-engine world for one reason: a repo +// that still carries `linter: eslint` must be told, not silently switched. +// Dropping the field would make yaml.Unmarshal ignore it, and such a repo +// would start gating on a different rule set — more correctness findings, +// fewer Node/backend security heuristics — with nothing in the output saying +// so. See docs/adr/0008. type Linter string const ( - // LinterESLint is the default: bulwark's pinned ESLint + - // eslint-plugin-security, reporting security findings only. - LinterESLint Linter = "eslint" - // LinterBiome is opt-in. Biome reports its security *and* correctness - // groups, so a repo that opts in is gating on more than security. + // LinterBiome reports Biome's security *and* correctness groups. LinterBiome Linter = "biome" + // LinterESLint is retired. It is defined so validateLinter can recognise + // it and name what happened, and is never a value bulwark acts on. + LinterESLint Linter = "eslint" ) // TypeScriptLanguage extends Language with TS-only coverage install // configuration. type TypeScriptLanguage struct { Language `yaml:",inline"` - // Linter selects the engine backing the TypeScript check. Defaults to - // LinterESLint, which is the behavior every repo had before this key - // existed. + // Linter selects the engine backing the TypeScript check. LinterBiome is + // the only accepted value. Linter Linter `yaml:"linter"` // Install overrides coverage's install-command auto-detection (npm ci / // corepack enable && yarn install --immutable / pnpm install @@ -249,6 +241,24 @@ type Coverage struct { // tolerated dips can't compound across merges. The patch gate has its // own knob (Patch.Tolerance). Tolerance float64 `yaml:"tolerance"` + // Floor is the minimum coverage percentage any single measured unit — a + // Go module, a Rust crate/workspace root, a TypeScript package — must + // reach. 0 disables the check, which is the default: it is opt-in, so + // upgrading bulwark never starts failing a repo over a gap it has always + // had. + // + // It exists because the aggregate figure is a line-weighted ratio, and + // weighting by lines is deliberately blind to a small unit nobody tests: + // a package with 0 of 8 lines covered is 0.1% of an 8,305-line repo, so + // the headline barely moves and the gate says nothing. The two questions + // are genuinely different — "did this change leave code untested?" is the + // aggregate, "is there a unit nobody tests at all?" is this — and neither + // answers the other. See docs/adr/0007-line-weighted-coverage-aggregation.md. + // + // Compared at the report's display precision (tenths), like the + // tolerances, so a unit shown as meeting the floor is never failed for a + // difference the report cannot show. + Floor float64 `yaml:"floor"` } // Toolchain is the override surface for language-toolchain provisioning — @@ -295,7 +305,7 @@ type Config struct { func Default() Config { return Config{ Rust: Language{Enabled: true}, - TypeScript: TypeScriptLanguage{Language: Language{Enabled: true}, Linter: LinterESLint}, + TypeScript: TypeScriptLanguage{Language: Language{Enabled: true}, Linter: LinterBiome}, Go: Language{Enabled: true}, Semgrep: Semgrep{Enabled: true, Config: "auto"}, Toolchain: Toolchain{Enabled: true}, @@ -339,6 +349,9 @@ func Load(root string) (Config, error) { if err := validateLinter(cfg); err != nil { return Config{}, fmt.Errorf("%s: %w", path, err) } + if err := validateFloor(cfg); err != nil { + return Config{}, fmt.Errorf("%s: %w", path, err) + } return cfg, nil } @@ -356,17 +369,25 @@ func validateSource(cfg Config) error { } } -// validateLinter rejects any typescript.linter other than the two defined -// values, for the same reason validateSource does: silently falling back to -// eslint on a typo ("biomejs", "Biome") would run a linter the repo believes it -// has migrated off, and report [PASS] the whole time. A misspelled opt-in that -// silently does nothing is the worst outcome available here. +// validateLinter accepts only LinterBiome, and gives the retired ESLint value +// its own message. +// +// Rejecting rather than ignoring is the whole point. A repo carrying +// `linter: eslint` has stated which rule set it gates on; accepting the key and +// running Biome anyway would change that silently — Biome's security group is +// six JSX/eval/secret rules where eslint-plugin-security was Node/backend +// heuristics, and Biome's correctness group fires on things ESLint never +// reported. A scan that quietly starts measuring something else is the failure +// this file rejects unknown values to prevent; a value that used to be valid +// deserves the same treatment, and a better sentence. func validateLinter(cfg Config) error { switch cfg.TypeScript.Linter { - case LinterESLint, LinterBiome: + case LinterBiome: return nil + case LinterESLint: + return fmt.Errorf("typescript.linter: %q is no longer supported — Biome is the only TypeScript linter. Remove the key (or set it to %q) and review the rule-set change in docs/adr/0008", LinterESLint, LinterBiome) default: - return fmt.Errorf("typescript.linter must be %q or %q, got %q", LinterESLint, LinterBiome, cfg.TypeScript.Linter) + return fmt.Errorf("typescript.linter must be %q, got %q", LinterBiome, cfg.TypeScript.Linter) } } @@ -387,6 +408,19 @@ func validateTolerances(cfg Config) error { return nil } +// validateFloor rejects a per-unit floor no unit could satisfy or that would +// silently disable itself. NaN makes every comparison false, so the gate +// prints nothing and passes while looking configured; a floor above 100 fails +// every unit including a fully covered one, which is a typo (1000 for 100) +// rather than a policy anyone holds. +func validateFloor(cfg Config) error { + floor := cfg.Coverage.Floor + if math.IsNaN(floor) || math.IsInf(floor, 0) || floor < 0 || floor > 100 { + return fmt.Errorf("coverage.floor must be a percentage between 0 and 100 (0 disables it), got %v", floor) + } + return nil +} + // AllExcludes merges every language's exclude list — used by callers (scan, // coverage) whose initial ecosystem-detection pass doesn't yet know which // language a given excluded directory belongs to. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6fa2cb4..7e845f9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -356,14 +356,14 @@ func write(t *testing.T, dir, contents string) { } } -func TestLoadTypeScriptLinterDefaultsToESLint(t *testing.T) { +func TestLoadTypeScriptLinterDefaultsToBiome(t *testing.T) { dir := t.TempDir() cfg, err := Load(dir) if err != nil { t.Fatalf("Load: %v", err) } - if cfg.TypeScript.Linter != LinterESLint { - t.Errorf("TypeScript.Linter = %q, want %q — every repo predating this key must keep ESLint", cfg.TypeScript.Linter, LinterESLint) + if cfg.TypeScript.Linter != LinterBiome { + t.Errorf("TypeScript.Linter = %q, want %q — Biome is the only engine", cfg.TypeScript.Linter, LinterBiome) } } @@ -377,15 +377,34 @@ func TestLoadTypeScriptLinterBiome(t *testing.T) { if cfg.TypeScript.Linter != LinterBiome { t.Errorf("TypeScript.Linter = %q, want %q", cfg.TypeScript.Linter, LinterBiome) } - // Opting into Biome must not disturb anything else in the section. + // Naming the linter explicitly must not disturb anything else in the section. if !cfg.TypeScript.Enabled { t.Error("TypeScript.Enabled was zeroed by a partial typescript: section") } } -// TestLoadRejectsUnknownLinter guards the failure mode the validator exists for: -// a misspelled opt-in that silently falls back to ESLint would have a repo -// believe it had migrated while bulwark ran the old linter and reported [PASS]. +// A repo still carrying `linter: eslint` has stated which rule set it gates on. +// Accepting the key and running Biome anyway would change that silently — +// Biome's security group is six JSX/eval/secret rules where +// eslint-plugin-security was Node/backend heuristics, and Biome's correctness +// group fires on things ESLint never reported. It must be told. +func TestLoadRejectsTheRetiredESLintValue(t *testing.T) { + dir := t.TempDir() + write(t, dir, "typescript:\n linter: eslint\n") + _, err := Load(dir) + if err == nil { + t.Fatal("Load silently accepted the retired eslint linter") + } + for _, want := range []string{"typescript.linter", "eslint", "biome"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not mention %q, so it cannot be acted on: %v", want, err) + } + } +} + +// TestLoadRejectsUnknownLinter guards the failure mode the validator exists +// for: a misspelled value that silently fell back would have a repo believe it +// had configured something bulwark never read, while every run reported [PASS]. func TestLoadRejectsUnknownLinter(t *testing.T) { dir := t.TempDir() write(t, dir, "typescript:\n linter: bimoe\n") @@ -397,3 +416,54 @@ func TestLoadRejectsUnknownLinter(t *testing.T) { t.Errorf("error does not name the offending key: %v", err) } } + +// The floor is opt-in: absent from .bulwark.yml it must be 0, which disables +// the gate. A default above 0 would fail existing repos on a bulwark upgrade +// over a gap they never agreed to gate on. +func TestCoverageFloorDefaultsToDisabled(t *testing.T) { + if got := Default().Coverage.Floor; got != 0 { + t.Errorf("default coverage.floor = %v, want 0 (disabled)", got) + } + cfg, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if got := cfg.Coverage.Floor; got != 0 { + t.Errorf("coverage.floor with no config file = %v, want 0", got) + } +} + +func TestCoverageFloorLoadsFromFile(t *testing.T) { + dir := t.TempDir() + write(t, dir, "coverage:\n floor: 60\n") + cfg, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if cfg.Coverage.Floor != 60 { + t.Errorf("coverage.floor = %v, want 60", cfg.Coverage.Floor) + } + // Setting the floor must not disturb the other coverage defaults. + if cfg.Coverage.Tolerance != 0.1 || cfg.Coverage.Source != SourceRun { + t.Errorf("coverage defaults disturbed: %+v", cfg.Coverage) + } +} + +// A floor no unit could satisfy, or one that quietly disables itself, is +// rejected rather than accepted: NaN makes every comparison false, so the +// gate would print nothing and pass while looking configured, and a floor +// above 100 fails a fully covered unit. +func TestCoverageFloorRejectsImpossibleValues(t *testing.T) { + for _, body := range []string{ + "coverage:\n floor: -1\n", + "coverage:\n floor: 101\n", + "coverage:\n floor: .nan\n", + "coverage:\n floor: .inf\n", + } { + dir := t.TempDir() + write(t, dir, body) + if _, err := Load(dir); err == nil { + t.Errorf("Load accepted %q, want a rejection", body) + } + } +} diff --git a/internal/coverage/coverage.go b/internal/coverage/coverage.go index c22f853..9ace85c 100644 --- a/internal/coverage/coverage.go +++ b/internal/coverage/coverage.go @@ -120,8 +120,83 @@ type GoModuleProfile struct { RelDir string } +// LineCount is one measured unit's tally of executable lines: how many the +// coverage report knows about, and how many of those were hit. For Go it +// counts statements rather than lines, because that is what a Go coverage +// profile records; the ratio is the same quantity either way. +// +// Carrying the counts rather than a percentage is what lets a language's +// figure be the ratio of its summed counts. A percentage is a lossy +// reduction: once a unit is down to one number, its size is gone, and the +// only aggregation left is an unweighted mean in which a 10-line package +// moves the headline as much as a 5,000-line one. +type LineCount struct { + Covered int + Total int +} + +// Percent is the covered-over-total ratio as a percentage. Total is never +// zero on a LineCount that reached a Unit — a unit with nothing to measure +// is dropped at the point it is measured, so no 0/0 ever enters an +// aggregate. +func (c LineCount) Percent() float64 { + if c.Total == 0 { + return 0 + } + return float64(c.Covered) / float64(c.Total) * 100 +} + +// Unit is one independently-discovered piece of a language's coverage: a Go +// module, a Rust crate/workspace root, or a TypeScript package. Compute +// returns them alongside the per-language percentages so a caller can gate +// on the distribution as well as the total — the per-unit floor in +// cmd/bulwark/coverage.go is the reason they leave this package at all, +// since a line-weighted headline cannot see a small unit with no tests. +// +// Discovered, not measured: a unit whose report is missing is returned with a +// zero Lines rather than dropped. A language reports a percentage as long as +// one of its units measured, so dropping the rest would leave the floor gate +// silently covering fewer units than the repo holds while the language still +// looks fully gated — and the per-language warnUnmeasured cannot see it, +// because the language did measure. Use Measured to tell the two apart. +type Unit struct { + // Lang is the detect.Ecosystem this unit belongs to, as a string. + Lang string + // Dir is the unit root relative to Compute's dir, "" when the unit root + // is dir itself. + Dir string + // Lines is the unit's own tally. Its Total is zero exactly when the unit + // produced no measurement. + Lines LineCount +} + +// Measured reports whether this unit produced a coverage measurement. It is +// the single predicate for that distinction: an unmeasured unit must never +// reach an aggregate or a floor comparison as a 0%, only as "not gated". +func (u Unit) Measured() bool { + return u.Lines.Total > 0 +} + +// aggregate sums every measured unit's counts into one language-level tally. +// This is the whole point of carrying counts: sum-covered over sum-total +// weights each unit by its size, where a mean of percentages weights them all +// equally. Unmeasured units contribute nothing rather than a zero. +func aggregate(units []Unit) LineCount { + var sum LineCount + for _, u := range units { + if !u.Measured() { + continue + } + sum.Covered += u.Lines.Covered + sum.Total += u.Lines.Total + } + return sum +} + // Compute returns a coverage percentage per detected ecosystem under dir, -// plus whatever patch-coverage sources want asked for. An ecosystem is +// every unit that percentage was summed from, and whatever patch-coverage +// sources want asked for. A language's percentage is the ratio of its units' +// summed line counts, not the mean of their percentages. An ecosystem is // silently omitted from the percentage map (not an error) when its coverage // tooling isn't available or produces no measurable result — coverage // tooling is more varied across projects than a linter, so bulwark reports @@ -139,10 +214,10 @@ type GoModuleProfile struct { // PatchSources paths (it removes the scratch directory SourceRun writes // reports into; SourceReport's cleanup is a no-op since it only ever reads // files the caller/CI already produced). -func Compute(ctx context.Context, dir string, cfg config.Config, source Source, reports ReportPaths, want PatchWanted) (map[string]float64, PatchSources, func(), error) { +func Compute(ctx context.Context, dir string, cfg config.Config, source Source, reports ReportPaths, want PatchWanted) (map[string]float64, []Unit, PatchSources, func(), error) { ecosystems, err := detect.Ecosystems(dir, cfg.AllExcludes()) if err != nil { - return nil, PatchSources{}, func() {}, err + return nil, nil, PatchSources{}, func() {}, err } workDir := "" @@ -150,42 +225,44 @@ func Compute(ctx context.Context, dir string, cfg config.Config, source Source, if source == SourceRun { tmp, err := os.MkdirTemp("", "bulwark-coverage-*") if err != nil { - return nil, PatchSources{}, func() {}, err + return nil, nil, PatchSources{}, func() {}, err } workDir = tmp cleanup = func() { _ = os.RemoveAll(tmp) } } report := map[string]float64{} + var units []Unit var sources PatchSources for _, e := range ecosystems { - var pct float64 + var measured []Unit var ok bool switch e { case detect.Rust: var lcovPaths map[string]string - pct, lcovPaths, ok = rustCoverage(ctx, dir, workDir, source, cfg.Rust.Exclude, reports.Rust, reports.RustLCOV, want.Rust) + measured, lcovPaths, ok = rustCoverage(ctx, dir, workDir, source, cfg.Rust.Exclude, reports.Rust, reports.RustLCOV, want.Rust) if ok && want.Rust { sources.RustLCOV = lcovPaths } case detect.Go: var goProfiles map[string]GoModuleProfile - pct, goProfiles, ok = goCoverage(ctx, dir, workDir, source, cfg.Go.Exclude, reports.Go) + measured, goProfiles, ok = goCoverage(ctx, dir, workDir, source, cfg.Go.Exclude, reports.Go) if ok && want.Go { sources.GoProfiles = goProfiles } case detect.TypeScript: - pct, ok = tsCoverage(ctx, dir, cfg.TypeScript.Exclude, source, cfg.TypeScript.Install) + measured, ok = tsCoverage(ctx, dir, cfg.TypeScript.Exclude, source, cfg.TypeScript.Install) if ok && want.TypeScript { pkgDirs, _ := detect.TSPackageDirs(dir, cfg.TypeScript.Exclude) sources.TSLCOV = tsLCOVSources(pkgDirs) } } if ok { - report[string(e)] = pct + report[string(e)] = aggregate(measured).Percent() + units = append(units, measured...) } } - return report, sources, cleanup, nil + return report, units, sources, cleanup, nil } // moduleName returns the Go module path rooted at moduleDir (e.g. @@ -252,23 +329,28 @@ func findReport(dir, override string, candidates []string) (string, bool) { // produces one. var goReportCandidates = []string{"coverage.out", "cover.out", "c.out"} -// goCoverage reads the statement coverage percentage for every independent Go -// module discovered under dir (see detect.GoModuleDirs), averaging across the -// modules that produced a result — mirroring how rustCoverage averages across -// crates and tsCoverage across packages. Returns a map of module dir -> what +// goCoverage measures statement coverage for every independent Go module +// discovered under dir (see detect.GoModuleDirs), returning one Unit per +// module that produced a result — mirroring how rustCoverage reports per +// crate and tsCoverage per package. Also returns a map of module dir -> what // patch coverage needs to read that module's profile back. // +// The units carry counts, not percentages, so the caller's language-level +// figure is summed statements over summed statements. A mean of per-module +// percentages instead lets a tiny module dominate a monorepo's headline. +// // Discovering modules rather than treating dir as one is what makes a // monorepo work at all: `go test` and `go list -m` are both module-scoped, so // running them at a dir that merely *contains* modules measures nothing. -func goCoverage(ctx context.Context, dir, workDir string, source Source, exclude []string, overrides GoReportOverrides) (float64, map[string]GoModuleProfile, bool) { +func goCoverage(ctx context.Context, dir, workDir string, source Source, exclude []string, overrides GoReportOverrides) ([]Unit, map[string]GoModuleProfile, bool) { moduleDirs, err := detect.GoModuleDirs(dir, exclude) if err != nil || len(moduleDirs) == 0 { - return 0, nil, false + return nil, nil, false } solo := len(moduleDirs) == 1 - var total, count float64 + var units []Unit + measured := 0 profiles := map[string]GoModuleProfile{} for i, moduleDir := range moduleDirs { rel, err := filepath.Rel(dir, moduleDir) @@ -278,31 +360,32 @@ func goCoverage(ctx context.Context, dir, workDir string, source Source, exclude if rel == "." { rel = "" } - pct, src, ok := goCoverageOne(ctx, dir, moduleDir, rel, workDir, source, overrides, solo, i) + lines, src, ok := goCoverageOne(ctx, dir, moduleDir, rel, workDir, source, overrides, solo, i) if !ok { + units = append(units, Unit{Lang: string(detect.Go), Dir: rel}) continue } - total += pct - count++ + measured++ + units = append(units, Unit{Lang: string(detect.Go), Dir: rel, Lines: lines}) profiles[moduleDir] = src } - if count == 0 { - return 0, nil, false + if measured == 0 { + return nil, nil, false } - return total / count, profiles, true + return units, profiles, true } // goCoverageOne measures one module, either running `go test -coverprofile` // inside it (SourceRun) or parsing a profile another step already produced // (SourceReport). Every command runs in moduleDir rather than dir, because that // is the only place a module-scoped Go command works. -func goCoverageOne(ctx context.Context, dir, moduleDir, moduleRelDir, workDir string, source Source, overrides GoReportOverrides, solo bool, idx int) (float64, GoModuleProfile, bool) { +func goCoverageOne(ctx context.Context, dir, moduleDir, moduleRelDir, workDir string, source Source, overrides GoReportOverrides, solo bool, idx int) (LineCount, GoModuleProfile, bool) { var profile string switch source { case SourceReport: found, ok := findReportForUnit(dir, moduleDir, moduleRelDir, overrides, solo, goReportCandidates) if !ok { - return 0, GoModuleProfile{}, false + return LineCount{}, GoModuleProfile{}, false } profile = found default: @@ -310,25 +393,27 @@ func goCoverageOne(ctx context.Context, dir, moduleDir, moduleRelDir, workDir st // module overwrite the last, leaving only the final module measured. profile = filepath.Join(workDir, fmt.Sprintf("cover-%d.out", idx)) if r := executil.Run(ctx, moduleDir, "go", "test", "-coverprofile="+profile, "./..."); !r.Ok() { - return 0, GoModuleProfile{}, false + return LineCount{}, GoModuleProfile{}, false } } name := moduleName(ctx, moduleDir) if name == "" { - return 0, GoModuleProfile{}, false + return LineCount{}, GoModuleProfile{}, false } src := GoModuleProfile{Profile: profile, ModuleName: name, RelDir: moduleRelDir} - pct, ok := goProfilePercent(src, dir) + lines, ok := goProfileLines(src, dir) if !ok { - return 0, GoModuleProfile{}, false + return LineCount{}, GoModuleProfile{}, false } - return pct, src, true + return lines, src, true } -// goProfilePercent computes a module's statement coverage straight from its -// profile: the covered-statements-over-total-statements ratio `go tool cover -// -func` prints on its `total:` line, minus generated files. +// goProfileLines counts a module's covered and total statements straight from +// its profile — the two numbers behind the ratio `go tool cover -func` prints +// on its `total:` line, minus generated files. A module the profile says has +// no statements at all is reported as unmeasured rather than as a 0/0 that +// would contribute nothing but still claim to be a result. // // Parsing rather than shelling out to `go tool cover -func` is what lets this // work from anywhere. That command resolves each profile entry's @@ -340,10 +425,10 @@ func goCoverageOne(ctx context.Context, dir, moduleDir, moduleRelDir, workDir st // // It is also the only place a generated file can be dropped from the // denominator, which `go tool cover` offers no way to do. -func goProfilePercent(src GoModuleProfile, dir string) (float64, bool) { +func goProfileLines(src GoModuleProfile, dir string) (LineCount, bool) { profiles, err := cover.ParseProfiles(src.Profile) if err != nil { - return 0, false + return LineCount{}, false } var covered, total int for _, p := range profiles { @@ -358,9 +443,9 @@ func goProfilePercent(src GoModuleProfile, dir string) (float64, bool) { } } if total == 0 { - return 0, false + return LineCount{}, false } - return float64(covered) / float64(total) * 100, true + return LineCount{Covered: covered, Total: total}, true } // llvmCovExport is the subset of `cargo llvm-cov --json`'s export format @@ -369,7 +454,8 @@ type llvmCovExport struct { Data []struct { Totals struct { Lines struct { - Percent float64 `json:"percent"` + Count int `json:"count"` + Covered int `json:"covered"` } `json:"lines"` } `json:"totals"` } `json:"data"` @@ -437,20 +523,25 @@ func isRegularFile(path string) bool { return err == nil && info.Mode().IsRegular() } -// rustCoverage reads the total line coverage percentage for every -// independent Cargo crate/workspace root discovered under dir (see -// detect.RustCrateDirs), averaging across crates that produced a result — -// mirroring how tsCoverage averages across TS packages. Returns a map of -// crate dir -> its resolved lcov export path (when wantLCOV is set and one -// was resolved for that crate), for patch coverage. -func rustCoverage(ctx context.Context, dir, workDir string, source Source, exclude []string, reportOverrides, lcovReportOverrides RustReportOverrides, wantLCOV bool) (float64, map[string]string, bool) { +// rustCoverage counts lines for every independent Cargo crate/workspace root +// discovered under dir (see detect.RustCrateDirs), returning one Unit per +// crate that produced a result — mirroring how tsCoverage reports per TS +// package. Also returns a map of crate dir -> its resolved lcov export path +// (when wantLCOV is set and one was resolved for that crate), for patch +// coverage. +// +// Counts rather than percentages, for the same reason goCoverage carries +// them: a workspace of one 20-line helper crate and one 5,000-line crate has +// one honest line-coverage figure, and it is not the mean of the two. +func rustCoverage(ctx context.Context, dir, workDir string, source Source, exclude []string, reportOverrides, lcovReportOverrides RustReportOverrides, wantLCOV bool) ([]Unit, map[string]string, bool) { crateDirs, err := detect.RustCrateDirs(dir, exclude) if err != nil || len(crateDirs) == 0 { - return 0, nil, false + return nil, nil, false } solo := len(crateDirs) == 1 - var total, count float64 + var units []Unit + measured := 0 lcovPaths := map[string]string{} for i, crateDir := range crateDirs { rel, err := filepath.Rel(dir, crateDir) @@ -460,24 +551,25 @@ func rustCoverage(ctx context.Context, dir, workDir string, source Source, exclu if rel == "." { rel = "" } - pct, lcovPath, ok := rustCoverageOne(ctx, dir, crateDir, rel, workDir, source, reportOverrides, lcovReportOverrides, solo, wantLCOV, i) + lines, lcovPath, ok := rustCoverageOne(ctx, dir, crateDir, rel, workDir, source, reportOverrides, lcovReportOverrides, solo, wantLCOV, i) if !ok { + units = append(units, Unit{Lang: string(detect.Rust), Dir: rel}) continue } - total += pct - count++ + measured++ + units = append(units, Unit{Lang: string(detect.Rust), Dir: rel, Lines: lines}) if lcovPath != "" { lcovPaths[crateDir] = lcovPath } } - if count == 0 { - return 0, nil, false + if measured == 0 { + return nil, nil, false } - return total / count, lcovPaths, true + return units, lcovPaths, true } -// rustCoverageOne reads one crate's total line coverage percentage from a -// cargo-llvm-cov JSON export, either running `cargo llvm-cov` itself +// rustCoverageOne reads one crate's total covered and total line counts from +// a cargo-llvm-cov JSON export, either running `cargo llvm-cov` itself // (SourceRun — requires cargo-llvm-cov already installed, a cargo subcommand // like cargo-audit/cargo-deny that bulwark doesn't auto-install) or parsing // an existing export another step already produced (SourceReport — needs no @@ -489,18 +581,18 @@ func rustCoverage(ctx context.Context, dir, workDir string, source Source, exclu // raw profile data on disk, and both the JSON and lcov reports are then // regenerated from that same profile via `--no-run` — no second test // execution. -func rustCoverageOne(ctx context.Context, dir, crateDir, crateRelDir, workDir string, source Source, reportOverrides, lcovReportOverrides RustReportOverrides, solo, wantLCOV bool, idx int) (float64, string, bool) { +func rustCoverageOne(ctx context.Context, dir, crateDir, crateRelDir, workDir string, source Source, reportOverrides, lcovReportOverrides RustReportOverrides, solo, wantLCOV bool, idx int) (LineCount, string, bool) { var data []byte var lcovPath string switch source { case SourceReport: found, ok := findReportForUnit(dir, crateDir, crateRelDir, reportOverrides, solo, rustReportCandidates) if !ok { - return 0, "", false + return LineCount{}, "", false } d, err := os.ReadFile(found) // #nosec G304 -- found is resolved from bulwark's own candidate list or an explicit CLI flag, not user input if err != nil { - return 0, "", false + return LineCount{}, "", false } data = d if wantLCOV { @@ -510,22 +602,22 @@ func rustCoverageOne(ctx context.Context, dir, crateDir, crateRelDir, workDir st } default: if !executil.Available("cargo-llvm-cov") { - return 0, "", false + return LineCount{}, "", false } if !wantLCOV { r := executil.Run(ctx, crateDir, "cargo", "llvm-cov", "--summary-only", "--json") if !r.Ok() { - return 0, "", false + return LineCount{}, "", false } data = []byte(r.Output) break } if r := executil.Run(ctx, crateDir, "cargo", "llvm-cov", "--no-report"); !r.Ok() { - return 0, "", false + return LineCount{}, "", false } r := executil.Run(ctx, crateDir, "cargo", "llvm-cov", "--no-run", "--summary-only", "--json") if !r.Ok() { - return 0, "", false + return LineCount{}, "", false } data = []byte(r.Output) lcovOut := filepath.Join(workDir, fmt.Sprintf("rust-lcov-%d.info", idx)) @@ -536,9 +628,19 @@ func rustCoverageOne(ctx context.Context, dir, crateDir, crateRelDir, workDir st var export llvmCovExport if err := json.Unmarshal(data, &export); err != nil || len(export.Data) == 0 { - return 0, "", false + return LineCount{}, "", false + } + lines := LineCount{ + Covered: export.Data[0].Totals.Lines.Covered, + Total: export.Data[0].Totals.Lines.Count, } - return export.Data[0].Totals.Lines.Percent, lcovPath, true + // A crate llvm-cov found no coverable lines in is unmeasured, not 0% + // covered: contributing 0/0 would be harmless to the aggregate but would + // present the crate to the per-unit floor as a unit with zero coverage. + if lines.Total <= 0 { + return LineCount{}, "", false + } + return lines, lcovPath, true } // istanbulSummary is the subset of Vitest/Istanbul's coverage-summary.json @@ -546,7 +648,8 @@ func rustCoverageOne(ctx context.Context, dir, crateDir, crateRelDir, workDir st type istanbulSummary struct { Total struct { Lines struct { - Pct float64 `json:"pct"` + Total int `json:"total"` + Covered int `json:"covered"` } `json:"lines"` } `json:"total"` } @@ -667,42 +770,91 @@ func tsInstall(ctx context.Context, roots []string, override string) { // (skipping packages that don't declare one) to produce that file; in // SourceReport it only reads a file a prior step already produced, running // nothing. -func tsCoverage(ctx context.Context, dir string, exclude []string, source Source, install string) (float64, bool) { +func tsCoverage(ctx context.Context, dir string, exclude []string, source Source, install string) ([]Unit, bool) { pkgDirs, err := detect.TSPackageDirs(dir, exclude) if err != nil || len(pkgDirs) == 0 { - return 0, false + return nil, false } if source == SourceRun { tsInstall(ctx, tsWorkspaceRoots(dir, pkgDirs), install) } - var total, count float64 + var units []Unit + measured := 0 for _, pkgDir := range pkgDirs { - if source == SourceRun { - if !hasCoverageScript(pkgDir) { - continue - } - if r := executil.Run(ctx, pkgDir, "npm", "run", "test:coverage"); !r.Ok() { - continue - } - } - summaryPath := filepath.Join(pkgDir, "coverage", "coverage-summary.json") - data, err := os.ReadFile(summaryPath) // #nosec G304 -- summaryPath is a fixed relative path under a detected package dir, not user input + rel, err := filepath.Rel(dir, pkgDir) if err != nil { continue } - var summary istanbulSummary - if err := json.Unmarshal(data, &summary); err != nil { + if rel == "." { + rel = "" + } + // A package declaring no test:coverage script has opted out of being + // measured, which is a different state from a report bulwark expected + // and did not find. It is not a discovered unit at all: reporting one + // [UNMEASURED] line and one stderr warning per types-only or + // config-only package, on every run and in every PR comment, is noise + // that trains readers to ignore the tag that exists to be noticed. + // + // Under SourceRun the check comes first, because there is no point + // running a script that isn't there. Under SourceReport it is the + // fallback below instead: a report already on disk is authoritative + // whether or not the package declares a script to produce it, since a + // workspace-level runner can write into a package that names none. + if source == SourceRun && !hasCoverageScript(pkgDir) { + continue + } + lines, ok := tsPackageLines(ctx, pkgDir, source) + if !ok { + // No report. Only a package that meant to produce one is + // unmeasured; the rest opted out. + if !hasCoverageScript(pkgDir) { + continue + } + units = append(units, Unit{Lang: string(detect.TypeScript), Dir: rel}) continue } - total += summary.Total.Lines.Pct - count++ + measured++ + units = append(units, Unit{Lang: string(detect.TypeScript), Dir: rel, Lines: lines}) + } + if measured == 0 { + return nil, false } - if count == 0 { - return 0, false + return units, true +} + +// tsPackageLines measures one package: under SourceRun by executing its own +// test:coverage script first, then — either way — by reading the +// coverage-summary.json Istanbul/Vitest writes at its fixed conventional +// path. The caller has already established that the package declares such a +// script; a false return here means the run or the report failed, which is +// what makes the package unmeasured rather than opted out. +func tsPackageLines(ctx context.Context, pkgDir string, source Source) (LineCount, bool) { + if source == SourceRun { + if r := executil.Run(ctx, pkgDir, "npm", "run", "test:coverage"); !r.Ok() { + return LineCount{}, false + } } - return total / count, true + summaryPath := filepath.Join(pkgDir, "coverage", "coverage-summary.json") + data, err := os.ReadFile(summaryPath) // #nosec G304 -- summaryPath is a fixed relative path under a detected package dir, not user input + if err != nil { + return LineCount{}, false + } + var summary istanbulSummary + if err := json.Unmarshal(data, &summary); err != nil { + return LineCount{}, false + } + // total.lines.{total,covered} rather than total.lines.pct: the counts are + // what make the language figure a line-weighted ratio instead of a mean in + // which a 230-line app outvotes a 4,494-line library. + lines := LineCount{Covered: summary.Total.Lines.Covered, Total: summary.Total.Lines.Total} + // A package with no executable lines is unmeasured, not 0% covered — same + // rule as Go's empty profile and Rust's zero line count. + if lines.Total <= 0 { + return LineCount{}, false + } + return lines, true } // packageJSON is the subset of package.json bulwark needs to detect whether diff --git a/internal/coverage/coverage_test.go b/internal/coverage/coverage_test.go index 00ebd2e..192ffdb 100644 --- a/internal/coverage/coverage_test.go +++ b/internal/coverage/coverage_test.go @@ -4,16 +4,19 @@ import ( "context" "encoding/json" "fmt" + "math" "os" "path/filepath" "strings" "testing" + + "wardnet/bulwark/internal/config" ) -// The percentage comes from the profile itself rather than `go tool cover +// The counts come from the profile itself rather than `go tool cover // -func`, which only runs from inside the module a profile came from — see -// goProfilePercent's doc comment. -func TestGoProfilePercent(t *testing.T) { +// goProfileLines' doc comment. +func TestGoProfileLines(t *testing.T) { dir := t.TempDir() // 3 of 4 statements covered. profile := "mode: set\n" + @@ -25,30 +28,33 @@ func TestGoProfilePercent(t *testing.T) { t.Fatal(err) } - got, ok := goProfilePercent(GoModuleProfile{Profile: path, ModuleName: "fixture"}, dir) - if !ok || got != 75 { - t.Fatalf("goProfilePercent = (%v, %v), want (75, true)", got, ok) + got, ok := goProfileLines(GoModuleProfile{Profile: path, ModuleName: "fixture"}, dir) + if !ok || got != (LineCount{Covered: 3, Total: 4}) { + t.Fatalf("goProfileLines = (%+v, %v), want ({3 4}, true)", got, ok) + } + if got.Percent() != 75 { + t.Fatalf("Percent() = %v, want 75", got.Percent()) } } // A profile with no coverable statement in it says nothing about the module, // so it must not be reported as 0% — that would drag a repo's baseline down // with a number no test run could ever move. -func TestGoProfilePercentEmptyIsUnmeasured(t *testing.T) { +func TestGoProfileLinesEmptyIsUnmeasured(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "cover.out") if err := os.WriteFile(path, []byte("mode: set\n"), 0o600); err != nil { t.Fatal(err) } - if got, ok := goProfilePercent(GoModuleProfile{Profile: path, ModuleName: "fixture"}, dir); ok { - t.Fatalf("goProfilePercent = (%v, true), want unmeasured", got) + if got, ok := goProfileLines(GoModuleProfile{Profile: path, ModuleName: "fixture"}, dir); ok { + t.Fatalf("goProfileLines = (%+v, true), want unmeasured", got) } } // Generated code arrives in large, entirely-uncovered blocks nobody is going // to hand-test. Left in the denominator it dominates the aggregate: wardnet's // generated REST client alone drags its SDK module to 2%. -func TestGoProfilePercentExcludesGeneratedFiles(t *testing.T) { +func TestGoProfileLinesExcludeGeneratedFiles(t *testing.T) { dir := t.TempDir() generated := "// Code generated by openapi-codegen. DO NOT EDIT.\n" + "\npackage rest\n\nfunc Gen() {\n\tprintln(1)\n}\n" @@ -69,9 +75,9 @@ func TestGoProfilePercentExcludesGeneratedFiles(t *testing.T) { t.Fatal(err) } - got, ok := goProfilePercent(GoModuleProfile{Profile: path, ModuleName: "fixture"}, dir) - if !ok || got != 100 { - t.Fatalf("goProfilePercent = (%v, %v), want (100, true)", got, ok) + got, ok := goProfileLines(GoModuleProfile{Profile: path, ModuleName: "fixture"}, dir) + if !ok || got.Percent() != 100 { + t.Fatalf("goProfileLines = (%+v, %v), want 100%%, true", got, ok) } } @@ -91,8 +97,11 @@ func TestLlvmCovExportParsing(t *testing.T) { if err := json.Unmarshal(data, &export); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(export.Data) != 1 || export.Data[0].Totals.Lines.Percent != 87.3 { - t.Fatalf("got %+v, want percent 87.3", export) + // count/covered, not percent: the counts are what a language's figure is + // summed from, and a percentage cannot be summed without losing the + // unit's size. + if len(export.Data) != 1 || export.Data[0].Totals.Lines.Count != 100 || export.Data[0].Totals.Lines.Covered != 87 { + t.Fatalf("got %+v, want count 100 covered 87", export) } } @@ -102,8 +111,8 @@ func TestIstanbulSummaryParsing(t *testing.T) { if err := json.Unmarshal(data, &summary); err != nil { t.Fatalf("unmarshal: %v", err) } - if summary.Total.Lines.Pct != 84 { - t.Fatalf("got %+v, want pct 84", summary) + if summary.Total.Lines.Total != 50 || summary.Total.Lines.Covered != 42 { + t.Fatalf("got %+v, want total 50 covered 42", summary) } } @@ -186,12 +195,12 @@ func TestGoCoverageSourceReportDoesNotRunTests(t *testing.T) { write(t, dir, "main_test.go", "package fixture\n\nimport \"testing\"\n\nfunc TestFails(t *testing.T) { t.Fatal(\"this test must never run under SourceReport\") }\n") write(t, dir, "coverage.out", "mode: set\nfixture/main.go:3.13,3.16 1 1\n") - pct, _, ok := goCoverage(context.Background(), dir, "", SourceReport, nil, nil) + units, _, ok := goCoverage(context.Background(), dir, "", SourceReport, nil, nil) if !ok { t.Fatal("expected goCoverage to succeed by parsing the existing coverage.out") } - if pct != 100 { - t.Fatalf("got %v%%, want 100%% from the fixture profile", pct) + if got := aggregate(units).Percent(); got != 100 { + t.Fatalf("got %v%%, want 100%% from the fixture profile", got) } } @@ -216,19 +225,20 @@ func TestGoCoverageDiscoversEveryModuleUnderDir(t *testing.T) { writeNested(t, dir, filepath.Join("sdk", "wardnet-go", "go.mod"), "module wardnet.network/go\n\ngo 1.26\n") writeNested(t, dir, filepath.Join("sdk", "wardnet-go", "api.go"), "package api\n\nfunc Bar() {}\n") - // Entirely uncovered, so an average across both modules is unmistakable. + // Entirely uncovered, so a figure spanning both modules is unmistakable. writeNested(t, dir, filepath.Join("sdk", "wardnet-go", "coverage.out"), "mode: set\nwardnet.network/go/api.go:3.13,3.16 1 0\n") - pct, profiles, ok := goCoverage(context.Background(), dir, "", SourceReport, nil, nil) + units, profiles, ok := goCoverage(context.Background(), dir, "", SourceReport, nil, nil) if !ok { t.Fatal("goCoverage found no measurable Go module under a dir holding two") } if len(profiles) != 2 { t.Fatalf("measured %d module(s), want 2: %+v", len(profiles), profiles) } - if pct != 50 { - t.Fatalf("got %v%%, want 50%% (100%% and 0%% averaged across the two modules)", pct) + // One statement each, so equal weight: 1 of 2 statements covered. + if got := aggregate(units).Percent(); got != 50 { + t.Fatalf("got %v%%, want 50%% (1 of 2 statements across the two modules)", got) } // Each module carries its own path and directory: one global module name @@ -546,3 +556,264 @@ func write(t *testing.T, dir, name, contents string) { t.Fatal(err) } } + +// The asymmetry the line-weighted aggregate exists for: a language's figure +// must be its units' summed counts, not the mean of their percentages. A mean +// gives a 4-statement module the same vote as a 100-statement one, so a +// single small, poorly-tested unit drags the headline down by tens of points +// while barely changing how much code is actually untested. +// +// Both directions are checked, because a mean is wrong symmetrically: it +// understates a repo whose small unit is the bad one, and overstates a repo +// whose small unit is the good one. +func TestGoCoverageWeightsModulesByStatementCount(t *testing.T) { + for _, tc := range []struct { + name string + bigCount, bigHits int + smallCount, smallHit int + want float64 + }{ + // 100/100 and 0/4 — the mean says 50%, the lines say 96.15%. + {name: "small unit poorly covered", bigCount: 100, bigHits: 1, smallCount: 4, smallHit: 0, want: 100.0 / 104 * 100}, + // 0/100 and 4/4 — the mean says 50%, the lines say 3.85%. + {name: "small unit well covered", bigCount: 100, bigHits: 0, smallCount: 4, smallHit: 1, want: 4.0 / 104 * 100}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, filepath.Join("big", "go.mod"), "module fixture/big\n\ngo 1.26\n") + writeNested(t, dir, filepath.Join("big", "main.go"), "package big\n") + writeNested(t, dir, filepath.Join("big", "coverage.out"), + fmt.Sprintf("mode: set\nfixture/big/main.go:3.13,50.2 %d %d\n", tc.bigCount, tc.bigHits)) + + writeNested(t, dir, filepath.Join("small", "go.mod"), "module fixture/small\n\ngo 1.26\n") + writeNested(t, dir, filepath.Join("small", "main.go"), "package small\n") + writeNested(t, dir, filepath.Join("small", "coverage.out"), + fmt.Sprintf("mode: set\nfixture/small/main.go:3.13,7.2 %d %d\n", tc.smallCount, tc.smallHit)) + + units, _, ok := goCoverage(context.Background(), dir, "", SourceReport, nil, nil) + if !ok { + t.Fatal("goCoverage measured neither module") + } + if got := aggregate(units).Percent(); math.Abs(got-tc.want) > 1e-9 { + t.Errorf("aggregate = %.4f%%, want %.4f%% — an unweighted mean of the two would give 50%%", got, tc.want) + } + }) + } +} + +// Rust carries the same rule as Go: a workspace of one tiny helper crate and +// one large one has a single honest line-coverage figure, and it is not the +// mean of the two. +func TestRustCoverageWeightsCratesByLineCount(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, filepath.Join("big", "Cargo.toml"), "[package]\nname = \"big\"\nversion = \"0.1.0\"\n") + writeNested(t, dir, filepath.Join("big", "coverage", "llvm-cov.json"), + `{"data":[{"totals":{"lines":{"count":5000,"covered":5000,"percent":100}}}]}`) + writeNested(t, dir, filepath.Join("small", "Cargo.toml"), "[package]\nname = \"small\"\nversion = \"0.1.0\"\n") + writeNested(t, dir, filepath.Join("small", "coverage", "llvm-cov.json"), + `{"data":[{"totals":{"lines":{"count":10,"covered":0,"percent":0}}}]}`) + + units, _, ok := rustCoverage(context.Background(), dir, "", SourceReport, nil, nil, nil, false) + if !ok { + t.Fatal("rustCoverage measured neither crate") + } + if len(units) != 2 { + t.Fatalf("measured %d crate(s), want 2: %+v", len(units), units) + } + const want = 5000.0 / 5010 * 100 // 99.80%, against an unweighted mean of 50% + if got := aggregate(units).Percent(); math.Abs(got-want) > 1e-9 { + t.Errorf("aggregate = %.4f%%, want %.4f%% — an unweighted mean of the two would give 50%%", got, want) + } +} + +// The shape measured on a real 9-package pnpm monorepo, reduced to two +// packages: a 4,494-line library beside a 230-line app. A mean of the two +// percentages lets the app move the repo's headline as much as the library. +func TestTSCoverageWeightsPackagesByLineCount(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, filepath.Join("packages", "expressions", "package.json"), `{"name":"expressions"}`) + writeNested(t, dir, filepath.Join("packages", "expressions", "coverage", "coverage-summary.json"), + `{"total":{"lines":{"total":4494,"covered":4300,"skipped":0,"pct":95.68}}}`) + writeNested(t, dir, filepath.Join("apps", "playground", "package.json"), `{"name":"playground"}`) + writeNested(t, dir, filepath.Join("apps", "playground", "coverage", "coverage-summary.json"), + `{"total":{"lines":{"total":230,"covered":115,"skipped":0,"pct":50}}}`) + + units, ok := tsCoverage(context.Background(), dir, nil, SourceReport, "") + if !ok { + t.Fatal("tsCoverage measured neither package") + } + if len(units) != 2 { + t.Fatalf("measured %d package(s), want 2: %+v", len(units), units) + } + const want = 4415.0 / 4724 * 100 // 93.46%, against an unweighted mean of 72.84% + if got := aggregate(units).Percent(); math.Abs(got-want) > 1e-9 { + t.Errorf("aggregate = %.4f%%, want %.4f%%", got, want) + } +} + +// A package Istanbul reports as having no executable lines is unmeasured, not +// 0% covered. Counting it as 0/0 is harmless to the aggregate but would offer +// the per-unit floor a unit with zero coverage to fail on. +func TestTSCoverageSkipsPackagesWithNoLines(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, filepath.Join("pkg", "package.json"), `{"name":"pkg"}`) + writeNested(t, dir, filepath.Join("pkg", "coverage", "coverage-summary.json"), + `{"total":{"lines":{"total":0,"covered":0,"skipped":0,"pct":0}}}`) + + if units, ok := tsCoverage(context.Background(), dir, nil, SourceReport, ""); ok { + t.Fatalf("tsCoverage = (%+v, true), want unmeasured for a package with no executable lines", units) + } +} + +// The units a language's figure is summed from also leave Compute, so a +// caller can gate on the distribution the aggregate deliberately hides. Each +// carries its own directory relative to dir and its own counts. +func TestComputeReturnsPerUnitCounts(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, filepath.Join("packages", "lib", "package.json"), `{"name":"lib"}`) + writeNested(t, dir, filepath.Join("packages", "lib", "coverage", "coverage-summary.json"), + `{"total":{"lines":{"total":100,"covered":90,"skipped":0,"pct":90}}}`) + writeNested(t, dir, filepath.Join("packages", "layout", "package.json"), `{"name":"layout"}`) + writeNested(t, dir, filepath.Join("packages", "layout", "coverage", "coverage-summary.json"), + `{"total":{"lines":{"total":8,"covered":0,"skipped":0,"pct":0}}}`) + + report, units, _, cleanup, err := Compute(context.Background(), dir, config.Default(), SourceReport, ReportPaths{}, PatchWanted{}) + defer cleanup() + if err != nil { + t.Fatal(err) + } + const want = 90.0 / 108 * 100 + if got := report["typescript"]; math.Abs(got-want) > 1e-9 { + t.Errorf("typescript = %.4f%%, want %.4f%%", got, want) + } + byDir := map[string]LineCount{} + for _, u := range units { + if u.Lang != "typescript" { + t.Errorf("unit %+v: unexpected language", u) + } + byDir[filepath.ToSlash(u.Dir)] = u.Lines + } + // The untested package is 0.1% of the aggregate and invisible there. It + // is a unit at 0% here, which is the whole reason the units are returned. + if got := byDir["packages/layout"]; got != (LineCount{Covered: 0, Total: 8}) { + t.Errorf("packages/layout = %+v, want {0 8}", got) + } + if got := byDir["packages/lib"]; got != (LineCount{Covered: 90, Total: 100}) { + t.Errorf("packages/lib = %+v, want {90 100}", got) + } +} + +// A discovered unit whose report is missing is returned with a zero LineCount +// rather than dropped, so the caller's per-unit floor can name it. Dropping it +// would leave the floor gate silently covering fewer units than the repo holds +// while the language still reported a percentage — and the per-language +// unmeasured warning cannot see that, because the language did measure. +func TestGoCoverageReturnsUnmeasuredModules(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, filepath.Join("measured", "go.mod"), "module fixture/measured\n\ngo 1.26\n") + writeNested(t, dir, filepath.Join("measured", "main.go"), "package measured\n") + writeNested(t, dir, filepath.Join("measured", "coverage.out"), + "mode: set\nfixture/measured/main.go:3.13,5.2 4 1\n") + // Discovered, but with no profile for SourceReport to read. + writeNested(t, dir, filepath.Join("skipped", "go.mod"), "module fixture/skipped\n\ngo 1.26\n") + writeNested(t, dir, filepath.Join("skipped", "main.go"), "package skipped\n") + + units, _, ok := goCoverage(context.Background(), dir, "", SourceReport, nil, nil) + if !ok { + t.Fatal("one module measured, so goCoverage must report the language") + } + byDir := map[string]Unit{} + for _, u := range units { + byDir[filepath.ToSlash(u.Dir)] = u + } + if u, present := byDir["skipped"]; !present || u.Measured() { + t.Errorf("skipped module = %+v (present %v), want present and unmeasured", u, present) + } + if u := byDir["measured"]; !u.Measured() { + t.Errorf("measured module = %+v, want measured", u) + } + // The unmeasured module contributes nothing — not a zero that would drag + // the language's figure down. + if got := aggregate(units).Percent(); got != 100 { + t.Errorf("aggregate = %v%%, want 100%% — an unmeasured unit must not count as 0/0 or 0%%", got) + } +} + +// A package that declares no test:coverage script has opted out of being +// measured — a different state from a report bulwark expected and did not +// find. It must not become an unmeasured Unit: a types-only or config-only +// package in a monorepo would then produce an [UNMEASURED] line and a stderr +// warning on every run, in every PR comment. +func TestTSCoverageSkipsPackagesWithoutACoverageScript(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, filepath.Join("packages", "app", "package.json"), + `{"name":"app","scripts":{"test:coverage":"true"}}`) + writeNested(t, dir, filepath.Join("packages", "app", "coverage", "coverage-summary.json"), + `{"total":{"lines":{"total":100,"covered":90,"skipped":0,"pct":90}}}`) + // Declares no test:coverage script at all. + writeNested(t, dir, filepath.Join("packages", "types", "package.json"), `{"name":"types"}`) + + units, ok := tsCoverage(context.Background(), dir, nil, SourceRun, "") + if !ok { + t.Fatal("the package with a coverage script measured, so tsCoverage must report typescript") + } + for _, u := range units { + if strings.Contains(filepath.ToSlash(u.Dir), "types") { + t.Errorf("a package with no test:coverage script became a unit: %+v", u) + } + } + if len(units) != 1 { + t.Errorf("got %d unit(s), want only the package that declares a coverage script: %+v", len(units), units) + } +} + +// The opt-out rule has to hold under SourceReport too, which is the mode CI +// uses. A types-only or config-only package produces no report there either, +// and treating that as unmeasured would print an [UNMEASURED] floor line and a +// stderr warning for it on every run and in every PR comment. +// +// A report that is present still wins: a workspace-level runner can write into +// a package that declares no script of its own, and that package is measured, +// not opted out. +func TestTSCoverageSourceReportSkipsPackagesWithoutACoverageScript(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, filepath.Join("packages", "app", "package.json"), + `{"name":"app","scripts":{"test:coverage":"true"}}`) + writeNested(t, dir, filepath.Join("packages", "app", "coverage", "coverage-summary.json"), + `{"total":{"lines":{"total":100,"covered":90,"skipped":0,"pct":90}}}`) + // Declares a script but produced no report: genuinely unmeasured. + writeNested(t, dir, filepath.Join("packages", "broken", "package.json"), + `{"name":"broken","scripts":{"test:coverage":"true"}}`) + // Declares nothing and produced nothing: opted out. + writeNested(t, dir, filepath.Join("packages", "types", "package.json"), `{"name":"types"}`) + // Declares nothing but a workspace runner wrote its report: measured. + writeNested(t, dir, filepath.Join("packages", "shared", "package.json"), `{"name":"shared"}`) + writeNested(t, dir, filepath.Join("packages", "shared", "coverage", "coverage-summary.json"), + `{"total":{"lines":{"total":50,"covered":25,"skipped":0,"pct":50}}}`) + + units, ok := tsCoverage(context.Background(), dir, nil, SourceReport, "") + if !ok { + t.Fatal("tsCoverage measured nothing") + } + got := map[string]bool{} + for _, u := range units { + got[filepath.ToSlash(u.Dir)] = u.Measured() + } + for dir, wantMeasured := range map[string]bool{ + "packages/app": true, + "packages/shared": true, + "packages/broken": false, + } { + measured, present := got[dir] + if !present { + t.Errorf("%s missing from the units", dir) + continue + } + if measured != wantMeasured { + t.Errorf("%s measured = %v, want %v", dir, measured, wantMeasured) + } + } + if _, present := got["packages/types"]; present { + t.Errorf("packages/types declares no coverage script and produced no report — it opted out, but became a unit: %+v", units) + } +} diff --git a/internal/executil/executil.go b/internal/executil/executil.go index 92d2b19..dff4e35 100644 --- a/internal/executil/executil.go +++ b/internal/executil/executil.go @@ -13,9 +13,23 @@ import ( // Result is the outcome of running one external command. type Result struct { - Name string - Args []string + Name string + Args []string + // Output is everything the command wrote to stdout and stderr. Run + // streams it live as well, so for most tools it has already reached the + // terminal by the time anyone reads this field. Output string + // Detail is findings a scanner package derived itself, for a tool whose + // real report never reaches the terminal at all. Biome is the case that + // needs it: bulwark sends its report to a file with --reporter-file so + // that Biome's own chatter cannot corrupt the JSON, which means nothing + // streams and Output holds no findings. A caller that only prints a + // pass/fail line then shows the developer a failure with no reason + // attached, in the terminal and in the PR comment alike. + // + // Empty for every tool that prints its own findings — reprinting those + // would duplicate what already streamed. + Detail string Err error } diff --git a/internal/gitstate/gitstate.go b/internal/gitstate/gitstate.go index 414ce4a..8836f6e 100644 --- a/internal/gitstate/gitstate.go +++ b/internal/gitstate/gitstate.go @@ -29,6 +29,31 @@ import ( // BranchName is the dedicated branch coverage baselines live on. const BranchName = "bulwark-state" +// stateDir is the directory inside BranchName that baselines are written to +// and read from, and it is keyed to the coverage metric rather than to the +// file format. A baseline is only comparable to one produced by the same +// metric: today's figure is a language's units' summed line counts, and +// diffing it against a figure computed some other way measures the change of +// definition, not a change in coverage — which surfaces as a step change of +// several points that the gate reads as a regression. +// +// So any change to how a language's percentage is derived must bump this +// constant. Entries at the superseded directory are then simply never found, +// every consumer takes one clean cache miss, and the gate recovers by +// recording afresh. +// +// A directory rather than a marker inside the file: the entries stay a plain +// language -> percentage object, which is what makes them readable by hand on +// the branch, and the superseded ones stay in place to be inspected rather +// than overwritten. +const stateDir = "v2" + +// StatePath is the path, inside BranchName, of the baseline for key (a tree +// or commit SHA). +func StatePath(key string) string { + return stateDir + "/" + key + ".json" +} + // TreeSHA resolves the tree a commit points at. // // Baselines are keyed by tree rather than by commit because the tree is what @@ -90,7 +115,7 @@ func ReadBaseline(ctx context.Context, dir string, keys ...string) (map[string]f if key == "" { continue } - r = executil.Run(ctx, dir, "git", "show", "origin/"+BranchName+":"+key+".json") + r = executil.Run(ctx, dir, "git", "show", "origin/"+BranchName+":"+StatePath(key)) if r.Ok() { found = key break @@ -142,8 +167,10 @@ func PriorBaselines(ctx context.Context, dir, sha string, langs []string, maxDep // ls-tree scopes to the cwd's path inside the ref's tree — bulwark-state // has no such subtree, so the listing comes back empty and carry-forward // silently finds nothing (`show ref:path` below is root-relative and - // unaffected). - ls := executil.Run(ctx, dir, "git", "ls-tree", "--full-tree", "--name-only", "origin/"+BranchName) + // unaffected). -r is load-bearing for the same reason: baselines live in + // a subdirectory of the branch, and a non-recursive listing names that + // directory rather than the entries inside it. + ls := executil.Run(ctx, dir, "git", "ls-tree", "-r", "--full-tree", "--name-only", "origin/"+BranchName) if !ls.Ok() { return found } @@ -170,7 +197,7 @@ func PriorBaselines(ctx context.Context, dir, sha string, langs []string, maxDep // Tree first, so a commit that has both resolves to the same entry // ReadBaseline would pick. for i := len(fields) - 1; i >= 0; i-- { - if cached[fields[i]+".json"] { + if cached[StatePath(fields[i])] { key = fields[i] break } @@ -178,7 +205,7 @@ func PriorBaselines(ctx context.Context, dir, sha string, langs []string, maxDep if key == "" { continue } - r := executil.Run(ctx, dir, "git", "show", "origin/"+BranchName+":"+key+".json") + r := executil.Run(ctx, dir, "git", "show", "origin/"+BranchName+":"+StatePath(key)) if !r.Ok() { continue } @@ -275,11 +302,15 @@ func pushBaseline(ctx context.Context, dir, sha string, data []byte) error { } } - path := filepath.Join(tmp, sha+".json") + rel := StatePath(sha) + path := filepath.Join(tmp, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } if err := os.WriteFile(path, data, 0o600); err != nil { return err } - if r := executil.Run(ctx, tmp, "git", "add", sha+".json"); !r.Ok() { + if r := executil.Run(ctx, tmp, "git", "add", rel); !r.Ok() { return fmt.Errorf("git add: %w", r.Err) } // Nothing staged means the fetched branch already carries this exact diff --git a/internal/gitstate/gitstate_test.go b/internal/gitstate/gitstate_test.go index 056d97b..e8aa156 100644 --- a/internal/gitstate/gitstate_test.go +++ b/internal/gitstate/gitstate_test.go @@ -38,11 +38,15 @@ func TestReadBaselineTreatsEmptyAsCacheMiss(t *testing.T) { run(seed, "init", "-b", BranchName, ".") run(seed, "config", "user.email", "t@t") run(seed, "config", "user.name", "t") - for name, content := range map[string]string{ - "empty.json": "{}", - "filled.json": `{"go":58.5}`, + for key, content := range map[string]string{ + "empty": "{}", + "filled": `{"go":58.5}`, } { - if err := os.WriteFile(filepath.Join(seed, name), []byte(content), 0o600); err != nil { + path := filepath.Join(seed, filepath.FromSlash(StatePath(key))) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } } @@ -90,8 +94,12 @@ func seedStateBranch(t *testing.T, ctx context.Context, files map[string]string) run(seed, "init", "-b", BranchName, ".") run(seed, "config", "user.email", "t@t") run(seed, "config", "user.name", "t") - for name, content := range files { - if err := os.WriteFile(filepath.Join(seed, name), []byte(content), 0o600); err != nil { + for key, content := range files { + path := filepath.Join(seed, filepath.FromSlash(StatePath(key))) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } } @@ -138,10 +146,10 @@ func TestPriorBaselinesNearestCommitWinsPerLanguage(t *testing.T) { // bulwark-state has baselines for c4 itself (a concurrent job's fresh // entry), c3 (empty — must be skipped), c2, and c1. origin := seedStateBranch(t, ctx, map[string]string{ - c4 + ".json": `{"go":77}`, - c3 + ".json": "{}", - c2 + ".json": `{"rust":10}`, - c1 + ".json": `{"rust":20,"typescript":93.8}`, + c4: `{"go":77}`, + c3: "{}", + c2: `{"rust":10}`, + c1: `{"rust":20,"typescript":93.8}`, }) run(clone, "remote", "add", "origin", origin) @@ -195,7 +203,7 @@ func TestPriorBaselinesNearestCommitWinsPerLanguage(t *testing.T) { func TestWriteBaselinePushesOverAStaleTrackingRef(t *testing.T) { ctx := context.Background() run := gitRunner(t, ctx) - origin := seedStateBranch(t, ctx, map[string]string{"first.json": `{"go":10}`}) + origin := seedStateBranch(t, ctx, map[string]string{"first": `{"go":10}`}) // The caller's repo: fetches bulwark-state once, then the remote advances. clone := t.TempDir() @@ -208,7 +216,11 @@ func TestWriteBaselinePushesOverAStaleTrackingRef(t *testing.T) { run(writer, "clone", "-b", BranchName, origin, ".") run(writer, "config", "user.email", "t@t") run(writer, "config", "user.name", "t") - if err := os.WriteFile(filepath.Join(writer, "concurrent.json"), []byte(`{"go":20}`), 0o600); err != nil { + concurrent := filepath.Join(writer, filepath.FromSlash(StatePath("concurrent"))) + if err := os.MkdirAll(filepath.Dir(concurrent), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(concurrent, []byte(`{"go":20}`), 0o600); err != nil { t.Fatal(err) } run(writer, "add", "-A") @@ -222,9 +234,9 @@ func TestWriteBaselinePushesOverAStaleTrackingRef(t *testing.T) { // Both the concurrent write and ours must be on the remote branch. verify := t.TempDir() run(verify, "clone", "-b", BranchName, origin, ".") - for _, name := range []string{"first.json", "concurrent.json", "stalerace.json"} { - if _, err := os.Stat(filepath.Join(verify, name)); err != nil { - t.Errorf("%s missing from %s after WriteBaseline: %v", name, BranchName, err) + for _, key := range []string{"first", "concurrent", "stalerace"} { + if _, err := os.Stat(filepath.Join(verify, filepath.FromSlash(StatePath(key)))); err != nil { + t.Errorf("%s missing from %s after WriteBaseline: %v", StatePath(key), BranchName, err) } } } @@ -235,7 +247,7 @@ func TestWriteBaselinePushesOverAStaleTrackingRef(t *testing.T) { func TestWriteBaselineReportsAPushThatNeverLands(t *testing.T) { ctx := context.Background() run := gitRunner(t, ctx) - origin := seedStateBranch(t, ctx, map[string]string{"first.json": `{"go":10}`}) + origin := seedStateBranch(t, ctx, map[string]string{"first": `{"go":10}`}) // Reject every push from here on. hook := filepath.Join(origin, "hooks", "pre-receive") @@ -380,3 +392,41 @@ func TestReadBaselinePrefersTheTreeAndFallsBackToTheCommit(t *testing.T) { t.Errorf("go = %v, want the tree-keyed 77 to take precedence over the commit-keyed 11", got["go"]) } } + +// The premise of versioning stateDir is that an entry recorded under a +// superseded metric is never read as the current one. A baseline sitting at +// the branch root — where entries predating the version-keyed layout live — +// must therefore be a clean cache miss, not a hit whose number means +// something else. +func TestReadBaselineIgnoresEntriesOutsideTheStateDir(t *testing.T) { + ctx := context.Background() + run := gitRunner(t, ctx) + + origin := t.TempDir() + run(origin, "init", "--bare", "-b", "main", ".") + seed := t.TempDir() + run(seed, "init", "-b", BranchName, ".") + run(seed, "config", "user.email", "t@t") + run(seed, "config", "user.name", "t") + // Deliberately at the branch root, not under StatePath's directory. + if err := os.WriteFile(filepath.Join(seed, "deadbeef.json"), []byte(`{"go":58.5}`), 0o600); err != nil { + t.Fatal(err) + } + run(seed, "add", "-A") + run(seed, "commit", "-m", "baseline under the superseded layout") + run(seed, "remote", "add", "origin", origin) + run(seed, "push", "origin", BranchName) + + clone := t.TempDir() + run(clone, "init", "-b", "main", ".") + run(clone, "remote", "add", "origin", origin) + + if report, hit, err := ReadBaseline(ctx, clone, "deadbeef"); err != nil || hit { + t.Errorf("ReadBaseline = (%v, hit=%v, err=%v), want a cache miss — the entry is outside %s", report, hit, err, StatePath("")) + } + // The carry-forward walk must agree: an entry it cannot compare against is + // not one to carry forward from either. + if got := PriorBaselines(ctx, clone, "deadbeef", []string{"go"}, 5); len(got) != 0 { + t.Errorf("PriorBaselines = %v, want nothing carried from an entry outside the state dir", got) + } +} diff --git a/internal/golang/go-pin/go.mod b/internal/golang/go-pin/go.mod index 2f62f70..ca1e726 100644 --- a/internal/golang/go-pin/go.mod +++ b/internal/golang/go-pin/go.mod @@ -40,7 +40,7 @@ require ( github.com/invopop/jsonschema v0.14.0 // indirect github.com/openai/openai-go/v3 v3.42.0 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect - github.com/securego/gosec/v2 v2.27.1 // indirect + github.com/securego/gosec/v2 v2.28.0 // indirect github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.2.0 // indirect @@ -62,7 +62,7 @@ require ( golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect - golang.org/x/vuln v1.6.0 // indirect + golang.org/x/vuln v1.7.0 // indirect google.golang.org/api v0.288.0 // indirect google.golang.org/genai v1.63.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect diff --git a/internal/golang/go-pin/go.sum b/internal/golang/go-pin/go.sum index 8a37d47..094009c 100644 --- a/internal/golang/go-pin/go.sum +++ b/internal/golang/go-pin/go.sum @@ -1,42 +1,13 @@ -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.21.0 h1:g/QwYfYb2Ai6HH8oomAOyBaIHLbscZ4+T/F/f5JZHkE= cloud.google.com/go/auth v0.21.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= -cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA= -cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= -cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= -cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/anthropics/anthropic-sdk-go v1.57.0 h1:iEAcPbUKfJ2Iqz9uN/jEndCNW2+x7OYLHDidXDhPjI0= github.com/anthropics/anthropic-sdk-go v1.57.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= -github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= -github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg= -github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= -github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw= -github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= -github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g= @@ -45,20 +16,12 @@ github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNr github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/eliben/go-sentencepiece v0.7.0/go.mod h1:nNYk4aMzgBoI6QFp4LUG8Eu1uO9fHD9L5ZEre93o9+c= -github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -66,21 +29,14 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -103,32 +59,22 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= -github.com/mozilla/tls-observatory v0.0.0-20250923143331-eef96233227e/go.mod h1:FUqVoUPHSEdDR0MnFM3Dh8AU0pZHLXUD127SAJGER/s= -github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= -github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= -github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/openai/openai-go/v3 v3.42.0 h1:16Skv1hpEhSm3imZpPGSeEBDUgVJJA9cHryKGGsVYI8= github.com/openai/openai-go/v3 v3.42.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/securego/gosec/v2 v2.27.1 h1:bg4lZnpCCpC8e5l0K+ADF5gG91jmT2LQgOcOflwBfJI= -github.com/securego/gosec/v2 v2.27.1/go.mod h1:lbgwsogcxq9aoN62Bk/vcdWwemFjlT5NPF/D/dH4+Ho= -github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/securego/gosec/v2 v2.28.0 h1:ZsSdiDb0AtTpLFVol5z91gbMei9ZiLEPG/pZjZujp7c= +github.com/securego/gosec/v2 v2.28.0/go.mod h1:lb4/9AHe+lJy/kjWmWRWWsEipvbwGKuxf+tY1Pmjdnk= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= @@ -146,17 +92,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= @@ -181,37 +120,32 @@ golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -golang.org/x/vuln v1.6.0 h1:FeMO9Rm/HwyduOztbvKcOw+zvDEPr4I4aQNSfevFcKY= -golang.org/x/vuln v1.6.0/go.mod h1:bWlG2493/sjR7ksvicBgMrznH3eYQEyK8ifUYBrqUbg= +golang.org/x/vuln v1.7.0 h1:4MQBuhmXbz2uepNJrf3v+aaZLGDqw1JluwYboegA1qg= +golang.org/x/vuln v1.7.0/go.mod h1:Xw7zvU3e1bsCYYBXu+w4wcn2Kgn27f34WBCTw8LL5Us= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.288.0 h1:glhO/J88obKP5I269W3hB73dvBKrjU56ZfmNlNXpgTU= google.golang.org/api v0.288.0/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genai v1.63.0 h1:Iryg+4TBco5HaRbwVhAV/ROKVcWiZkuvQzKb4u1QggY= google.golang.org/genai v1.63.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= -google.golang.org/genproto/googleapis/bytestream v0.0.0-20260630182238-925bb5da69e7/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= diff --git a/internal/golang/golang.go b/internal/golang/golang.go index d476ae2..e56ed8c 100644 --- a/internal/golang/golang.go +++ b/internal/golang/golang.go @@ -19,8 +19,8 @@ import ( // Pinned so every invocation of bulwark uses the exact same toolchain // regardless of what's already on the machine. const ( - gosecVersion = "v2.27.1" - govulncheckVersion = "v1.6.0" + gosecVersion = "v2.28.0" + govulncheckVersion = "v1.7.0" gosecPkg = "github.com/securego/gosec/v2/cmd/gosec@" + gosecVersion govulncheckPkg = "golang.org/x/vuln/cmd/govulncheck@" + govulncheckVersion diff --git a/internal/golang/pins_test.go b/internal/golang/pins_test.go index 001e63b..52cd7f6 100644 --- a/internal/golang/pins_test.go +++ b/internal/golang/pins_test.go @@ -9,7 +9,7 @@ import ( // TestPinnedVersionsMatchGoPinModule is the drift guard for the one pair of // pins bulwark cannot read from its manifest at runtime. // -// Every other pinned tool (ESLint, Biome, cargo-audit, cargo-deny, Semgrep) has +// Every other pinned tool (Biome, cargo-audit, cargo-deny, Semgrep) has // its version read directly out of the package-manager manifest Dependabot // edits, so drift is impossible by construction. Go's two can't work that way: // gosecPkg/govulncheckPkg are const expressions that concatenate the version at diff --git a/internal/rust/rust.go b/internal/rust/rust.go index 793b91b..b3d3e74 100644 --- a/internal/rust/rust.go +++ b/internal/rust/rust.go @@ -12,7 +12,7 @@ // (the standard rustup convention for pinning rustc/clippy/rustfmt together) — // bulwark doesn't second-guess that. cargo-audit and cargo-deny are different: // they're standalone cargo subcommands with no equivalent per-repo pin, so — -// like every other scanner's toolchain (gosec/govulncheck, ESLint, Semgrep) — +// like every other scanner's toolchain (gosec/govulncheck, Biome, Semgrep) — // bulwark pins their exact versions and installs them into a version-keyed // cache directory rather than trusting whatever's already on PATH. package rust diff --git a/internal/toolchain/install.go b/internal/toolchain/install.go index 5a9d88f..a1724be 100644 --- a/internal/toolchain/install.go +++ b/internal/toolchain/install.go @@ -32,7 +32,7 @@ const maxArchiveBytes = 2 << 30 // 2 GiB // cacheRoot is the version-keyed directory layout every bulwark-managed // install already uses (internal/golang's gobin--, // internal/rust's -, internal/typescript's -// eslint-toolchain-). Language toolchains join it rather than +// biome-toolchain-). Language toolchains join it rather than // inventing a second location, so one cache key in CI covers all of them — // which is exactly what wardnet's workflow already caches by path. func cacheRoot(elem ...string) (string, error) { diff --git a/internal/toolchain/toolchain.go b/internal/toolchain/toolchain.go index f555a1e..e8e8240 100644 --- a/internal/toolchain/toolchain.go +++ b/internal/toolchain/toolchain.go @@ -6,7 +6,7 @@ // toolchain, don't reuse ambient installs" principle (see internal/golang). // bulwark provisions everything it *runs* — gosec and govulncheck via `go // install` into a version-keyed cache, cargo-audit/cargo-deny via `cargo -// install`, ESLint via npm, Semgrep via pipx — but until now it assumed the +// install`, Biome via npm, Semgrep via pipx — but until now it assumed the // language toolchain it does that provisioning *with* was simply there. On a // GitHub-hosted runner that holds, which is why nothing was visibly broken; // on a self-hosted or container runner without Go it fails at `go install`, diff --git a/internal/typescript/biome-pin/package-lock.json b/internal/typescript/biome-pin/package-lock.json index fd894b1..8c95d72 100644 --- a/internal/typescript/biome-pin/package-lock.json +++ b/internal/typescript/biome-pin/package-lock.json @@ -8,13 +8,13 @@ "name": "bulwark-biome-pin", "version": "0.0.0", "dependencies": { - "@biomejs/biome": "2.5.8" + "@biomejs/biome": "2.5.10" } }, "node_modules/@biomejs/biome": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.8.tgz", - "integrity": "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.10.tgz", + "integrity": "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==", "license": "MIT OR Apache-2.0", "bin": { "biome": "bin/biome" @@ -27,20 +27,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.8", - "@biomejs/cli-darwin-x64": "2.5.8", - "@biomejs/cli-linux-arm64": "2.5.8", - "@biomejs/cli-linux-arm64-musl": "2.5.8", - "@biomejs/cli-linux-x64": "2.5.8", - "@biomejs/cli-linux-x64-musl": "2.5.8", - "@biomejs/cli-win32-arm64": "2.5.8", - "@biomejs/cli-win32-x64": "2.5.8" + "@biomejs/cli-darwin-arm64": "2.5.10", + "@biomejs/cli-darwin-x64": "2.5.10", + "@biomejs/cli-linux-arm64": "2.5.10", + "@biomejs/cli-linux-arm64-musl": "2.5.10", + "@biomejs/cli-linux-x64": "2.5.10", + "@biomejs/cli-linux-x64-musl": "2.5.10", + "@biomejs/cli-win32-arm64": "2.5.10", + "@biomejs/cli-win32-x64": "2.5.10" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.8.tgz", - "integrity": "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.10.tgz", + "integrity": "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==", "cpu": [ "arm64" ], @@ -54,9 +54,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.8.tgz", - "integrity": "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.10.tgz", + "integrity": "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==", "cpu": [ "x64" ], @@ -70,9 +70,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.8.tgz", - "integrity": "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.10.tgz", + "integrity": "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==", "cpu": [ "arm64" ], @@ -89,9 +89,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.8.tgz", - "integrity": "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.10.tgz", + "integrity": "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==", "cpu": [ "arm64" ], @@ -108,9 +108,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.8.tgz", - "integrity": "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.10.tgz", + "integrity": "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==", "cpu": [ "x64" ], @@ -127,9 +127,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.8.tgz", - "integrity": "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.10.tgz", + "integrity": "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==", "cpu": [ "x64" ], @@ -146,9 +146,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.8.tgz", - "integrity": "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.10.tgz", + "integrity": "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==", "cpu": [ "arm64" ], @@ -162,9 +162,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.8.tgz", - "integrity": "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz", + "integrity": "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==", "cpu": [ "x64" ], diff --git a/internal/typescript/biome-pin/package.json b/internal/typescript/biome-pin/package.json index 51c2831..da6b9c2 100644 --- a/internal/typescript/biome-pin/package.json +++ b/internal/typescript/biome-pin/package.json @@ -2,8 +2,8 @@ "name": "bulwark-biome-pin", "version": "0.0.0", "private": true, - "description": "Version pin for bulwark's bundled Biome toolchain, used when a repo sets typescript.linter: biome. Not a package bulwark builds or publishes: it exists so Dependabot can see this version and open bump PRs, and so `npm ci` installs it deterministically. See internal/typescript/biome.go.", + "description": "Version pin for bulwark's bundled Biome toolchain, the engine behind the TypeScript check. Not a package bulwark builds or publishes: it exists so Dependabot can see this version and open bump PRs, and so `npm ci` installs it deterministically. See internal/typescript/biome.go.", "dependencies": { - "@biomejs/biome": "2.5.8" + "@biomejs/biome": "2.5.10" } } diff --git a/internal/typescript/biome.go b/internal/typescript/biome.go index b7f37ef..c4603da 100644 --- a/internal/typescript/biome.go +++ b/internal/typescript/biome.go @@ -15,8 +15,8 @@ import ( //go:embed biome.json var biomeConfig []byte -// The pin manifest — see eslint-pin's counterpart in typescript.go for why the -// version lives in package.json rather than a Go constant. +// The pin manifest — see the package doc in typescript.go for why the version +// lives in package.json rather than a Go constant. var ( //go:embed biome-pin/package.json biomePackageJSON []byte @@ -49,10 +49,9 @@ type biomeDiagnostic struct { } // reportableBiome decides whether a diagnostic is something bulwark should fail -// on. It is the direct analogue of reportable() for ESLint, and exists for the -// same reason: bulwark lints with its own standalone config, so anything the -// scanned project's own configuration drags in is a complaint about the setup -// we imposed rather than a defect in the code. +// on. bulwark lints with its own standalone config, so anything the scanned +// project's own configuration drags in is a complaint about the setup we +// imposed rather than a defect in the code. // // Only the two groups bulwark's bundled config enables count. In particular // this drops: @@ -70,8 +69,7 @@ type biomeDiagnostic struct { // way to close it from here: a nested config could set `"security": "off"` and // silently narrow what bulwark checks. It is called out in AGENTS.md. // -// Everything that is *not* a rule opinion is kept, for the same reason -// reportable() keeps ESLint's fatal diagnostics: a file bulwark could not +// Everything that is *not* a rule opinion is kept: a file bulwark could not // actually lint is worth knowing about, and dropping it is worse than a false // positive. This is deliberately a denylist of opinion categories rather than an // allowlist of failure categories, because the failure categories are open-ended @@ -131,22 +129,28 @@ func lintDirBiome(ctx context.Context, dir, biomeBin, configPath string) (execut defer func() { _ = os.Remove(outPath) }() // --reporter-file keeps the machine-readable report out of the combined - // stdout/stderr stream, for the same reason ESLint's --output-file does: - // Biome writes its own diagnostics there, including an "experimental - // reporter" notice, which JSON parsing must not trip over. + // stdout/stderr stream: Biome writes its own diagnostics there, including + // an "experimental reporter" notice, which JSON parsing must not trip over. r := executil.Run(ctx, dir, biomeBin, "lint", "--config-path", configPath, "--reporter", "json", "--reporter-file", outPath, ".") r.Name = "biome(" + dir + ")" - // A nested biome.json aborts the whole run before anything is linted. That - // must not read as a pass, and it must not read as a findings failure - // either — it is a fixable configuration conflict, so say exactly that and - // what fixes it. + // A nested biome.json aborting the run before anything is linted must not + // read as a pass, and must not read as a findings failure either — it is a + // fixable configuration conflict, so say exactly that and what fixes it. + // + // The abort happens only when Biome resolves configuration from the tree + // itself. --config-path, which is always passed above, suppresses it: a + // nested config is ignored and the lint proceeds normally (checked against + // 2.5.8 and 2.5.10). So this branch does not fire today. It is kept + // because it stops being unreachable the moment --config-path is dropped, + // which is exactly what honouring a project's own Biome config would + // require. if !r.Ok() && strings.Contains(r.Output, biomeNestedRootConfig) { r.Err = fmt.Errorf("nested biome.json conflicts with bulwark's bundled config") - r.Output = "Biome refused to run: a biome.json below this package is treated as a second root config.\n" + + r.Detail = "Biome refused to run: a biome.json below this package is treated as a second root config.\n" + "Add \"root\": false to it (Biome's own requirement for nested configs), or exclude that\n" + - "directory via typescript.exclude in .bulwark.yml.\n\n" + r.Output + "directory via typescript.exclude in .bulwark.yml." return r, nil } @@ -173,10 +177,13 @@ func lintDirBiome(ctx context.Context, dir, biomeBin, configPath string) (execut if count == 0 { r.Err = nil - r.Output = "no findings" return r, nil } - r.Output = b.String() + // Detail, not Output: Output is Biome's own stream, which already reached + // the terminal and holds no findings, and overwriting it would discard the + // raw log the run artifact keeps. These findings exist nowhere else, so + // they are what report() has to print. + r.Detail = b.String() r.Err = fmt.Errorf("%d finding(s)", count) return r, nil } diff --git a/internal/typescript/biome.json b/internal/typescript/biome.json index 155df8e..a64f05b 100644 --- a/internal/typescript/biome.json +++ b/internal/typescript/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", "root": true, "files": { "includes": [ diff --git a/internal/typescript/biome_test.go b/internal/typescript/biome_test.go index 0f9e96a..fe92cc0 100644 --- a/internal/typescript/biome_test.go +++ b/internal/typescript/biome_test.go @@ -93,9 +93,9 @@ func TestBiomeConfigEnablesTailwindDirectives(t *testing.T) { } } -// TestBiomeConfigIgnoresMatchesDefaultSkipDirs is the Biome counterpart of -// TestEslintConfigIgnoresMatchesDefaultSkipDirs, and guards a failure verified -// against Biome 2.5.8 directly: with these negations removed, Biome lints +// TestBiomeConfigIgnoresMatchesDefaultSkipDirs guards a failure verified +// against Biome directly, on 2.5.8 and again on 2.5.10: with these negations +// removed, Biome lints // dist/ and reports findings inside a minified production bundle. The ignores // are load-bearing, not decorative. func TestBiomeConfigIgnoresMatchesDefaultSkipDirs(t *testing.T) { @@ -141,8 +141,7 @@ func TestReportableBiomeGatesOnlyOnOurGroups(t *testing.T) { // A file Biome cannot parse emits only `parse` diagnostics (category verified // against 2.5.8 with a deliberately broken .ts file). Filtering those out // leaves count == 0, which clears the error and prints "no findings" — a - // package where nothing was linted would report as a clean pass. ESLint's - // reportable() keeps its fatal diagnostics for exactly this reason. + // package where nothing was linted would report as a clean pass. // Failure categories, none of which are rule opinions: a package where these // fire was not successfully linted, and filtering them out reports it as a // clean pass. internalError/io is the nastier one — Biome emits it with diff --git a/internal/typescript/eslint-pin/package-lock.json b/internal/typescript/eslint-pin/package-lock.json deleted file mode 100644 index 9cdcaa1..0000000 --- a/internal/typescript/eslint-pin/package-lock.json +++ /dev/null @@ -1,1406 +0,0 @@ -{ - "name": "bulwark-eslint-pin", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "bulwark-eslint-pin", - "version": "0.0.0", - "dependencies": { - "@typescript-eslint/parser": "8.63.0", - "eslint": "10.8.1", - "eslint-plugin-security": "4.0.1", - "typescript": "7.0.2" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-security": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.0.1.tgz", - "integrity": "sha512-/lZCkOxPOWaf1jXAqgICrS8St3BMBccIPvhOSUYuV6VCr1o5nFVG998FnTLt6w2Nxb8Uo0nM8fzmnhp+GY/aEg==", - "license": "Apache-2.0", - "dependencies": { - "safe-regex": "^2.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "license": "ISC" - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/regexp-tree": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", - "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", - "license": "MIT", - "bin": { - "regexp-tree": "bin/regexp-tree" - } - }, - "node_modules/safe-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", - "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", - "license": "MIT", - "dependencies": { - "regexp-tree": "~0.1.1" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/internal/typescript/eslint-pin/package.json b/internal/typescript/eslint-pin/package.json deleted file mode 100644 index 2ed3f88..0000000 --- a/internal/typescript/eslint-pin/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "bulwark-eslint-pin", - "version": "0.0.0", - "private": true, - "description": "Version pins for bulwark's bundled ESLint toolchain. Not a package bulwark builds or publishes: it exists so Dependabot can see these versions and open bump PRs, and so `npm ci` installs them deterministically. See internal/typescript/typescript.go.", - "dependencies": { - "@typescript-eslint/parser": "8.63.0", - "eslint": "10.8.1", - "eslint-plugin-security": "4.0.1", - "typescript": "7.0.2" - } -} diff --git a/internal/typescript/eslint.config.mjs b/internal/typescript/eslint.config.mjs deleted file mode 100644 index 9582265..0000000 --- a/internal/typescript/eslint.config.mjs +++ /dev/null @@ -1,53 +0,0 @@ -// Canonical bulwark ESLint config. This file is embedded into the bulwark -// binary and passed explicitly via --config, independent of whatever (if -// anything) the target package declares in its own devDependencies — see -// internal/typescript/typescript.go. -import security from "eslint-plugin-security"; -import tsParser from "@typescript-eslint/parser"; - -export default [ - { - // Every pattern is "**/"-prefixed so it matches at any nesting depth, not - // just directly under the scanned package root — ESLint's flat-config - // ignores use minimatch semantics (no implicit gitignore-style recursive - // matching for a bare "dist/**"), so a build-output dir nested inside a - // package (e.g. admin-site/web/dist) would otherwise slip through. - ignores: [ - "**/node_modules/**", - "**/dist/**", - "**/build/**", - "**/target/**", - "**/vendor/**", - "**/.git/**", - "**/.bare/**", - "**/.next/**", - "**/coverage/**", - ], - }, - { - // Without an explicit `files:`, ESLint's flat-config default applies and - // only **/*.js, **/*.mjs and **/*.cjs are linted — so in a TypeScript - // project bulwark would scan nothing and still report [PASS]. Naming the - // TS extensions here is what actually puts .ts/.tsx in front of the - // security rules. - files: ["**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}"], - languageOptions: { - // TypeScript is not valid JavaScript, so espree (ESLint's default - // parser) throws on the first type annotation it meets. The - // typescript-eslint parser reads both, and is used for .js too so every - // file goes through one parser rather than two. - // - // Deliberately parser-only: no `parserOptions.project`. Every rule in - // eslint-plugin-security is syntactic, so none of them need type - // information — and requiring it would mean resolving each scanned - // package's tsconfig, which bulwark has no business guessing at. - parser: tsParser, - ecmaVersion: "latest", - sourceType: "module", - }, - plugins: { security }, - rules: { - ...security.configs.recommended.rules, - }, - }, -]; diff --git a/internal/typescript/typescript.go b/internal/typescript/typescript.go index 79e6a38..6812ea6 100644 --- a/internal/typescript/typescript.go +++ b/internal/typescript/typescript.go @@ -1,73 +1,34 @@ -// Package typescript runs ESLint + eslint-plugin-security against every -// detected TypeScript package using a toolchain bulwark bundles and pins -// itself, independent of the target package's own devDependencies. This -// avoids the failure mode where a package's lint script references eslint -// but never actually declares it as a dependency. +// Package typescript runs Biome against every detected TypeScript package +// using a toolchain bulwark bundles and pins itself, independent of the target +// package's own devDependencies. This avoids the failure mode where a +// package's lint script references a linter it never declares as a dependency. // -// The pinned eslint + eslint-plugin-security versions are installed once into -// a bulwark-managed cache directory (not via npx's ephemeral install) and the -// bundled config is written into that same directory — co-locating them is -// required so the config's `import "eslint-plugin-security"` resolves; a -// config staged in an unrelated temp directory can't see npx's ephemeral -// node_modules and fails with ERR_MODULE_NOT_FOUND. +// Biome is the only engine. It parses TypeScript with its own Rust parser and +// depends on no compiler package, which is why bulwark's TypeScript linting +// carries no `typescript` pin and cannot be broken by one: the ESLint stack it +// replaced needed @typescript-eslint/parser to see .ts at all, the parser +// needed the `typescript` package as a peer, and that peer range is a moving +// ceiling a repo's own TypeScript version eventually crosses. See +// docs/adr/0008-biome-as-the-only-typescript-linter.md, and 0005 for the +// opt-in that preceded it. // -// The versions themselves live in eslint-pin/package.json, not in Go -// constants, so Dependabot can see them and open bump PRs — a pinned security -// toolchain that nothing ever ages out is a scanner that quietly goes stale -// while still reporting [PASS]. package.json has no comments, so the -// constraints that decided those pins are recorded here instead: -// -// - @typescript-eslint/parser is what lets the security rules see .ts/.tsx -// at all, and `typescript` is the parser's own peer dependency — it cannot -// read TS without it. -// - Parser 8.63.0 declares `eslint: ^8.57 || ^9 || ^10` (so it matches the -// eslint pin) and `typescript: >=4.8.4 <6.1.0` — which is why the -// typescript pin is 5.x and not the 7.x now on npm latest. A Dependabot PR -// bumping typescript across that ceiling must bump the parser too. +// The pinned version lives in biome-pin/package.json, not in a Go constant, so +// Dependabot can see it and open bump PRs — a pinned security toolchain that +// nothing ever ages out is a scanner that quietly goes stale while still +// reporting [PASS]. package typescript import ( "context" - _ "embed" - "encoding/json" - "fmt" - "os" "path/filepath" - "strings" "wardnet/bulwark/internal/detect" "wardnet/bulwark/internal/executil" ) -//go:embed eslint.config.mjs -var eslintConfig []byte - -// The pin manifest, embedded so the binary is self-contained. npm ci installs -// exactly what the lockfile resolves — every transitive dependency included — -// rather than re-resolving them freshly on each cache miss. -var ( - //go:embed eslint-pin/package.json - eslintPackageJSON []byte - //go:embed eslint-pin/package-lock.json - eslintPackageLock []byte -) - -// Linter names which engine backs the TypeScript check for a repo. The two are -// mutually exclusive and their rule sets barely overlap, so this is a migration -// state a repo moves through, not a pair of independent switches — see -// .bulwark.yml's typescript.linter and docs/adr/0005. -type Linter string - -const ( - // ESLint is the default: bulwark's pinned ESLint + eslint-plugin-security. - ESLint Linter = "eslint" - // Biome is opt-in, for repos that have migrated off ESLint. - Biome Linter = "biome" -) - -// Check lints every package directory under root with the configured linter, -// skipping any directory named in exclude. -func Check(ctx context.Context, root string, exclude []string, linter Linter) ([]executil.Result, error) { +// Check lints every package directory under root, skipping any directory +// named in exclude. +func Check(ctx context.Context, root string, exclude []string) ([]executil.Result, error) { pkgDirs, err := detect.TSPackageDirs(root, exclude) if err != nil { return nil, err @@ -77,31 +38,7 @@ func Check(ctx context.Context, root string, exclude []string, linter Linter) ([ return nil, nil } - if linter == Biome { - return checkBiome(ctx, pkgDirs) - } - - toolchainDir, err := ensureToolchain(ctx) - if err != nil { - return nil, err - } - eslintBin := filepath.Join(toolchainDir, "node_modules", ".bin", "eslint") - configPath := filepath.Join(toolchainDir, "eslint.config.mjs") - - var results []executil.Result - for _, dir := range pkgDirs { - res, err := lintDir(ctx, dir, eslintBin, configPath) - if err != nil { - return nil, err - } - results = append(results, res) - } - return results, nil -} - -// checkBiome is Check's Biome arm, split out only to keep the two toolchains' -// setup from interleaving. -func checkBiome(ctx context.Context, pkgDirs []string) ([]executil.Result, error) { + // One toolchain install for the whole run, then one lint per package. toolchainDir, err := ensureBiome(ctx) if err != nil { return nil, err @@ -119,121 +56,3 @@ func checkBiome(ctx context.Context, pkgDirs []string) ([]executil.Result, error } return results, nil } - -// eslintNothingToLint is the message ESLint prints, alongside a non-zero exit, -// when every file under the target is ignored. -const eslintNothingToLint = "all of the files matching the glob pattern" - -// eslintFile / eslintMessage mirror the subset of `eslint --format json` bulwark reads. -type eslintFile struct { - FilePath string `json:"filePath"` - Messages []eslintMessage `json:"messages"` -} - -type eslintMessage struct { - RuleID string `json:"ruleId"` - Severity int `json:"severity"` - Message string `json:"message"` - Line int `json:"line"` - Fatal bool `json:"fatal"` -} - -// reportable decides whether a message is something bulwark should fail on. -// -// bulwark lints with its OWN standalone config, deliberately independent of -// whatever the scanned project declares. That has a consequence ESLint's exit -// code alone doesn't distinguish: a project's sources routinely carry -// `eslint-disable-next-line /` comments, and under a -// config that never loaded that plugin ESLint raises "Definition for rule ... -// was not found" — plus "Unused eslint-disable directive" for any suppression -// whose rule we don't run. Those are complaints about the config we imposed, -// not defects in the code, and failing on them would fail every project that -// suppresses one of its own lint rules anywhere. -// -// So: report the findings from the plugin we actually brought (security/*), -// and genuine parse errors (fatal — the file couldn't be read at all, which is -// worth knowing). Ignore the rest. -// -// Note this must not be solved with --no-inline-config: that would also void -// legitimate `eslint-disable-next-line security/...` suppressions, which are -// exactly how a reviewed false positive is meant to be recorded. -func reportable(m eslintMessage) bool { - return m.Fatal || strings.HasPrefix(m.RuleID, "security/") -} - -// lintDir runs ESLint over one package and reports only bulwark's own findings. -func lintDir(ctx context.Context, dir, eslintBin, configPath string) (executil.Result, error) { - out, err := os.CreateTemp("", "bulwark-eslint-*.json") - if err != nil { - return executil.Result{}, err - } - outPath := out.Name() - _ = out.Close() - defer func() { _ = os.Remove(outPath) }() - - // --format json + --output-file keeps the machine-readable report out of the - // combined stdout/stderr stream, so parsing it can't trip over ESLint's own - // diagnostics. No --max-warnings: we decide what counts, below. - r := executil.Run(ctx, dir, eslintBin, - "--config", configPath, "--format", "json", "--output-file", outPath, ".") - r.Name = "eslint(" + dir + ")" - - // A package can legitimately hold nothing ESLint will look at — a types-only - // package, or one whose every source file sits under an ignored path. ESLint - // calls that a usage error and exits non-zero; it is the absence of a - // finding, not a finding. - if !r.Ok() && strings.Contains(r.Output, eslintNothingToLint) { - r.Err = nil - r.Output = "no lintable files" - return r, nil - } - - data, readErr := os.ReadFile(outPath) // #nosec G304 -- outPath is our own CreateTemp result, not user input - if readErr != nil { - // No report to read: leave ESLint's own exit status and output as-is - // rather than inventing a verdict. - return r, nil - } - var files []eslintFile - if jsonErr := json.Unmarshal(data, &files); jsonErr != nil { - return r, nil - } - - var b strings.Builder - count := 0 - for _, f := range files { - for _, m := range f.Messages { - if !reportable(m) { - continue - } - count++ - rule := m.RuleID - if rule == "" { - rule = "parse-error" - } - fmt.Fprintf(&b, "%s:%d %s %s\n", f.FilePath, m.Line, rule, m.Message) - } - } - - if count == 0 { - r.Err = nil - r.Output = "no findings" - return r, nil - } - r.Output = b.String() - r.Err = fmt.Errorf("%d finding(s)", count) - return r, nil -} - -// ensureToolchain installs the pinned ESLint stack from eslint-pin/ and writes -// the bundled config alongside it. -func ensureToolchain(ctx context.Context) (string, error) { - dir, err := ensureNPMToolchain(ctx, "eslint", "eslint", eslintPackageJSON, eslintPackageLock) - if err != nil { - return "", err - } - if err := os.WriteFile(filepath.Join(dir, "eslint.config.mjs"), eslintConfig, 0o600); err != nil { - return "", err - } - return dir, nil -} diff --git a/internal/typescript/typescript_test.go b/internal/typescript/typescript_test.go deleted file mode 100644 index cea691d..0000000 --- a/internal/typescript/typescript_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package typescript - -import ( - "strings" - "testing" -) - -// TestEslintConfigIgnoresMatchesDefaultSkipDirs guards against regressing the -// incident that prompted this test: the bundled ESLint config lacked ignores -// for several of detect.go's defaultSkipDirs, so it linted a minified -// production bundle under a nested dist/ directory. -func TestEslintConfigIgnoresMatchesDefaultSkipDirs(t *testing.T) { - want := []string{ - "**/node_modules/**", - "**/dist/**", - "**/build/**", - "**/target/**", - "**/vendor/**", - "**/.git/**", - "**/.bare/**", - "**/.next/**", - "**/coverage/**", - } - for _, entry := range want { - if !strings.Contains(string(eslintConfig), `"`+entry+`"`) { - t.Errorf("eslint.config.mjs ignores missing %q", entry) - } - } -} - -// TestEslintConfigLintsTypeScript guards the incident this fixed: with no -// `files:` key, ESLint's flat-config default applies and only .js/.mjs/.cjs are -// linted — so every .ts/.tsx file in a TypeScript project was silently skipped -// and the check still reported PASS. The parser matters just as much: espree -// cannot read a type annotation, so the glob alone would only trade silence for -// a parse error on every file. -func TestEslintConfigLintsTypeScript(t *testing.T) { - cfg := string(eslintConfig) - for _, ext := range []string{"ts", "tsx"} { - if !strings.Contains(cfg, ext) { - t.Errorf("eslint.config.mjs has no files: entry covering .%s — TypeScript would be silently skipped", ext) - } - } - if !strings.Contains(cfg, "files:") { - t.Error("eslint.config.mjs has no files: key; ESLint then defaults to .js/.mjs/.cjs only") - } - if !strings.Contains(cfg, "@typescript-eslint/parser") { - t.Error("eslint.config.mjs does not register the TypeScript parser; espree cannot parse .ts") - } - if !strings.Contains(cfg, "parser:") { - t.Error("eslint.config.mjs imports the TS parser but never sets languageOptions.parser") - } -} - -// TestReportableIgnoresForeignRuleDiagnostics guards the second half of the -// same incident. bulwark lints with its own standalone config, so a project's -// `eslint-disable-next-line /` comments reference rules we -// never loaded, and ESLint reports "Definition for rule ... was not found". -// Failing on those would fail every project that suppresses one of its own lint -// rules anywhere — they are complaints about the config we imposed, not defects -// in the code. -func TestReportableIgnoresForeignRuleDiagnostics(t *testing.T) { - cases := []struct { - name string - msg eslintMessage - want bool - }{ - { - name: "our own security finding", - msg: eslintMessage{RuleID: "security/detect-object-injection"}, - want: true, - }, - { - name: "parse error (fatal, no rule)", - msg: eslintMessage{Fatal: true, Message: "Parsing error: Unexpected token"}, - want: true, - }, - { - name: "unknown rule from the project's own plugin", - msg: eslintMessage{RuleID: "react-hooks/refs", Message: "Definition for rule 'react-hooks/refs' was not found."}, - want: false, - }, - { - name: "unused disable directive for a rule we do not run", - msg: eslintMessage{Message: "Unused eslint-disable directive (no problems were reported from 'no-console')."}, - want: false, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := reportable(tc.msg); got != tc.want { - t.Errorf("reportable(%+v) = %v, want %v", tc.msg, got, tc.want) - } - }) - } -} - -// TestEslintConfigKeepsInlineSuppressions is the constraint that rules out the -// tempting shortcut for the above: --no-inline-config would also silence -// legitimate `eslint-disable-next-line security/...` comments, which are exactly -// how a reviewed false positive is meant to be recorded. -func TestEslintConfigKeepsInlineSuppressions(t *testing.T) { - if strings.Contains(string(eslintConfig), "noInlineConfig") { - t.Error("noInlineConfig would void legitimate security/* line-level suppressions") - } -}