From cd83ed11b128f00398e18d1a94995e5fdee91039 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 12:33:25 +0100 Subject: [PATCH 1/5] feat(coverage)!: weight aggregation by lines, add a per-unit floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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 --- cmd/bulwark/coverage.go | 155 ++++++++- cmd/bulwark/coverage_test.go | 218 +++++++++++- ...0007-line-weighted-coverage-aggregation.md | 87 +++++ internal/config/config.go | 92 +++-- internal/config/config_test.go | 84 ++++- internal/coverage/coverage.go | 322 +++++++++++++----- internal/coverage/coverage_test.go | 319 +++++++++++++++-- internal/gitstate/gitstate.go | 45 ++- internal/gitstate/gitstate_test.go | 82 ++++- 9 files changed, 1223 insertions(+), 181 deletions(-) create mode 100644 docs/adr/0007-line-weighted-coverage-aggregation.md 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/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/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/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) + } +} From c1448e2ce6491cb6f6ac2cca5fae24065829f621 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 12:33:41 +0100 Subject: [PATCH 2/5] feat(typescript)!: drop ESLint; Biome is the only linter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .bulwark.yml | 6 +- .github/dependabot.yml | 18 +- .gt-repo.yaml | 8 +- CONTEXT.md | 6 +- cmd/bulwark/linter_test.go | 47 - cmd/bulwark/scan.go | 25 +- docs/adr/0005-optional-biome-linter.md | 5 + ...008-biome-as-the-only-typescript-linter.md | 99 ++ internal/golang/pins_test.go | 2 +- internal/rust/rust.go | 2 +- internal/toolchain/install.go | 2 +- internal/toolchain/toolchain.go | 2 +- .../typescript/eslint-pin/package-lock.json | 1406 ----------------- internal/typescript/eslint-pin/package.json | 12 - internal/typescript/eslint.config.mjs | 53 - internal/typescript/typescript.go | 221 +-- internal/typescript/typescript_test.go | 106 -- 17 files changed, 161 insertions(+), 1859 deletions(-) delete mode 100644 cmd/bulwark/linter_test.go create mode 100644 docs/adr/0008-biome-as-the-only-typescript-linter.md delete mode 100644 internal/typescript/eslint-pin/package-lock.json delete mode 100644 internal/typescript/eslint-pin/package.json delete mode 100644 internal/typescript/eslint.config.mjs delete mode 100644 internal/typescript/typescript_test.go 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/.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/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/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/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/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/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/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") - } -} From 35f152793898f8ec5314052d09fbaa7fb2a198f4 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 12:34:00 +0100 Subject: [PATCH 3/5] fix(scan): print findings for checks whose output never reaches the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit report() emitted only "[FAIL] ", 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)\] $ 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 --- cmd/bulwark/scan_test.go | 71 +++++++++++++++++++++++++++++++++++ internal/executil/executil.go | 18 ++++++++- internal/typescript/biome.go | 45 ++++++++++++---------- 3 files changed, 113 insertions(+), 21 deletions(-) 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/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/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 } From fd81650be4b78a55661807476396a1c5ad8875e1 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 12:34:00 +0100 Subject: [PATCH 4/5] build(deps): bump gosec, govulncheck and Biome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/golang/go-pin/go.mod | 4 +- internal/golang/go-pin/go.sum | 86 +++---------------- internal/golang/golang.go | 4 +- .../typescript/biome-pin/package-lock.json | 72 ++++++++-------- internal/typescript/biome-pin/package.json | 4 +- internal/typescript/biome.json | 2 +- internal/typescript/biome_test.go | 9 +- 7 files changed, 57 insertions(+), 124 deletions(-) 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/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.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 From 7e8a2a4efbdf834ee02ea562eeafd02e9942a0f4 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 12:34:11 +0100 Subject: [PATCH 5/5] docs: add release notes, and wire goreleaser to publish them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 --- .github/workflows/release.yml | 30 ++++- .gitignore | 3 + .goreleaser.yml | 19 +-- AGENTS.md | 220 +++++++++++++++++++++++++++------- README.md | 9 +- docs/release-notes/_header.md | 12 ++ docs/release-notes/v2.0.0.md | 144 ++++++++++++++++++++++ 7 files changed, 381 insertions(+), 56 deletions(-) create mode 100644 docs/release-notes/_header.md create mode 100644 docs/release-notes/v2.0.0.md 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/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/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/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).