Skip to content

feat!: line-weighted coverage aggregation, per-unit floor, and Biome as the only TypeScript linter - #44

Merged
pedromvgomes merged 5 commits into
mainfrom
feature/cinder-juniper
Aug 22, 2026
Merged

feat!: line-weighted coverage aggregation, per-unit floor, and Biome as the only TypeScript linter#44
pedromvgomes merged 5 commits into
mainfrom
feature/cinder-juniper

Conversation

@pedromvgomes

@pedromvgomes pedromvgomes commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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 / Σ 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, 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.floor puts 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 to 0, 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>.json on bulwark-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.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.

This fixes a live outage. @typescript-eslint/parser declares typescript as a peer with an upper bound (>=4.8.4 <6.1.0). #42 bumped the pin to typescript@7.0.2, across that ceiling, and npm ci on the committed lockfile has failed with ERESOLVE on main ever since — so installNPMToolchain fails 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 only package.json files are the pin manifests, which .bulwark.yml excludes), so self-scan never 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-security rule Covered by Semgrep at config: auto
detect-child-process Yes — and with taint: it names the tainted argument
detect-non-literal-fs-filename Yes — as a path-traversal finding
detect-unsafe-regex Partly — dynamic patterns, not literal-pattern ReDoS
detect-object-injection No

So 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.Run streams 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), and lintDirBiome's derived findings were dropped on the floor. They existed nowhere: not the terminal, not the captured log, not the PR comment.

Result.Detail carries 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 past action.yml's anchored tool_result(). This predates the ESLint removal — lintDir had the same shape — so it has been costing every TypeScript repo all along.

Also in here

Verification

go build ./..., go test -race ./... and golangci-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-path precedence on 2.5.10, and the [FAIL] biome(.) output now carrying its findings.

Known gaps

  • .agentic-toolkit.lock.yaml is dirty from an unrelated toolkit sync and deliberately not included.
  • coverage.Compute still measures languages set to enabled: false, so the aggregate gate reports them — AGENTS.md claims otherwise. floorReport filters, but the real fix is in Compute and would change existing repos' aggregate behaviour, so it belongs in its own change. Both the gap and that reasoning are recorded in AGENTS.md.
  • README's action example still pins @v1, which stays accurate until v2.0.0 ships.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — go: 64.1% (baseline 57.3%); line naming packages/layout, got: %q", out); summary line naming cleared-of-total, got: %q", out); floor: N unit(s)"; line naming packages/skipped, got: %q", out.String()); floor line on the main run, got stdout: %q", out.String()); biome(.)" left the developer to re-run the pinned toolchain by hand; biome(.)", "noGlobalEval", "eval() is dangerous", "noUnusedVariables"} {; biome(.)", Err: errors.New("1 finding(s)")},; ") || strings.HasPrefix(line, "[FAIL] ") {; the whole time. A misspelled opt-in that; line and one stderr warning per types-only or; line and a stderr; floor line and a; go patch: 73.5% (166/226 new lines; baseline 57.3%)

📦 Full bulwark output — complete scan and coverage logs.

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
@pedromvgomes
pedromvgomes force-pushed the feature/cinder-juniper branch from cfbd857 to 7e8a2a4 Compare August 22, 2026 12:39
@pedromvgomes
pedromvgomes merged commit 9a9b04c into main Aug 22, 2026
11 checks passed
@pedromvgomes
pedromvgomes deleted the feature/cinder-juniper branch August 22, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant