feat!: line-weighted coverage aggregation, per-unit floor, and Biome as the only TypeScript linter - #44
Conversation
|
A language's figure is now the ratio of its units' summed line counts — Sum(covered) / Sum(total) across every discovered Go module, Rust crate/workspace root and TypeScript package — rather than the unweighted mean of those units' percentages. The mean gave a 230-line app the same vote as a 4,494-line library. On a nine-package monorepo, a commit adding 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. Each per-unit measurement returns a LineCount, and Compute returns the Unit list alongside the per-language percentages. A unit with no measurable lines is unmeasured, never 0%, at all three measurement sites. coverage.floor is what line-weighting removes, put back deliberately: weighting by lines is blind to a small unit nobody tests (0 of 8 lines is 0.1% of an 8,305-line repo). It gates every measured unit, defaults to 0 (off) so upgrading never starts failing a repo over a gap it has always had, and has no baseline and no ratchet — a floor is an absolute standard. It is the only gate that runs on pushes to main, for the same reason: the others need a baseline to compare against, and on main the current commit is that baseline. Baselines move to v2/<key>.json on bulwark-state. Entries recorded under the mean are a different quantity, so every consumer takes one clean cache miss and re-records instead of reading a step change of several points as a regression. PriorBaselines' git ls-tree needs -r accordingly. Also retires typescript.linter: eslint in internal/config, since the floor and the linter changes share config.go; the removal itself is the next commit. Claude-Session: https://claude.ai/code/session_01R92Aw5xrg2o8ogHLUPd3pc
typescript.linter accepts biome and nothing else. A config still carrying eslint is rejected 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 printed [PASS]. 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 the pin manifest had to carry a compiler inside a window that only moves when upstream ships support for a new release. The package doc recorded that hazard in as many words; a Dependabot PR then bumped typescript to 7.0.2 across the ceiling and merged, and npm ci on the committed lockfile has failed with ERESOLVE ever since — so installNPMToolchain fails and every consumer with TypeScript gets an install error instead of a lint result. CI stayed green because this 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 parser and pins no compiler, so the failure class does not exist for it. What this costs, measured rather than assumed: Semgrep at config: auto — which bulwark already runs on every ecosystem — covers detect-child-process (with taint, naming the tainted argument) and detect-non-literal-fs-filename (as a path-traversal finding), partly covers detect-unsafe-regex (dynamic patterns, not literal-pattern ReDoS), and does not cover detect-object-injection. So the real gap is that last rule — the plugin's most commonly disabled — plus literal ReDoS, not the four rules. Rebuilding either as a Biome GritQL plugin is the wrong direction: GritQL matches syntax with no dataflow or taint. Claude-Session: https://claude.ai/code/session_01R92Aw5xrg2o8ogHLUPd3pc
…erminal report() emitted only "[FAIL] <name>", so a failing check's findings went nowhere — not the terminal, not the log the action captures, not the PR comment. Finding out what was wrong meant re-running the pinned toolchain by hand. executil.Run streams every tool's stdout and stderr live, so for gosec, clippy, cargo-audit and Semgrep the findings are already on the terminal by the time anyone reads the Result. Biome is the exception: bulwark sends its report to a file with --reporter-file so its own chatter cannot corrupt the JSON, so nothing streams, and lintDirBiome then overwrote Output with the findings it derived — which report() dropped. Result gains Detail: findings a scanner derived itself, for a tool whose report never reaches the terminal. Empty for every tool that prints its own, so nothing that already streamed is printed twice. lintDirBiome sets it and leaves Output holding Biome's raw stream, which also stops it discarding the log the run artifact keeps. Detail lines are indented, and that is load-bearing: action.yml's tool_result() matches ^\[(PASS|FAIL)\] <name>$ anchored at both ends, so an indented line cannot be mistaken for a status line even when a finding's own message contains one. The action's emit_error_output then inlines the tail of this stream into the PR comment, so the findings travel with it and action.yml needs no change. This predates the ESLint removal — lintDir had the same shape via --output-file — so it has been costing every TypeScript repo all along. Claude-Session: https://claude.ai/code/session_01R92Aw5xrg2o8ogHLUPd3pc
gosec 2.27.1 -> 2.28.0 and golang.org/x/vuln 1.6.0 -> 1.7.0, each mirrored into golang.go's constants, which is what TestPinnedVersionsMatchGoPinModule requires and what the two Dependabot PRs were failing on. x/vuln 1.7.0 still declares go 1.25.0, so the GOTOOLCHAIN=local reasoning is unchanged. Biome 2.5.8 -> 2.5.10, with biome.json's $schema URL mirrored to match — TestBiomePinMatchesConfigSchema fails the build otherwise. Re-verified against 2.5.10: out-of-tree files.includes negations still prune dist/, and --config-path still beats a project's own biome.json. Corrected a third claim while checking it — a nested biome.json does NOT abort bulwark's run. That error fires only when Biome resolves configuration from the tree itself; under --config-path a nested config is ignored and the lint proceeds normally, on 2.5.8 and 2.5.10 alike. lintDirBiome's branch for it is therefore unreachable today and kept only against --config-path being dropped. Claude-Session: https://claude.ai/code/session_01R92Aw5xrg2o8ogHLUPd3pc
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/<tag>.md is where that gets written. release.yml assembles the release header from docs/release-notes/_header.md (the invariant install block, moved out of .goreleaser.yml so the workflow can extend it) plus the tag's notes when they exist, and passes it with --release-header so the notes sit above the generated changelog rather than below it. A missing notes file is deliberately not an error. Most patch releases are fully described by their commits, and failing a release over a file it never needed would make every release depend on remembering a step — the failure the major-alias move in this same workflow was automated away to prevent. The step records which case it took in the job summary. docs/release-notes/v2.0.0.md covers both breaking changes and what each costs. Claude-Session: https://claude.ai/code/session_01R92Aw5xrg2o8ogHLUPd3pc
cfbd857 to
7e8a2a4
Compare
Two breaking changes, plus a reporting bug that has been costing every TypeScript repo. Intended to ship as v2.0.0.
1. Coverage aggregation is line-weighted
A language's figure is now
Σ covered / Σ totalacross every discovered Go module, Rust crate/workspace root and TypeScript package, rather than the unweighted mean of those units' percentages.The mean gave a 230-line app the same vote as a 4,494-line library. 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 every repo's reported coverage to move, upward in every case measured so far, by as much as 9 points.
coverage.floorputs back the signal line-weighting removes: weighting by lines is blind to a small unit nobody tests (0 of 8 lines is 0.1% of an 8,305-line repo). Opt-in, defaults to0, no baseline and no ratchet — and it is the only gate that runs on pushes to main, because it is the only one that doesn't need a baseline to compare against.Baselines move to
v2/<key>.jsononbulwark-state. Entries recorded under the mean are a different quantity, so every consumer takes one clean cache miss and re-records rather than reading a step change as a regression.2. ESLint is removed; Biome is the only TypeScript linter
typescript.linteracceptsbiomeand nothing else. A config still carryingeslintis rejected 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.This fixes a live outage.
@typescript-eslint/parserdeclarestypescriptas a peer with an upper bound (>=4.8.4 <6.1.0). #42 bumped the pin totypescript@7.0.2, across that ceiling, andnpm cion the committed lockfile has failed withERESOLVEonmainever since — soinstallNPMToolchainfails and every consumer with TypeScript gets an install error instead of a lint result.internal/typescript's package doc had recorded that exact hazard; nothing executable enforced it. CI stayed green because this repo has no TypeScript to scan (its onlypackage.jsonfiles are the pin manifests, which.bulwark.ymlexcludes), soself-scannever reaches that code path.Biome parses TypeScript with its own parser and pins no compiler, so the failure class does not exist for it.
What it costs, measured rather than assumed — scanning a fixture carrying all four vulnerability classes with the pinned Semgrep, which already runs on every ecosystem:
eslint-plugin-securityruleconfig: autodetect-child-processdetect-non-literal-fs-filenamedetect-unsafe-regexdetect-object-injectionSo the real gap is
detect-object-injection— the plugin's most commonly disabled rule — plus literal ReDoS. Not four rules. Rebuilding either as a Biome GritQL plugin would be the wrong direction: GritQL matches syntax with no dataflow or taint.3. Failing checks now print their findings
report()emitted only[FAIL] <name>.executil.Runstreams most tools live, so Semgrep and gosec findings reached the terminal anyway — but Biome writes its report to a file (so its own chatter cannot corrupt the JSON), andlintDirBiome's derived findings were dropped on the floor. They existed nowhere: not the terminal, not the captured log, not the PR comment.Result.Detailcarries findings a scanner derived itself;report()prints it under a failing check, indented so a finding's own text can never forge a status line pastaction.yml's anchoredtool_result(). This predates the ESLint removal —lintDirhad the same shape — so it has been costing every TypeScript repo all along.Also in here
x/vuln1.6.0→1.7.0 (both mirrored intogolang.go's constants, which is what those PRs failed on), and Biome 2.5.8→2.5.10. PRs chore(deps): bump golang.org/x/vuln from 1.6.0 to 1.7.0 in /internal/golang/go-pin #36, chore(deps): bump github.com/securego/gosec/v2 from 2.27.1 to 2.28.0 in /internal/golang/go-pin #38 and chore(deps): bump @typescript-eslint/parser from 8.63.0 to 8.67.0 in /internal/typescript/eslint-pin #40 are closed with an explanation.docs/release-notes/<tag>.md, assembled into the release header byrelease.ymland passed with--release-headerso it sits above the generated changelog. A missing file is not an error.biome.jsondoes not abort bulwark's run. That error fires only when Biome resolves config from the tree; under--config-patha nested config is ignored, on 2.5.8 and 2.5.10 alike.lintDirBiome's branch for it is unreachable today and kept only against--config-pathbeing dropped.Verification
go build ./...,go test -race ./...andgolangci-lint run ./...clean. Behaviour checked end-to-end against real fixtures rather than only in unit tests: the Semgrep coverage table above, Biome's out-of-tree ignores and--config-pathprecedence on 2.5.10, and the[FAIL] biome(.)output now carrying its findings.Known gaps
.agentic-toolkit.lock.yamlis dirty from an unrelated toolkit sync and deliberately not included.coverage.Computestill measures languages set toenabled: false, so the aggregate gate reports them — AGENTS.md claims otherwise.floorReportfilters, but the real fix is inComputeand would change existing repos' aggregate behaviour, so it belongs in its own change. Both the gap and that reasoning are recorded in AGENTS.md.@v1, which stays accurate until v2.0.0 ships.