Skip to content

fix: ten quick-fix issues (metrics, CLI, web, xtask, CI) - #1309

Merged
dekobon merged 23 commits into
mainfrom
batch-2026-08-08-2
Aug 9, 2026
Merged

fix: ten quick-fix issues (metrics, CLI, web, xtask, CI)#1309
dekobon merged 23 commits into
mainfrom
batch-2026-08-08-2

Conversation

@dekobon

@dekobon dekobon commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Ten quick-fix issues, each taken through investigate → fix → simplify → review → validate, on one branch. Rebased onto main (d65b3049); make pre-commit is green on the tip.

Fixes #1259
Fixes #1253
Fixes #1274
Fixes #1258
Fixes #1254
Fixes #1245
Fixes #1244
Fixes #1243
Fixes #1249
Fixes #1250

What changed

Issue Area Fix
#1259 getter/php Each $variable counts once in Halstead N2
#1253 getter/elixir, getter/csharp Boolean/nil literals count once
#1274 abc/java, abc/groovy Generic <> and ? no longer score conditions
#1258 npm/cpp, npm/mozcpp Templated members with inline bodies counted
#1254 checker/{c,cpp,mozcpp} is_call matches the aliased kind
#1245 web vcs_invalid_author_hash_key token + macro-forced guard
#1244 cli Aggregate --output sorted by path
#1243 cli Refresh command parses; 19 composition flags mirrored
#1249 CI / Makefile Drift gate catches added pages; 23-test self-test
#1250 xtask Case-only rename refused instead of deleting the page

Every issue body's prescribed fix was incomplete or wrong

Worth flagging for reviewers, because in each case the correction came from an authoritative artifact rather than from the issue's reasoning:

A methodological note that cost real time: grammar.json and node-types.json disagree. A closure over grammar.json reported operator_cast unreachable from declaration, which the live AST contradicts. node-types.json's subtypes key is the authoritative expansion of hidden _-prefixed supertypes.

Metric-value impact

Behaviour-changing, so published values move:

  • PHP: volume / difficulty / effort / MI change for every file.
  • Elixir and C#: all Halstead derivatives shift.
  • Java and Groovy: abc.conditions drops wherever generics appear.
  • C++: npm rises on classes with templated inline-bodied members — 92 integration snapshots re-baselined, with nom and wmc unchanged (the fix closes a disagreement rather than creating one).

Submodule tests/repositories/big-code-analysis-output bumped to 77c11134, pushed to its remote main.

Three things needing a maintainer decision

  1. fix(web): vcs InvalidAuthorHashKey mis-labeled as vcs_internal_error #1245 changes an error_kind wire value in a patch line (2.1.0 → 2.1.1). Framed in STABILITY.md as a bug fix rather than a rename, on the grounds that a client-input mistake previously labelled internal is exactly what the vocabulary exists to distinguish. Reviewer's call.
  2. .rustfmt-bail-baseline.txt grew — 12 cause-2 entries from fix(web): vcs InvalidAuthorHashKey mis-labeled as vcs_internal_error #1245's guard macro, plus ratchets for php.rs (2→8) and csharp.rs (2→5) where new arms joined already-bailing matches. Rationale is in the baseline header.
  3. .bca-baseline.toml was regenerated, not hand-merged. The rebase left it wholly conflicted (it is -merge in .gitattributes). Regeneration vindicated that: run_check's halstead.effort is 63636 on the merged tree against 62885 on main and 61595 on this branch — higher than either, because main's walk changes and this branch's fix(cli/check): remediation refresh command uses pre-#597 flag order and fails to parse #1243 signature change compound.

Follow-ups filed

#1293, #1294, #1296, #1297, #1298, #1299, #1300, #1301, #1302, #1303, #1304 — PHP type/qualified-name wrappers, a Tcl operand-vocabulary gap, C# operator true classification, six more languages with the generic-bracket bug, four C++ npm/npa/wmc gaps, an ungated STABILITY.md token list, and two further nondeterminism bugs (-O json to stdout, preproc --output).

dekobon added 22 commits August 9, 2026 07:48
Three claims in the "Output report formats" section -- the table that
declares itself SemVer-protected in signature -- contradicted the
shipped code, all introduced by a8d80f2 and never true:

- write_sarif_with_suppressed was documented with two offender slices;
  it has taken three since c1827aa, because the suppression origins
  render different SARIF suppressions[].kind values (inSource vs
  external). A consumer coding against the documented shape gets a
  compile error.
- The dump entry-point sentence promised shape stability for `Dump` /
  `DumpCfg`, which exist nowhere in the crate; CHANGELOG.md records
  their removal in 2.0. The real re-exported config type is AstCfg.
- tool.driver.version was attributed to the CLI package; sarif.rs
  embeds the library crate's CARGO_PKG_VERSION. The two are in
  lockstep today, but the writer is also reached from the Python
  bindings' to_sarif, where no CLI exists.

Documentation only: the shipped signatures are what downstream already
compiled against, so the document moves to match the code.

Fixes #1252
#838 replaced the before-side `git archive --format=tar` route with
`ls-tree -r -z` + `cat-file blob` plumbing (diff::materialize_tree),
because `git archive` silently honoured export-ignore / export-subst.
Only materialize_tree's own doc was updated; six comments still named
the retired mechanism.

The no-`..` invariant is restated against what now enforces it:
materialize_tree filters every ls-tree path through bytes_to_rel_path,
which rejects `..` and absolute components outright. That is a stronger
guarantee than the property the old comment inherited from git archive.

The issue listed five sites and classified diff.rs:411 as deliberate
history. It is not -- TREEISH_SUFFIX's doc describes in the present
tense what happens downstream now, so it is reworded too. The two
genuine history mentions (diff.rs:458, :468, explaining why the route
was replaced) are left alone.

Comment-only; every file is net-zero-line so no file-level loc.sloc
baseline moves.

Fixes #1246
The to_sarif logical-location docs named a "<unnamed>" sentinel the
code has never emitted. extract_space_fields renders a closure/lambda
(the "<anonymous>" name every grammar emits) and the None-name
parse-failure case alike as "<anon@L{start_line}>", mirroring the CLI's
space_segment. A consumer filtering SARIF logical locations on
"<unnamed>" matched nothing.

The same sentence carried two further errors, both fixed: it credited
the rule to a CLI "function_token" that exists in no crate, and it
called this the "rare parse-failure case" when closures and lambdas
are the common path through it.

Separately, #667 split vcs_jit into commit / score_diff, but three
rustdoc and docstring sites still cited `bca vcs jit --diff`, a
subcommand spelling that survives only as a hidden alias. The issue
listed the two in the bindings; a fourth in the root crate
(src/vcs/jit.rs) makes the same present-tense claim and is corrected
too.

Also repoints two stale cross-references found while verifying: the
py sarif.rs comment placed space_segment in thresholds.rs (it is in
qualified_name.rs), and test_sarif.py credited the <file> sentinel to
function_token rather than qualified_symbol.

Left alone deliberately: the deprecation, changelog, STABILITY and
lessons-learned mentions of `vcs jit` are correct history, and
cli/tests/vcs/vcs_jit.rs exercises the alias on purpose alongside the
canonical spelling.

Documentation and docstrings only; no runtime behaviour changes and no
stub signatures move, so py-stubtest is unaffected.

Fixes #1240
The comment above lang_cpp_resolves_to_upstream_tree_sitter_cpp called
asserting against `tree_sitter_mozcpp` "the load-bearing detail here",
which is the inverse of what the test asserts: #720 rebound Lang::Cpp
to the upstream community grammar and moved the Mozilla fork to
Lang::Mozcpp, updating the test name, body and inner comment but not
the outer block.

Keeps the still-true #344 history, states the current contract, and
names lang_mozcpp_resolves_to_vendored_mozcpp as the fork's own pin.
The two partially-overlapping comments are folded into one.

This file exists to be read against the dispatch table by a human, so
a comment asserting the inverse of the code defeats its purpose.

The sibling entries were checked for the same #720-era drift: the C,
Objc and mozjs/javascript comments are accurate and unchanged.

Fixes #1239
Revises the four prose docs the quick-fix batch touched against
docs/conventions/documentation.md. Two of the corrections are factual
errors, not style:

- STABILITY.md told readers the Python package was "not yet on PyPI"
  and that the names were what a first publish would lock in. It has
  been published for a long time (README.md, CHANGELOG.md). A
  stability contract saying its own names are not yet locked is the
  worst thing for it to be wrong about.
- Both SARIF sections claimed there are "no built-in default
  thresholds". Since #1140 default_thresholds.rs is the source of
  truth for the limits bca init scaffolds. Reworded to what is still
  true: bca check applies no *implicit* limits.

Also: the py README taught `bca check -O sarif`, a deprecated alias
for --report-format on check (deprecations.rs); three relative links
that 404 on the PyPI-rendered page are now absolute; and the README's
release-status paragraph ("All nine phases ... have landed", issue
numbers) is gone, per the convention's rule that a README is not a
changelog.

Convention work: link tree-sitter, SARIF 2.1.0, GitHub Code Scanning
and Halstead on first mention; retitle a sarif.md heading that
described only the Unit case while its section covers every space;
name what exporting-data.md actually contains; drop repo-internal
history from an end-user recipe; normalize ~25 British spellings to
the dominant American form, preserving the plural "analyses".

Existing em dashes are untouched. The corpus has roughly 1,250 across
49 of the book's 51 pages, so removing them is a repo-wide decision
rather than a rider on four files.

Documentation only: no executable line changes, and the version pins
remain at the published release 2.1.0 that check-versions requires.
`PhpCode::get_op_type` listed `Name`, `VariableName` and
`DynamicVariableName` in one operand arm, but the grammar nests them:
`$x` is a `variable_name` wrapping a `name` leaf, and `"${x}"` a
`dynamic_variable_name` wrapping one directly. The walker visits both,
so every variable reference contributed twice to N2 and planted a
sigil-less twin (`x` beside `$x`) in the n2 vocabulary. Variable-
variable syntax compounded it: `$$a` scored 3 and `$$$b` scored 4 for
one reference. Since `$var` is the most common token class in PHP,
N2 roughly doubled on real files and every derived Halstead value —
volume, difficulty, effort, and the MI built on them — was wrong.

Parent-guard the nested kinds instead: `Name` yields `Unknown` under a
`variable_name` or `dynamic_variable_name` parent, and the two wrappers
yield `Unknown` under a `dynamic_variable_name` parent, which handles
arbitrary nesting depth. This is the guard Bash already applies to
`variable_name` under `simple_expansion` and iRules to `Id` under
`variable_substitution`. Both kinds move out of the plain operand arm
so the classification no longer depends on arm order. `Name` stays an
operand everywhere else — function, class, const, member and
namespace-component identifiers all use it — and an inner reference
reached through a real expression (`${$x . 'y'}`) still counts.

The remaining wrapper/leaf double counts in the same arm (type
expressions, qualified and namespace names) are the same class but need
a per-construct call on which node carries the operand; they are marked
with a FIXME and tracked in #1293.

Fixes #1259
Both regression tests used inputs where every operand was unique, so
n2 and N2 were equal and the assertions could not tell "each reference
counted once per occurrence" from "the vocabulary deduplicated them".
Repeat one variable in each fixture so N2 exceeds n2, and record the
measured pre-fix counts (9 / 13 and 14 / 17) rather than derived ones.
Elixir's `boolean` / `nil` and C#'s `boolean_literal` are named
wrappers around a keyword leaf, and both the wrapper and the leaf sat
in `get_op_type`'s operand arm — so every `true` / `false` / `nil` /
`null` occurrence added one spurious operand to Halstead N2, skewing
length, volume, difficulty and effort. Operands are keyed by source
text, so the duplicate collapsed into the existing vocabulary entry
and n2 stayed correct, which is why nothing caught it.

Elixir drops the leaves outright, mirroring Ruby's `Nil` / `Nil2`
split: `boolean: choice("true", "false")` and `nil: "nil"` are the
grammar's only producers of them, and the one other position that
accepts the reserved words — the right-hand side of a remote dot —
aliases them to `identifier`.

C# parent-guards the leaf instead. Deleting it would have been wrong:
the overloadable-operator list emits a bare `true` / `false` under
`operator_declaration` with no wrapper, so `operator true` and
`operator false` would have lost the operand that tells them apart.
Whether that name is better classified as an operator, as `operator +`
already is, is deferred to #1296.

A sweep of the other nineteen languages found no second instance of
this shape; PHP's `primitive_type` wrapper is the same class but is
already tracked as FIXME(#1293).

The new arms sit inside a match that already bailed out of rustfmt for
a pre-existing in-pattern comment (#1136), so `.rustfmt-bail-baseline.txt`
ratchets from 2 to 5 for `src/getter/csharp.rs` without a new cause.

Fixes #1253
`java_count_token_condition` and `groovy_count_token_condition` denied
`<` / `>` only under `TypeArguments`, so a generic *declaration* —
`class Gen<T>`, `<T> void m()`, both `type_parameters` — scored two
conditions. `class Gen<T> { void plain() { int a = 0; } }` measured
`abc.conditions = 2` in each language with no conditional construct in
the file.

Flip to the allowlist polarity (parent is `BinaryExpression`) that C,
C++, Objective-C, mozcpp, Rust and Go already use, rather than
extending the denylist as the issue planned. A `grammar.json` sweep
shows a bare `<` / `>` is emitted from exactly three productions in
tree-sitter-java (`binary_expression`, `type_arguments`,
`type_parameters`) and four in dekobon-tree-sitter-groovy (those plus
`method_type_parameters`), so on well-formed input the two shapes are
equivalent — revert-verified: the planned `| TypeParameters` denylist
fails no Java test. They differ on input the grammar cannot parse: a
Groovy explicit type witness (`Collections.<String>emptyList()`) puts
its `<` under an `ERROR` node, which no denylist can name. The
allowlist also needs no revisiting when a grammar bump adds a fifth
type-syntax production, which is the coverage claim
`.claude/rules/grammar-dispatch.md` §1 warns against making.

The denylist the issue proposed would also have been incomplete for
Groovy: a generic *method* (`def <U> U m(U x)`) emits
`method_type_parameters`, not `type_parameters`.

The same sweep found a second instance of the same bug in the same
match: a bare `?` comes only from `ternary_expression` and `wildcard`,
so `List<? extends Number>` scored one condition as a phantom ternary
in both languages. Gate `QMARK` on `TernaryExpression` the same way.

Sibling sweep over every grammar's angle-bracket productions: C, C++,
Objective-C, mozcpp, Rust and Go are immune (allowlist already); PHP,
Python, Tcl, iRules, Perl and Elixir emit `<` / `>` from no
non-comparison production that is reachable. C#, Kotlin, TypeScript,
TSX, JavaScript, Ruby, Lua and Bash are affected by the same class of
over-count through *different* constructs (JSX elements, `super<A>`,
`operator <`, `class Foo < Bar`, `local x <const>`, shell
redirection); each needs its own grammar analysis and fixture set, so
they are filed separately rather than folded in here.

No integration-snapshot churn: the DeepSpeech corpus snapshots cover
only `cc` / `h` / `hh` / `cpp`, and no Groovy file is snapshotted.

Also folds four near-identical copies of the deepest-space
ABC-versus-cyclomatic parity walker in the test module into one
helper beside the shim whose comment already states that invariant.

The wildcard and method-type-parameter fixtures assert a non-zero
condition total rather than 0: an unparsable file also scores 0, so a
zero assertion could not tell "the construct was ignored" from "the
fixture stopped being recognised". Each carries two wildcards against
one ternary so that every way of mis-aiming the gate lands on its own
number.

Fixes #1274
`impl Npm for CppCode` and its Mozcpp clone gate their
`TemplateDeclaration` arm on the shared `cpp_has_function_declarator`
helper, which recursed through pointer / reference / declaration
wrappers hunting a `function_declarator`. A templated member *with a
body* parses as `template_declaration > function_definition`, so the
helper never found one and the method scored zero: `npm` reported one
method on a class where `nom` opened two function spaces and `wmc`
weighted two.

Widen the shared helper rather than adding a local predicate to each
of the two npm impls, against the earlier suggestion on the issue. The
reason given there was cost-avoidance -- not having to re-verify the
npa call site -- and that verification turned out to be cheap and
definitive, so the reason lapsed. The widening is inert for `Npa` on
three independent grounds: a `node-types.json` subtypes closure over
exactly the arms the helper recurses through shows neither
`function_definition` nor `declaration` is reachable from a
`field_declaration` in either grammar; the npa suite stays green under
every perturbation of the new arm; and no `npa` key moved anywhere in
the 92 corpus snapshots this change re-baselines.

Accept a `function_definition` child outright instead of recursing into
it, which is where this departs from the issue's resolution plan. A
conversion operator's declarator is an `operator_cast`, so
`template<typename T> operator T() { ... }` contains no
`function_declarator` at any depth and the prescribed recursion would
still score it zero. Matching on the kind-name string covers both
grammars and all four aliased `FunctionDefinition` ids at once, and
short-circuiting is cheaper than walking the subtree.

Rename the helper to `cpp_declares_function`: it no longer only finds
declarators, and the old name would have been actively false for the
conversion-operator case.

The 92 re-baselined DeepSpeech snapshots are metric-value-only (1762
insertions against 1762 deletions, no structural change). Every count
metric moves up; the only decreases are `class_coa`, a ratio whose
denominator grew.

Two adjacent gaps found while sweeping the rest of the arm are filed
separately rather than folded in, since neither is a template bug:
declaration-only conversion operators are counted as neither method
nor attribute, and a templated static data member is invisible to npa.

Fixes #1258
`is_call` for C, C++ and Mozcpp matched only the unsuffixed
`CallExpression`, but that enum variant is the grammar's
`preproc_call_expression`, referenced only under an
`alias(..., $.call_expression)`. `ts_symbol_map` rewrites it onto
the aliased `CallExpression2` for every input, so the unsuffixed id
never reaches `kind_id()` and the predicate matched nothing:
`bca count -t call` and `bca find call` reported 0 on ordinary
C/C++ source.

Add the aliased variant to all three siblings, keeping the
unsuffixed one as a defensive arm per grammar-dispatch §1. The
already-correct `objc.rs` was the template; the ABC walkers in
`src/metrics/abc/{c,cpp,mozcpp}.rs` had listed both ids all along,
so the checkers were the outliers.

`is_call` feeds exactly one consumer — the `"call"` filter in
`ParserTrait::filters` — so no metric moves and no snapshot churns.
`new_expression` stays unlisted: object creation is an ABC concern,
matching Groovy / Java / C# (#430).

A cross-language sweep of the remaining 20 `is_call` impls found no
sibling with the same defect. The unmatched variants elsewhere are
either `_`-prefixed hidden rules (Ruby `Call5`, Tcl / iRules
`Command2`, TypeScript `CallExpression3` / `4`, the two C# pattern
kinds) or alias sources that surface under another id (JS / TS
`decorator_call_expression`).

Fixes #1254
A client mistake in `author_hash_key` on `POST /v1/vcs` or
`/v1/vcs/trend` answered `400` with `error_kind: vcs_internal_error`,
the token reserved for backend faults, so a client branching on the
token saw its own mistake as a server failure. `InvalidAuthorHashKey`
is classified client-input by `is_client_input` but #956 never gave it
an arm in the web crate's `vcs_error_kind`, so it fell to the
`_ => VCS_INTERNAL_ERROR` wildcard. Add the token and the arm.

The wildcard's own comment claimed it "cannot silently mis-classify a
client error" because `is_client_input` is an exhaustive forcing
function. That was false and is why the drift lasted: `vcs::Error` is
`#[non_exhaustive]`, so a match in any other crate is *required* to
carry a wildcard and the compiler forces nothing there.
`is_client_input` forces the status, and only the status.

Rebuild the guard as the compiler cannot. `classify_error_variants!`
generates `is_client_input` and a new `#[doc(hidden)] pub
client_input_samples()` from one variant list, and the web token test
iterates those samples instead of a hand-written copy. Adding a variant
is a compile error at the macro (the generated match has no wildcard);
classifying it as client input then yields a sample, which fails the
token test until a token exists. A plain hand-written samples list, the
alternative considered, has the same weakness as the test it replaces.
The list takes `pat_param`, not `pat`, so one entry cannot cover two
variants via an or-pattern -- the shape these arms used before the
macro, and one that would silently starve the second variant of a
sample. The API cost is one `pub` item, which STABILITY.md already
places outside the contract for `#[doc(hidden)]`.

The same blind spot sat in the library's own tests, which the new
mechanism now closes: `is_client_input_classifies_every_variant` listed
ten of eleven client variants and `display_covers_every_variant`
sixteen of seventeen, both omitting `InvalidAuthorHashKey`. The group
counts are pinned by hand on purpose -- *moving* a variant between
groups shrinks one list and grows the other, which every per-variant
assertion survives.

Also record `not_acceptable` and `serialize_failed` in STABILITY.md,
missing from the documented vocabulary since #657.

Fixes #1245
Auditing the guards added in 04508c67 found two of the three new count
assertions could not fail for any change to `Error`. Both compared a
vec literal in the test against a constant beside it, so only editing
the literal moved them. Measured: adding a new environment variant to
`Error` -- unasserted by any test in the file -- left all five tests
green.

Drop both. `CLIENT_INPUT_COUNT` stays, because it is compared against
`client_input_samples()`, which production generates, and it does fire
(verified by moving a variant between the two groups).

In their place `display_covers_every_variant` now checks its case list
against `client_input_samples()` by `mem::discriminant`, which catches
the omission that actually happened: `InvalidAuthorHashKey` had no
Display case from #956 until #1245, leaving the `error` prose of a 400
pinned by nothing. Removing that case again now fails the test by name.

This covers the client-input half only. The environment variants cannot
be enumerated, so a new one still goes unpinned; the comments say so
rather than implying a completeness the test does not have.
`bca metrics --output <FILE>` and `bca ops --output <FILE>` serialized
the collected per-file results in worker-completion order, so two runs
over an unchanged tree wrote differently-ordered documents at
`--jobs > 1` — twelve measured runs over a 40-file tree gave twelve
distinct orders and none of them sorted. The walk resolves its file
list sorted, but the aggregate channel discards that order and
`write_aggregate` never restored it, unlike every sibling collector
(`run_check_walk`, `collect_marker_rows`, the #1091 `Ops`
vocabularies).

Sort the collected items by emitted path before serialization, which
covers the generic array, the TOML `files` wrapper, the CBOR/YAML
arms, and the CSV row concatenation from one site. `AggregateItem::Ops`
now carries the emitted path alongside the tree — `Ops::name` is a
lossy rendering of it, so a non-UTF-8 path could reorder under the
string key — giving both variants a total ordering key via
`emitted_path()`. The enum is unchanged in size and the path was
previously dropped at the send site, so this costs nothing.

Refreshes the `write_aggregate` halstead.effort baseline entry, which
the added statement moves past the hard limit.

Fixes #1244
The remediation block's copy-paste baseline-refresh invocation still
used the pre-#597 shape, `bca <walk flags> check <check flags>`. Those
walk flags became subcommand-scoped in #597, so every failing gate
printed a next-steps command that exits 1 with a clap usage error.

Rebuild it as argv starting `["bca", "check", ...]`, and render the
displayed string by shell-quoting each element. Splitting the flag list
from its shell rendering is what makes the regression testable: the
guard now feeds the argv to `Cli::try_parse_from` and compares the
parsed flags against the run that produced them, where the eight
existing assertions only ever string-matched the output.

Mirror every flag that decides what `--write-baseline` records, not
just the walk scope. `--check-exclude` / `--check-exclude-from` were
the known gap (`apply_check_exclude` runs before
`write_check_baseline`, #378), but `--threshold` and `--no-config` were
worse: with the ordering alone fixed, the issue's own reproducer still
failed, with "no thresholds configured" instead of a usage error. Also
mirrored: `--include`, `--paths-from`, `--language`, `--preproc-data`,
`--no-ignore`, `--no-skip-generated`, `--exclude-tests`,
`--cyclomatic-count-try`, `--tier`, `--no-suppress`, and
`--baseline-fuzzy-match`, which populates each entry's `body_hash`.
Reporting and exit-code flags stay off; `--since` / `--changed-only`
are omitted because clap forbids them alongside `--write-baseline`.

The resolved tier is threaded in from `run_check` rather than read off
`args.tier`, which loses a deprecated `--headroom <R>` promotion, and
re-resolving would re-emit that flag's deprecation warning.

That leaves nothing composition-affecting omitted, so the enumerated
"re-add any --include / --language / ..." caveat is replaced by one
that is conditional and true: a `-` list flag read stdin, which no
printed command can replay.

`run_check`'s halstead.effort moved past its baselined value on the
one added argument, so `.bca-baseline.toml` is refreshed here.

Fixes #1243
An audit of the tests #1243 added found three perturbations that left
the whole crate suite green.

Hard-coding `TierSpec::Hard` at `format_remediation_block`'s call to
`refresh_baseline_command` — the exact mistake threading the resolved
tier through exists to prevent — failed nothing, because every test
called the builder directly and none pinned the seam between the block
and the builder.

Widening `shell_quote`'s fast-path allow-list to admit `$`, `~` and
`&`, and dropping its `!s.is_empty()` guard, also failed nothing. The
`$(echo pwned)` fixture looks like it covers `$`, but the parentheses
force the slow path by themselves; a value has to isolate a
metacharacter to test it. The empty string is the same gap inverted:
unquoted it is no word at all and every later argument shifts left.

The third was a deny-list. "Flags the gate never set stay off the
command" named four of sixteen conditionally-emitted flags, so pinning
`--baseline-fuzzy-match=true` onto a run that set neither passed. It
is now an exact whole-argv assertion, which also covers flags added
later.
`git diff` compares tracked content only, so the page `cargo xtask`
writes for a brand-new subcommand — untracked, never committed —
passed `git diff --exit-code -- man/` green in both the CI `manpage`
job and `make manpages-check`. The release artifacts then shipped
without that page and nothing flagged it afterwards.

Measured verdicts for every state that reaches this gate:

  state                    git diff  ls-files --others  porcelain
  clean                    pass      pass               pass
  new page, untracked      PASS      fail               fail
  new page, staged         pass      pass               FAIL
  tracked page modified    fail      pass               fail
  tracked page deleted     fail      pass               fail
  page removed from index  PASS      fail               fail
  gitignored new page      PASS      see below          pass

`git diff` plus `ls-files --others` is the only pair that covers every
row. Plain `git status --porcelain` fails the staged row by design,
and staging is the remedy the gate's own message prescribes — the
`pre-commit` framework runs the `manpages` hook against the staged
tree, so a porcelain gate would reject every legitimate man-page
commit. `git add -N` is rejected too: it mutates the contributor's
index.

The last row is why the untracked half runs *without*
`--exclude-standard` and is scoped to `man/*.1` instead. A global
`~/.config/git/ignore` line matching `*.1` reinstates the bug exactly,
and no in-repo config overrides it; `xtask` writes nothing but `.1`
files, so narrowing the pathspec to the generated surface means
dropping the ignore rules cannot turn an editor dropping into a gate
failure. The tracked half keeps the old `man/` scope.

The assertion moves out of the two hand-mirrored shell blocks into
`utils/check-manpage-drift.py`, which both sites now call, so they
cannot drift apart; it emits the CI `::error::` annotation itself when
GITHUB_ACTIONS is set. The verdict comes from `git diff --exit-code`,
not from stdout emptiness — a configured `GIT_EXTERNAL_DIFF` can print
nothing and would otherwise read as a clean tree.

`utils/check-manpage-drift-test.py` pins all of the above against
scratch repositories (never the real `man/`, which `_pc-manpages`
rewrites concurrently). Each element of the implementation was
perturbed to its plausible-wrong alternative and watched fail:
dropping the `ls-files` half → 6 red; `git status --porcelain` → 5;
`--exclude-standard` → 1; widening either pathspec → 1 and 2;
dropping `--exit-code` → 5; dropping `--no-ext-diff` → 1; swallowing
the untracked half's GitError → 1.

Two notes for whoever reads the issue:

* the issue's prescribed probe cannot work. `touch man/zz-probe.1 &&
  make manpages-check` passes even with this fix, because
  `sweep_orphans` deletes any unexpected `.1` before the check runs.
  `git rm --cached man/<page>` is the faithful reproduction.
* the issue says `utils/check-manpage-assets.py` "reads the committed
  file list" and so cannot see the missing page. It actually globs the
  filesystem — but in CI it runs in a job that never runs `cargo
  xtask`, and locally it races `_pc-manpages` in the parallel DAG, so
  it is not a backstop either way.

Interacts with #1250 (`sweep_orphans` is case-sensitive, unlike the
write guard). On a case-sensitive filesystem a case-only command
rename deletes the stale page and adds a new-case one; the old gate
reported only the deletion, so its message never named the page that
had to be added. `CaseOnlyRenameTest` pins that both are now reported.
On a case-insensitive filesystem the same rename makes `sweep_orphans`
delete the page it just wrote — that is #1250, in the sweep, not here.

Fixes #1249
An independent audit of the suite added in 450095d9 found six tests
that could pass while the thing they name was broken. Each fix below
was confirmed by perturbing the exact production line and watching one
test — and only that test — go red.

* The verdict-from-exit-status rule was unpinned. With `--no-ext-diff`
  in place git never consults the external driver, so
  `test_external_diff_driver_cannot_report_clean` ran an ordinary diff
  and asserted what its sibling already did; switching `tracked_drift`
  back to a stdout-emptiness verdict failed nothing. That rule and the
  empty-output fallback under it are now covered by `EmptyDiffOutputTest`,
  which drives `_git` directly — no filesystem state can produce
  "status 1, no output" while the flag is passed. The external-diff
  test is renamed to what it actually pins, `--no-ext-diff`.
* `GitFailureTest` covered only the untracked half. `main()` calls
  `tracked_drift` first, so letting the *diff* half swallow every git
  failure failed nothing. Both halves now have their own test, plus a
  both-halves case.
* `GitFailureTest` also inherited the host environment and assumed
  $TMPDIR sits outside any repository. With TMPDIR inside a checkout,
  git discovered an ancestor `.git`, the gate reported a clean tree and
  both tests failed `0 != 2` with no hint of the cause. It now sets
  `GIT_CEILING_DIRECTORIES`, clears `GIT_DIR`/`GIT_WORK_TREE`, and
  asserts its own precondition — verified by removing the ceiling and
  re-running with TMPDIR inside this checkout, which now names the
  reason.
* `CaseOnlyRenameTest` is guarded on a probed case-sensitive
  filesystem. `git init` sets `core.ignorecase=true` on APFS and NTFS,
  where the rename produces a different tree; the class documented the
  assumption and nothing enforced it, so it would have gone red only
  for a macOS contributor running `make pre-commit`.
* `test_no_annotation_locally` never asserted the gate failed, so it
  was vacuous whenever the added-page half stopped firing. The helper
  now asserts the failure, and two further cases pin the annotation's
  predicate (`== "true"`, not `is not None` — popping the variable
  cannot tell those apart) and its placement (it must fire for a
  modified page too, not only an added one).
* `test_modified_page_diff_is_printed` discarded the exit code.

Also corrects the `ManDirFixture` docstring: its `.gitignore` is
realism, not a lever — emptying it changes no verdict, because the
untracked half never passes `--exclude-standard`. Per lesson 84, a
factual claim in a docstring is untested code.

Refs #1249
`render_man_page`'s collision guard compared filenames
ASCII-case-insensitively; `sweep_orphans` compared them byte-for-byte.
On APFS / NTFS a case-only command rename (`bca` -> `BCA`) writes into
the pre-existing `bca.1` directory entry — those filesystems are
case-preserving, so opening a file under a different spelling does not
rename it — and the case-sensitive sweep then unlinked the page it had
just written, with `cargo xtask` still exiting 0.

Extract the relation into `names_same_file` so the two sites cannot
spell it differently again, and classify each directory entry three
ways rather than two: byte-equal keeps, no match removes, and a match
that is not byte-equal is a conflict.

The simpler remedy — give the sweep `eq_ignore_ascii_case` and keep two
outcomes — was rejected. The two sites use the relation with opposite
polarity: the guard folds case to *reject* more collisions, whereas a
sweep that folds case *retains* more entries, so "share the guard's
relation" would invert the sweep's purpose and permanently disable
stale-page removal for any old-case page.

The issue's own rationale for that rejection was partly wrong and is
worth correcting. It claims the stale page is "tracked and unchanged, so
the drift gate never flags it". Measured against
`utils/check-manpage-drift.py` as of #1249: at the moment of the rename
the gate is red, because its untracked half reports the new `BCA.1`.
What the gate cannot see is the state after its own remedy — `bca.1` is
never dirty, since the rename does not touch its bytes, so it is
invisible to both halves from the outset and simply stays tracked. The
page does ship forever, but through the prescribed fix rather than gate
blindness. Erroring is the only verdict derivable from the filenames
alone that reads the same on every filesystem, which is what the guard's
comment says the crate normalises for.

The sweep classifies every entry before unlinking any of it.
`fs::read_dir` order is unspecified, so removing as it went would delete
a different subset of the genuine orphans on each run before bailing,
and would report only the first of several renames. Two phases make "the
error path removes nothing" a postcondition a test can assert.

The remedy in the message is a plain `rm`, deliberately: on a
case-insensitive filesystem the stale spelling now holds the freshly
rendered content, so `git rm` refuses it for having local modifications
— broken on exactly the platform the error exists for.

Composes with #1249 without depending on it: that gate makes the rename
transition visible, this makes it impossible to reach silently.

Tests cover both filesystem shapes — a single `bca.1` entry (APFS) and
both entries (ext4) — which are constructible in a tempdir either way,
plus the ASCII-only boundary of the relation and the no-removal
postcondition. Verified by test-via-revert against six perturbations:
the pre-fix case-sensitive sweep, the competing two-way
`eq_ignore_ascii_case` keep, `to_lowercase()` full folding, an inverted
keep guard, a remedy naming the expected spelling instead of the stale
one, and a single-phase remove-then-raise. Each fails only the tests
that name its behaviour.

Fixes #1250
The two-phase classification in 664688b5 claims it reports every
case-only rename rather than only the first, but no test supplied more
than one conflict — `conflicts.truncate(1)` passed the whole suite.
Assert both stale spellings appear in the error, by presence rather than
position, since `fs::read_dir` order is unspecified.
Record the ten quick-fix issues fixed on this branch in the Unreleased
section. Entries are consolidated here rather than written per fix so
the parallel fixes could not conflict on this file.
.bca-baseline.toml is marked -merge in .gitattributes, so the rebase
left it wholly conflicted rather than splicing the two sides. That is
deliberate: neither side's recorded values describe the merged tree.

Regenerated with make self-scan-write-baseline-headroom, which confirms
the point. run_check's halstead.effort is 63636 on the merged tree,
against 62885 on main and 61595 on this branch - higher than either,
because main's walk changes and this branch's #1243 signature change
compound. Taking either side would have left the gate red.

The regeneration also restores main's classify_dropped_child nargs
entry, which the intermediate conflict resolution had dropped.
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.72145% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 98.38%. Comparing base (d65b304) to head (e0ed245).

Files with missing lines Patch % Lines
src/metrics/npm/mozcpp.rs 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1309      +/-   ##
==========================================
+ Coverage   98.35%   98.38%   +0.02%     
==========================================
  Files         278      278              
  Lines       72069    72352     +283     
  Branches    71639    71922     +283     
==========================================
+ Hits        70882    71181     +299     
+ Misses        777      762      -15     
+ Partials      410      409       -1     
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.37% <99.72%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/checker.rs 96.50% <100.00%> (+0.29%) ⬆️
src/checker/c.rs 100.00% <100.00%> (+10.34%) ⬆️
src/checker/cpp.rs 100.00% <100.00%> (+10.00%) ⬆️
src/checker/mozcpp.rs 100.00% <100.00%> (+10.00%) ⬆️
src/getter/csharp.rs 91.66% <100.00%> (+1.19%) ⬆️
src/getter/elixir.rs 93.65% <ø> (ø)
src/getter/php.rs 96.42% <100.00%> (+0.97%) ⬆️
src/metrics/abc.rs 99.53% <100.00%> (+<0.01%) ⬆️
src/metrics/abc/groovy.rs 90.10% <100.00%> (+0.96%) ⬆️
src/metrics/abc/java.rs 95.53% <100.00%> (+0.18%) ⬆️
... and 9 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

sweep_errors_on_case_only_rename_with_both_entries wrote bca.1 and
BCA.1 and asserted both survive with their own contents. Two spellings
are only two files on a case-sensitive filesystem; on APFS and NTFS the
second write lands in the first file's directory entry, so the stale
page reads back as the freshly rendered one and the last assertion
fails. Green on ubuntu, red on macos-latest and windows-latest.

The comment claiming "the assertions below hold in both worlds" was
the actual defect - three of the four do, and the fourth is what the
collapse invalidates. It now says which.

Case-sensitivity is probed at run time rather than inferred from a cfg:
macOS is cfg(unix) yet case-insensitive by default, and a Linux
checkout can sit on a case-insensitive mount, so a cfg gate answers a
different question. The shared assertions - the AlreadyExists verdict,
the remedy naming the stale spelling, the expected page intact - still
run everywhere, so neither branch is vacuous. The case-insensitive
branch asserts the hazard directly: exactly one entry, still spelled
bca.1, now holding the fresh bytes.
@dekobon
dekobon merged commit 2e8bece into main Aug 9, 2026
56 checks passed
@dekobon
dekobon deleted the batch-2026-08-08-2 branch August 9, 2026 18:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment