@biffo/cli 0.291.2 → 0.291.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,7 +67,24 @@ jobs:
67
67
  with:
68
68
  enable-cache: true
69
69
  - run: uv sync --all-groups
70
- - run: uv run pytest --cov --cov-report=xml
70
+ # `--cov-report=json` is additive to the xml Codecov reads: the step
71
+ # below (`scripts/error_branch_coverage.py`) reads coverage.json and
72
+ # nothing else, so without this line it has no input and exits 2 ("no
73
+ # coverage data"), which is the gate failing to run rather than passing.
74
+ - run: uv run pytest --cov --cov-report=xml --cov-report=json
75
+ # Fails only when error handling is ADDED with nothing exercising it
76
+ # (biffo-template#956). An unexecuted `except` is unverified — nobody has
77
+ # ever observed what it does — and that is where every fail-open this
78
+ # estate has found actually lived. The analyser is template-owned and
79
+ # distributed verbatim via shared-files.json's `files` (biffo-template#1603):
80
+ # do not edit it here, edit it upstream, same rule
81
+ # scripts/py-dependency-audit.sh already carries. A freshly scaffolded
82
+ # repo has no baseline yet, so this reports what it finds and exits 0 —
83
+ # take the first baseline with `uv run python
84
+ # scripts/error_branch_coverage.py --write` once real code exists to
85
+ # measure.
86
+ - name: Error-branch coverage
87
+ run: uv run python scripts/error_branch_coverage.py --check --coverage coverage.json
71
88
  - uses: codecov/codecov-action@v5
72
89
  if: always()
73
90
  with:
@@ -5,6 +5,16 @@ __pycache__/
5
5
  .pytest_cache/
6
6
  .ruff_cache/
7
7
 
8
+ # Coverage artefacts. `--cov-report=json` is the error-branch coverage gate's
9
+ # only input (biffo-template#1603), so re-taking the baseline locally writes
10
+ # one — an untracked coverage.json sitting in `git status` is how a
11
+ # measurement output ends up committed as if it were source. `.coverage`
12
+ # (the sqlite datafile) and coverage.xml (Codecov's input) are the same
13
+ # category and were never ignored here either.
14
+ .coverage
15
+ coverage.json
16
+ coverage.xml
17
+
8
18
  # Node
9
19
  node_modules/
10
20
  dist/
@@ -107,8 +107,19 @@ python_classes = ["Test*"]
107
107
  python_functions = ["test_*"]
108
108
  asyncio_mode = "auto"
109
109
 
110
+ # `scripts` is in `source` alongside `src` because the Test job (this repo's
111
+ # own ci.yml) runs from the repo root, not scoped to `src/` -- so a script
112
+ # under `scripts/` is exactly as measurable as anything under `src/`, and a
113
+ # `source` that omits it is a scope the error-branch coverage gate
114
+ # (biffo-template#1603) cannot see into. Measured live in
115
+ # biffo-plugin-marketing: `source = ["src"]` alone left 4 of 6 branches in its
116
+ # own `scripts/` unmeasured by ANY gate until an instance vendored the plugin
117
+ # and its own wider `source` found them -- the same analyser, on the same
118
+ # code, silently blind here and not there. Keep this in agreement with
119
+ # whatever `scripts/` actually holds; a directory added there that is not in
120
+ # `source` is a directory this repo does not check.
110
121
  [tool.coverage.run]
111
- source = ["src"]
122
+ source = ["src", "scripts"]
112
123
  omit = ["*/tests/*"]
113
124
 
114
125
  # python-semantic-release config for release.yml. This repo does not use
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env python3
2
+ """Which error-handling branches has the test suite never executed? (#956)
3
+
4
+ A fail-open is a check that passes without checking. Four surfaced on
5
+ 2026-07-30 alone, none caught by a gate, and every one lived in a branch that
6
+ runs only when something has already gone wrong — an `except` that swallows, a
7
+ fallback that returns a permissive default. Ordinary line coverage hides them:
8
+ the happy path through a function is well covered, so the file looks fine.
9
+
10
+ This asks the narrower question. For every `except` handler and every
11
+ `return`-a-default fallback, did the suite ever run it? An unexecuted error
12
+ branch is not automatically a defect, but it is *unverified* — nobody has ever
13
+ observed what it does — and today's evidence is that this is precisely where
14
+ fail-opens live.
15
+
16
+ ## Why a ratchet rather than a gate
17
+
18
+ Plenty of error branches are legitimately untested: an `except ImportError`
19
+ around an optional dependency, a defensive re-raise. A hard gate at this
20
+ precision gets switched off, taking the real findings with it — the same reason
21
+ the substring-assertion lint in #957 was scoped down after measurement. So this
22
+ records a **baseline** and fails only when the count *grows*: new unverified
23
+ error handling has to be a deliberate, visible choice.
24
+
25
+ ## Scope, stated plainly
26
+
27
+ **Python only.** The three fail-opens found on 2026-07-30 were in three
28
+ different languages — shell (`scripts/verify.sh`'s `ci_has`), TypeScript
29
+ (branch-protection's 403 skip) and Python (the plugin-host lifespan
30
+ misclassification). This catches the Python one. Shell has no practical coverage
31
+ story and is not in scope; TypeScript is a possible follow-on via vitest
32
+ coverage. Claiming otherwise would be the same shape of error this tool exists
33
+ to find.
34
+
35
+ ## The blind spot this alone cannot see, and the fix for it (#637)
36
+
37
+ `--coverage` used to take exactly one `coverage.json`. In an instance, the
38
+ Python job's coverage is all this ever saw — and that job runs with no
39
+ Postgres, so every `*_pg.py` test skips there. An error branch reachable only
40
+ from a real-Postgres lane (an RLS policy refusing a write, a trigger firing)
41
+ therefore read as unexercised no matter how honestly it was tested, and the
42
+ workaround was a second, weaker test with a stub session driving the same
43
+ clause — duplication carried only because this gate could not see the real one.
44
+
45
+ `--coverage` is now repeatable and *combines* what it is given: a
46
+ line executed in ANY of them counts as executed. This is deliberately the same
47
+ mechanism as `coverage combine` (coverage.py's own tool for exactly this), done
48
+ here instead so the combine and the analysis are one step and one dependency.
49
+ A repo with a second, Postgres-dependent test lane (e.g. an instance's `RLS
50
+ Tests` workflow) can pass both artefacts:
51
+
52
+ python scripts/error_branch_coverage.py --check \\
53
+ --coverage coverage.json --coverage rls-coverage.json
54
+
55
+ Passing one path (or none, using the default) behaves exactly as before — this
56
+ is additive, not a breaking change to the single-file case.
57
+
58
+ ## Local and CI used to disagree here, and the cause was not what it looked like (#1588)
59
+
60
+ `--check`'s verdict is entirely a function of the coverage.json(s) you hand it,
61
+ so any gap between what a local pytest run executed and what CI's did shows up
62
+ here as a disagreement. In *this* repo the actual cause, found and fixed by
63
+ #1588, was neither test selection nor environment: `services/api` is async
64
+ throughout and reaches the database through SQLAlchemy's async layer, which
65
+ runs user code — including exception handlers — inside a **greenlet**
66
+ (`greenlet_spawn`), itself running on a **background thread** spun up by
67
+ FastAPI's/Starlette's `TestClient` (an `anyio` blocking portal). Coverage does
68
+ not trace either a greenlet context or a non-main thread unless told to, and
69
+ `[tool.coverage.run]` named neither — so a local run silently under-recorded
70
+ 24 files of async DB code (60 unexecuted branches locally against CI's
71
+ correctly-measured 47, reproduced exactly at commit `0820ca7f`), for no
72
+ reason a careful contributor could see by reading test output. `concurrency =
73
+ ["greenlet", "thread"]` on `[tool.coverage.run]` closes that gap; **both**
74
+ values are required — `greenlet` alone still under-counts (verified: 80
75
+ unexecuted, worse than no setting at all), because it never extends tracing
76
+ into the TestClient's background thread in the first place.
77
+
78
+ If `--check` still disagrees with CI on a repo carrying that setting, do not
79
+ reach for a narrower local pytest invocation, a Postgres service, or the
80
+ two-lane combine below as the explanation by default — confirm what actually
81
+ differs. This repo in particular has no `rls-tests.yml` and no Postgres
82
+ service on its Python job, so neither applies to it; the paragraph below is
83
+ real for a repo that has grown a genuine second test lane, not a first port of
84
+ call everywhere this script runs.
85
+
86
+ ## The two-lane combine, for a repo that has a real Postgres lane (#637)
87
+
88
+ Since #637, CI's own verdict for one commit is not even stable across its own
89
+ runs on a repo whose CI *does* run a second, Postgres-backed lane (e.g. an
90
+ instance's `RLS Tests` workflow) alongside the plain Python job — that is a
91
+ different, additive concern from the greenlet/thread gap above, and applies
92
+ only where such a lane exists. A local `--check` reports against whatever
93
+ coverage.json(s) YOU hand it — for most contributors, one file, from one
94
+ pytest invocation, compared against the recorded baseline. A repo's `ci.yml`
95
+ does exactly the same comparison against the same baseline, but best-effort
96
+ combines the Postgres-only lane's artefact when it can reach one (see that
97
+ file's own comments on the timing this depends on) — and combining coverage
98
+ can only mark MORE lines executed, never fewer, so the second artefact can
99
+ only turn a branch from "new and unexecuted" into "already covered", never
100
+ the reverse. That means: a run of CI that catches the artefact in time can go
101
+ green on a branch an earlier, artefact-less run of the very same commit
102
+ reported as newly unexecuted. **On a repo with such a lane, a clean local
103
+ `--check` is therefore not evidence CI will pass, and neither is a red CI run
104
+ evidence the next run of the identical commit will also be red** —
105
+ re-running once the Postgres-only lane has finished is the remedy for that
106
+ shape of red, not a sign the gate is flaky. Pass every coverage.json you have
107
+ (see the `--coverage` usage above) and trust the one that has seen the most.
108
+
109
+ Usage:
110
+ uv run pytest --cov --cov-report=json # writes coverage.json
111
+ python scripts/error_branch_coverage.py # report
112
+ python scripts/error_branch_coverage.py --write # update the baseline
113
+ python scripts/error_branch_coverage.py --check # fail if it grew
114
+ python scripts/error_branch_coverage.py --check --coverage a.json --coverage b.json
115
+ # combine two lanes' coverage first (#637)
116
+ python scripts/error_branch_coverage.py --check --source-root <tree> --coverage a.json
117
+ # judge a tree other than this
118
+ # script's own repo (#1595)
119
+ """
120
+
121
+ from __future__ import annotations
122
+
123
+ import argparse
124
+ import ast
125
+ import json
126
+ import sys
127
+ from dataclasses import dataclass
128
+ from pathlib import Path
129
+
130
+ REPO_ROOT = Path(__file__).resolve().parent.parent
131
+ BASELINE_REL = Path("docs/practices/error-branch-baseline.json")
132
+ BASELINE = REPO_ROOT / BASELINE_REL
133
+ COVERAGE_JSON = REPO_ROOT / "coverage.json"
134
+
135
+
136
+ def baseline_for(source_root: Path) -> Path:
137
+ """The baseline belonging to the tree being judged.
138
+
139
+ Ordinarily that is this script's own repo and the answer is `BASELINE`,
140
+ unchanged. It differs only for a caller that passed `--source-root` — see
141
+ `unexecuted`'s note for why the source and the baseline must travel
142
+ together rather than being taken from wherever the script happens to sit.
143
+ """
144
+ if source_root == REPO_ROOT:
145
+ return BASELINE
146
+ return source_root / BASELINE_REL
147
+
148
+
149
+ # The two commands that take the first measurement, in the order they must run.
150
+ # Named in every message about a missing baseline, because the missing piece is
151
+ # never obvious from the failure: the analyser reads `coverage.json`, which only
152
+ # exists after a --cov run, and neither the FileNotFoundError nor "N error
153
+ # branches added" says so (#983).
154
+ BOOTSTRAP_COMMANDS = (
155
+ "uv run pytest --cov --cov-report=json",
156
+ "uv run python scripts/error_branch_coverage.py --write",
157
+ )
158
+
159
+
160
+ # Why an absent baseline is a normal state, not a broken repo.
161
+ #
162
+ # This script is template-owned and reaches every instance through `biffo core
163
+ # upgrade`. Its baseline is NOT, and must not be: the file is a measurement of
164
+ # the repo it lives in, so shipping the template's copy would assert the
165
+ # template's unexecuted branches against an instance's tree — wrong data, naming
166
+ # files that do not exist there.
167
+ #
168
+ # So the test travels and its data cannot, and every instance arrives at this
169
+ # gate having never taken the measurement. A ratchet with no prior position
170
+ # should start, not block.
171
+ def no_baseline_message(baseline: Path) -> str:
172
+ """Written against the baseline actually looked for, not a fixed path.
173
+
174
+ A caller that passed `--source-root` is judging a different tree, and
175
+ naming this repo's baseline in the failure would send the reader to a file
176
+ that was never consulted.
177
+ """
178
+ try:
179
+ where: Path | str = baseline.relative_to(REPO_ROOT)
180
+ except ValueError:
181
+ where = baseline
182
+
183
+ return (
184
+ f"No error-branch baseline at {where}.\n"
185
+ "\n"
186
+ "That file is a measurement of THIS repo, so it is not distributed by a core\n"
187
+ "upgrade — a fresh instance has simply never taken it (#983). Take it with:\n"
188
+ "\n"
189
+ f" {BOOTSTRAP_COMMANDS[0]}\n"
190
+ f" {BOOTSTRAP_COMMANDS[1]}\n"
191
+ "\n"
192
+ "Until then the ratchet has no prior position to compare against, so it\n"
193
+ "reports what it finds and does not fail."
194
+ )
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class Branch:
199
+ """One error-handling branch, identified by where its body starts."""
200
+
201
+ path: str
202
+ line: int
203
+ kind: str
204
+ label: str
205
+
206
+ def key(self) -> str:
207
+ return f"{self.path}:{self.kind}:{self.label}"
208
+
209
+
210
+ def _handler_label(node: ast.ExceptHandler) -> str:
211
+ if node.type is None:
212
+ return "except:"
213
+ try:
214
+ return f"except {ast.unparse(node.type)}"
215
+ except Exception: # pragma: no cover - unparse is total on real trees
216
+ return "except <?>"
217
+
218
+
219
+ def error_branches(tree: ast.AST, path: str) -> list[Branch]:
220
+ """Every error-handling branch in one module.
221
+
222
+ Two shapes, both of which have produced fail-opens in this estate:
223
+
224
+ - an `except` handler, whose body runs only when something raised;
225
+ - a bare `return`/`return <literal>` that is the *only* statement of an
226
+ `if`, which is the fallback shape — "if we cannot tell, say yes".
227
+ """
228
+ found: list[Branch] = []
229
+
230
+ for node in ast.walk(tree):
231
+ if isinstance(node, ast.ExceptHandler):
232
+ body = node.body[0]
233
+ found.append(Branch(path, body.lineno, "except", _handler_label(node)))
234
+ continue
235
+
236
+ if isinstance(node, ast.If) and len(node.body) == 1:
237
+ stmt = node.body[0]
238
+ if not isinstance(stmt, ast.Return) or stmt.value is None:
239
+ continue
240
+ # Only constant defaults. A computed return is ordinary control
241
+ # flow; `return True` / `return frozenset()` under a guard is the
242
+ # shape that decides a question by assumption.
243
+ if isinstance(stmt.value, ast.Constant) or (
244
+ isinstance(stmt.value, ast.Call)
245
+ and isinstance(stmt.value.func, ast.Name)
246
+ and stmt.value.func.id in {"set", "frozenset", "list", "dict", "tuple"}
247
+ and not stmt.value.args
248
+ ):
249
+ try:
250
+ label = f"if {ast.unparse(node.test)[:50]} -> {ast.unparse(stmt.value)}"
251
+ except Exception: # pragma: no cover
252
+ label = "if <?> -> <?>"
253
+ found.append(Branch(path, stmt.lineno, "fallback", label))
254
+
255
+ return found
256
+
257
+
258
+ def unexecuted(coverage: dict, root: Path) -> list[Branch]:
259
+ """Error branches whose first executed line never ran under the suite.
260
+
261
+ `root` MUST be the tree the coverage was measured against. This function
262
+ parses `root / rel` to find branches and then asks whether their line
263
+ numbers appear in that report's `executed_lines` / `missing_lines` — so a
264
+ `root` from a different revision looks the report's line numbers up in the
265
+ wrong file, and a change of even one line above a branch shifts every
266
+ verdict below it.
267
+
268
+ That was live in the `workflow_run` gate (#1595), which runs this script
269
+ from the default branch — correctly, so a fork's PR cannot execute its own
270
+ modified analyser — and until `--source-root` existed took the *source*
271
+ from that same checkout too. On tabsii-platform#922 the default branch's
272
+ `admin_app.py` was 35 lines shorter than the commit under test's, and the
273
+ gate reported two covered branches as newly unexecuted at lines that held
274
+ unrelated code. It diverges only on files a commit changes, which is
275
+ exactly the set a gate exists to judge.
276
+ """
277
+ files = coverage.get("files", {})
278
+ out: list[Branch] = []
279
+
280
+ for rel, data in sorted(files.items()):
281
+ source = root / rel
282
+ if not source.is_file():
283
+ continue
284
+ try:
285
+ tree = ast.parse(source.read_text())
286
+ except SyntaxError:
287
+ continue
288
+
289
+ executed = set(data.get("executed_lines", []))
290
+ # A line coverage.py never considered (a comment, say) is not evidence
291
+ # of anything; only count a branch whose body line is one coverage.py
292
+ # tracked and reported as missing.
293
+ missing = set(data.get("missing_lines", []))
294
+
295
+ for branch in error_branches(tree, rel):
296
+ if branch.line in executed:
297
+ continue
298
+ if branch.line in missing:
299
+ out.append(branch)
300
+
301
+ return out
302
+
303
+
304
+ def merge_coverage(reports: list[dict]) -> dict:
305
+ """Combine coverage.json reports so a line executed in ANY of them counts.
306
+
307
+ Built for #637: a line is "unverified" only if nothing that ran ever
308
+ reached it, so the merge is a per-file UNION of executed_lines — the same
309
+ outcome `coverage combine` gives, computed here instead so pulling in a
310
+ second lane (e.g. a real-Postgres test run) needs no extra tool, just a
311
+ second coverage.json.
312
+
313
+ `missing_lines` follows from the merged `executed_lines`, not from a
314
+ separate union: a line coverage.py called "missing" in one report but
315
+ "executed" in another was, in fact, executed — carrying the stale
316
+ "missing" verdict forward would silently re-introduce the exact blind
317
+ spot this function exists to close. A single input is the identity case:
318
+ merging one report must read exactly as if merge_coverage were never
319
+ called, so the single-`--coverage` path (unchanged since #956) still
320
+ behaves the same after this.
321
+ """
322
+ merged_files: dict[str, dict] = {}
323
+ for report in reports:
324
+ for rel, data in report.get("files", {}).items():
325
+ entry = merged_files.setdefault(rel, {"executed": set(), "missing": set()})
326
+ entry["executed"] |= set(data.get("executed_lines", []))
327
+ entry["missing"] |= set(data.get("missing_lines", []))
328
+
329
+ return {
330
+ "files": {
331
+ rel: {
332
+ "executed_lines": sorted(entry["executed"]),
333
+ "missing_lines": sorted(entry["missing"] - entry["executed"]),
334
+ }
335
+ for rel, entry in merged_files.items()
336
+ }
337
+ }
338
+
339
+
340
+ def load_baseline(baseline: Path) -> dict | None:
341
+ """The committed baseline, or None when this repo has never taken one.
342
+
343
+ None rather than an empty baseline. They are different states and used to be
344
+ conflated: an empty baseline means "measured, and found nothing", which for a
345
+ tree this size means the analyser is broken; a missing one means "never
346
+ measured". Reading the second as the first made every branch look NEW and
347
+ red-lit the gate on every instance that upgraded (#983).
348
+ """
349
+ if not baseline.is_file():
350
+ return None
351
+ return json.loads(baseline.read_text())
352
+
353
+
354
+ def main() -> int:
355
+ parser = argparse.ArgumentParser(description=__doc__)
356
+ parser.add_argument("--write", action="store_true", help="record the current set as baseline")
357
+ parser.add_argument("--check", action="store_true", help="fail if the count grew")
358
+ parser.add_argument(
359
+ "--coverage",
360
+ type=Path,
361
+ action="append",
362
+ default=None,
363
+ help=(
364
+ "coverage.json to analyse. Repeatable (#637): pass it more than once "
365
+ "to combine several lanes' coverage (e.g. the Python job's and a "
366
+ "real-Postgres RLS lane's) — a line executed in ANY of them counts "
367
+ "as executed. Defaults to a single coverage.json at the repo root."
368
+ ),
369
+ )
370
+ parser.add_argument(
371
+ "--source-root",
372
+ type=Path,
373
+ default=None,
374
+ help=(
375
+ "tree whose source and baseline to judge the coverage against. "
376
+ "Defaults to this script's own repo, which is correct whenever the "
377
+ "coverage was produced from the same checkout. A caller running "
378
+ "this script from one revision against coverage measured on "
379
+ "ANOTHER — the workflow_run gate, which deliberately executes the "
380
+ "default branch's copy of this script — must point it at the "
381
+ "commit under test, or line numbers are looked up in the wrong "
382
+ "revision's AST."
383
+ ),
384
+ )
385
+ args = parser.parse_args()
386
+ coverage_paths: list[Path] = args.coverage if args.coverage else [COVERAGE_JSON]
387
+ source_root: Path = args.source_root.resolve() if args.source_root else REPO_ROOT
388
+ baseline_path = baseline_for(source_root)
389
+
390
+ present = [p for p in coverage_paths if p.is_file()]
391
+ if not present:
392
+ paths_str = ", ".join(str(p) for p in coverage_paths)
393
+ print(
394
+ f"No coverage data at {paths_str}.\nRun: uv run pytest --cov --cov-report=json",
395
+ file=sys.stderr,
396
+ )
397
+ return 2
398
+
399
+ absent = [p for p in coverage_paths if p not in present]
400
+ for p in absent:
401
+ # Not fatal: a repo that has only just adopted a second lane, or is
402
+ # invoked before that lane has produced its artefact yet, still gets an
403
+ # answer from what IS present rather than refusing outright — but the
404
+ # gap must be LOUD, not a line to scroll past. #637 was filed on exactly
405
+ # this shape: "a check that skips an input it cannot evaluate is not
406
+ # neutral — it shrinks its own scope and reports the remainder as the
407
+ # whole" (AGENTS.md §2). A clean result over fewer lanes than requested
408
+ # has to be falsifiable, so this is a `::warning::` GitHub Actions
409
+ # annotation — the estate's own convention for exactly this
410
+ # (scripts/py-dependency-audit.sh, scripts/js-dependency-audit.sh) —
411
+ # not a "note:" that only shows up if someone scrolls the raw log.
412
+ # Printed unconditionally, not gated on running-in-CI: it is equally
413
+ # true, and equally worth seeing, from a terminal.
414
+ print(
415
+ f"::warning::error-branch coverage: requested artefact not found: {p} — "
416
+ f"proceeding with {len(present)}/{len(coverage_paths)} coverage "
417
+ "file(s). This result does NOT reflect that lane's coverage.",
418
+ file=sys.stderr,
419
+ )
420
+
421
+ # Restated in every summary line below (not just the warning above) so the
422
+ # denominator survives even if the warning scrolls past unread: a reader
423
+ # who sees only the final line still learns how much of the requested
424
+ # picture this result is actually over.
425
+ coverage_note = f"{len(present)}/{len(coverage_paths)} coverage artefact(s)"
426
+ if absent:
427
+ coverage_note += f" — MISSING: {', '.join(str(p) for p in absent)}"
428
+
429
+ reports = [json.loads(p.read_text()) for p in present]
430
+ coverage = merge_coverage(reports)
431
+ found = unexecuted(coverage, source_root)
432
+ keys = sorted({b.key() for b in found})
433
+
434
+ if args.write:
435
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
436
+ baseline_path.write_text(
437
+ json.dumps({"total": len(keys), "branches": keys}, indent=2) + "\n",
438
+ )
439
+ print(f"baseline written: {len(keys)} unexecuted error branches")
440
+ return 0
441
+
442
+ baseline = load_baseline(baseline_path)
443
+ if baseline is None:
444
+ # Report, then stop. Loudly, on stderr, naming both commands — a gate
445
+ # that goes quiet without saying so is the fail-open this whole script
446
+ # exists to hunt.
447
+ print(no_baseline_message(baseline_path), file=sys.stderr)
448
+ for branch in sorted(found, key=lambda b: (b.path, b.line)):
449
+ print(f" {branch.path}:{branch.line} [{branch.kind}] {branch.label}")
450
+ print(f"unexecuted error branches: {len(keys)} (no baseline yet; {coverage_note})")
451
+ return 0
452
+
453
+ known = set(baseline.get("branches", []))
454
+ new = [k for k in keys if k not in known]
455
+
456
+ print(
457
+ f"unexecuted error branches: {len(keys)} "
458
+ f"(baseline {baseline.get('total', 0)}; {coverage_note})"
459
+ )
460
+ for branch in sorted(found, key=lambda b: (b.path, b.line)):
461
+ marker = "NEW " if branch.key() not in known else " "
462
+ print(f" {marker}{branch.path}:{branch.line} [{branch.kind}] {branch.label}")
463
+
464
+ if args.check and new:
465
+ print(
466
+ f"\n{len(new)} error branch(es) added with no test exercising them:",
467
+ file=sys.stderr,
468
+ )
469
+ for k in new:
470
+ print(f" {k}", file=sys.stderr)
471
+ print(
472
+ "\nAn unexecuted error branch is unverified — nobody has observed what it does.\n"
473
+ "Either cover it, or run --write to accept it deliberately.",
474
+ file=sys.stderr,
475
+ )
476
+ return 1
477
+
478
+ return 0
479
+
480
+
481
+ if __name__ == "__main__":
482
+ raise SystemExit(main())
@@ -194,7 +194,31 @@ jobs:
194
194
  run: uv run pyright
195
195
  - name: Test
196
196
  if: ${{ !cancelled() }}
197
- run: uv run pytest --cov --cov-report=xml
197
+ # `--cov-report=json` is additive to the xml Coverage upload step reads:
198
+ # the Error-branch coverage step below reads coverage.json and nothing
199
+ # else, so without this line it has no input and exits 2 rather than
200
+ # actually checking anything.
201
+ run: uv run pytest --cov --cov-report=xml --cov-report=json
202
+ # Fails only when error handling is ADDED with nothing exercising it
203
+ # (biffo-template#956). An unexecuted `except` is unverified — nobody has
204
+ # ever observed what it does. The analyser is template-owned and
205
+ # distributed verbatim via shared-files.json's `files` at the repo root
206
+ # (biffo-template#1603) — invoked with `../../` because this job's steps
207
+ # run with working-directory services/api, same as
208
+ # scripts/py-dependency-audit.sh below. `--source-root .` matters here:
209
+ # coverage.json's paths are relative to THIS cwd (services/api), and the
210
+ # script's default source root is two directories up from its own file —
211
+ # right for the template/instance, wrong here, where the code being
212
+ # measured lives under services/api, not the repo root. Do not edit this
213
+ # script here; edit it upstream, same rule
214
+ # scripts/py-dependency-audit.sh already carries. A freshly scaffolded
215
+ # sibling has no baseline yet, so this reports what it finds and exits
216
+ # 0 — take the first baseline with `uv run python
217
+ # ../../scripts/error_branch_coverage.py --write --source-root .` once
218
+ # real code exists to measure.
219
+ - name: Error-branch coverage
220
+ if: ${{ !cancelled() }}
221
+ run: uv run python ../../scripts/error_branch_coverage.py --check --coverage coverage.json --source-root .
198
222
  - name: Coverage upload
199
223
  if: ${{ !cancelled() }}
200
224
  uses: codecov/codecov-action@v5
@@ -65,8 +65,15 @@ Thumbs.db
65
65
  npm-debug.log*
66
66
  pnpm-debug.log*
67
67
 
68
- # Test coverage
68
+ # Test coverage. `coverage` (bare) matches a `coverage/` directory, not the
69
+ # `coverage.json`/`coverage.xml` files services/api's Test job now writes --
70
+ # `--cov-report=json` is the error-branch coverage gate's only input
71
+ # (biffo-template#1603), so re-taking the baseline locally writes one, and an
72
+ # untracked coverage.json sitting in `git status` is how a measurement output
73
+ # ends up committed as if it were source.
69
74
  coverage
75
+ coverage.json
76
+ coverage.xml
70
77
  htmlcov
71
78
  .coverage
72
79
 
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env python3
2
+ """Which error-handling branches has the test suite never executed? (#956)
3
+
4
+ A fail-open is a check that passes without checking. Four surfaced on
5
+ 2026-07-30 alone, none caught by a gate, and every one lived in a branch that
6
+ runs only when something has already gone wrong — an `except` that swallows, a
7
+ fallback that returns a permissive default. Ordinary line coverage hides them:
8
+ the happy path through a function is well covered, so the file looks fine.
9
+
10
+ This asks the narrower question. For every `except` handler and every
11
+ `return`-a-default fallback, did the suite ever run it? An unexecuted error
12
+ branch is not automatically a defect, but it is *unverified* — nobody has ever
13
+ observed what it does — and today's evidence is that this is precisely where
14
+ fail-opens live.
15
+
16
+ ## Why a ratchet rather than a gate
17
+
18
+ Plenty of error branches are legitimately untested: an `except ImportError`
19
+ around an optional dependency, a defensive re-raise. A hard gate at this
20
+ precision gets switched off, taking the real findings with it — the same reason
21
+ the substring-assertion lint in #957 was scoped down after measurement. So this
22
+ records a **baseline** and fails only when the count *grows*: new unverified
23
+ error handling has to be a deliberate, visible choice.
24
+
25
+ ## Scope, stated plainly
26
+
27
+ **Python only.** The three fail-opens found on 2026-07-30 were in three
28
+ different languages — shell (`scripts/verify.sh`'s `ci_has`), TypeScript
29
+ (branch-protection's 403 skip) and Python (the plugin-host lifespan
30
+ misclassification). This catches the Python one. Shell has no practical coverage
31
+ story and is not in scope; TypeScript is a possible follow-on via vitest
32
+ coverage. Claiming otherwise would be the same shape of error this tool exists
33
+ to find.
34
+
35
+ ## The blind spot this alone cannot see, and the fix for it (#637)
36
+
37
+ `--coverage` used to take exactly one `coverage.json`. In an instance, the
38
+ Python job's coverage is all this ever saw — and that job runs with no
39
+ Postgres, so every `*_pg.py` test skips there. An error branch reachable only
40
+ from a real-Postgres lane (an RLS policy refusing a write, a trigger firing)
41
+ therefore read as unexercised no matter how honestly it was tested, and the
42
+ workaround was a second, weaker test with a stub session driving the same
43
+ clause — duplication carried only because this gate could not see the real one.
44
+
45
+ `--coverage` is now repeatable and *combines* what it is given: a
46
+ line executed in ANY of them counts as executed. This is deliberately the same
47
+ mechanism as `coverage combine` (coverage.py's own tool for exactly this), done
48
+ here instead so the combine and the analysis are one step and one dependency.
49
+ A repo with a second, Postgres-dependent test lane (e.g. an instance's `RLS
50
+ Tests` workflow) can pass both artefacts:
51
+
52
+ python scripts/error_branch_coverage.py --check \\
53
+ --coverage coverage.json --coverage rls-coverage.json
54
+
55
+ Passing one path (or none, using the default) behaves exactly as before — this
56
+ is additive, not a breaking change to the single-file case.
57
+
58
+ ## Local and CI used to disagree here, and the cause was not what it looked like (#1588)
59
+
60
+ `--check`'s verdict is entirely a function of the coverage.json(s) you hand it,
61
+ so any gap between what a local pytest run executed and what CI's did shows up
62
+ here as a disagreement. In *this* repo the actual cause, found and fixed by
63
+ #1588, was neither test selection nor environment: `services/api` is async
64
+ throughout and reaches the database through SQLAlchemy's async layer, which
65
+ runs user code — including exception handlers — inside a **greenlet**
66
+ (`greenlet_spawn`), itself running on a **background thread** spun up by
67
+ FastAPI's/Starlette's `TestClient` (an `anyio` blocking portal). Coverage does
68
+ not trace either a greenlet context or a non-main thread unless told to, and
69
+ `[tool.coverage.run]` named neither — so a local run silently under-recorded
70
+ 24 files of async DB code (60 unexecuted branches locally against CI's
71
+ correctly-measured 47, reproduced exactly at commit `0820ca7f`), for no
72
+ reason a careful contributor could see by reading test output. `concurrency =
73
+ ["greenlet", "thread"]` on `[tool.coverage.run]` closes that gap; **both**
74
+ values are required — `greenlet` alone still under-counts (verified: 80
75
+ unexecuted, worse than no setting at all), because it never extends tracing
76
+ into the TestClient's background thread in the first place.
77
+
78
+ If `--check` still disagrees with CI on a repo carrying that setting, do not
79
+ reach for a narrower local pytest invocation, a Postgres service, or the
80
+ two-lane combine below as the explanation by default — confirm what actually
81
+ differs. This repo in particular has no `rls-tests.yml` and no Postgres
82
+ service on its Python job, so neither applies to it; the paragraph below is
83
+ real for a repo that has grown a genuine second test lane, not a first port of
84
+ call everywhere this script runs.
85
+
86
+ ## The two-lane combine, for a repo that has a real Postgres lane (#637)
87
+
88
+ Since #637, CI's own verdict for one commit is not even stable across its own
89
+ runs on a repo whose CI *does* run a second, Postgres-backed lane (e.g. an
90
+ instance's `RLS Tests` workflow) alongside the plain Python job — that is a
91
+ different, additive concern from the greenlet/thread gap above, and applies
92
+ only where such a lane exists. A local `--check` reports against whatever
93
+ coverage.json(s) YOU hand it — for most contributors, one file, from one
94
+ pytest invocation, compared against the recorded baseline. A repo's `ci.yml`
95
+ does exactly the same comparison against the same baseline, but best-effort
96
+ combines the Postgres-only lane's artefact when it can reach one (see that
97
+ file's own comments on the timing this depends on) — and combining coverage
98
+ can only mark MORE lines executed, never fewer, so the second artefact can
99
+ only turn a branch from "new and unexecuted" into "already covered", never
100
+ the reverse. That means: a run of CI that catches the artefact in time can go
101
+ green on a branch an earlier, artefact-less run of the very same commit
102
+ reported as newly unexecuted. **On a repo with such a lane, a clean local
103
+ `--check` is therefore not evidence CI will pass, and neither is a red CI run
104
+ evidence the next run of the identical commit will also be red** —
105
+ re-running once the Postgres-only lane has finished is the remedy for that
106
+ shape of red, not a sign the gate is flaky. Pass every coverage.json you have
107
+ (see the `--coverage` usage above) and trust the one that has seen the most.
108
+
109
+ Usage:
110
+ uv run pytest --cov --cov-report=json # writes coverage.json
111
+ python scripts/error_branch_coverage.py # report
112
+ python scripts/error_branch_coverage.py --write # update the baseline
113
+ python scripts/error_branch_coverage.py --check # fail if it grew
114
+ python scripts/error_branch_coverage.py --check --coverage a.json --coverage b.json
115
+ # combine two lanes' coverage first (#637)
116
+ python scripts/error_branch_coverage.py --check --source-root <tree> --coverage a.json
117
+ # judge a tree other than this
118
+ # script's own repo (#1595)
119
+ """
120
+
121
+ from __future__ import annotations
122
+
123
+ import argparse
124
+ import ast
125
+ import json
126
+ import sys
127
+ from dataclasses import dataclass
128
+ from pathlib import Path
129
+
130
+ REPO_ROOT = Path(__file__).resolve().parent.parent
131
+ BASELINE_REL = Path("docs/practices/error-branch-baseline.json")
132
+ BASELINE = REPO_ROOT / BASELINE_REL
133
+ COVERAGE_JSON = REPO_ROOT / "coverage.json"
134
+
135
+
136
+ def baseline_for(source_root: Path) -> Path:
137
+ """The baseline belonging to the tree being judged.
138
+
139
+ Ordinarily that is this script's own repo and the answer is `BASELINE`,
140
+ unchanged. It differs only for a caller that passed `--source-root` — see
141
+ `unexecuted`'s note for why the source and the baseline must travel
142
+ together rather than being taken from wherever the script happens to sit.
143
+ """
144
+ if source_root == REPO_ROOT:
145
+ return BASELINE
146
+ return source_root / BASELINE_REL
147
+
148
+
149
+ # The two commands that take the first measurement, in the order they must run.
150
+ # Named in every message about a missing baseline, because the missing piece is
151
+ # never obvious from the failure: the analyser reads `coverage.json`, which only
152
+ # exists after a --cov run, and neither the FileNotFoundError nor "N error
153
+ # branches added" says so (#983).
154
+ BOOTSTRAP_COMMANDS = (
155
+ "uv run pytest --cov --cov-report=json",
156
+ "uv run python scripts/error_branch_coverage.py --write",
157
+ )
158
+
159
+
160
+ # Why an absent baseline is a normal state, not a broken repo.
161
+ #
162
+ # This script is template-owned and reaches every instance through `biffo core
163
+ # upgrade`. Its baseline is NOT, and must not be: the file is a measurement of
164
+ # the repo it lives in, so shipping the template's copy would assert the
165
+ # template's unexecuted branches against an instance's tree — wrong data, naming
166
+ # files that do not exist there.
167
+ #
168
+ # So the test travels and its data cannot, and every instance arrives at this
169
+ # gate having never taken the measurement. A ratchet with no prior position
170
+ # should start, not block.
171
+ def no_baseline_message(baseline: Path) -> str:
172
+ """Written against the baseline actually looked for, not a fixed path.
173
+
174
+ A caller that passed `--source-root` is judging a different tree, and
175
+ naming this repo's baseline in the failure would send the reader to a file
176
+ that was never consulted.
177
+ """
178
+ try:
179
+ where: Path | str = baseline.relative_to(REPO_ROOT)
180
+ except ValueError:
181
+ where = baseline
182
+
183
+ return (
184
+ f"No error-branch baseline at {where}.\n"
185
+ "\n"
186
+ "That file is a measurement of THIS repo, so it is not distributed by a core\n"
187
+ "upgrade — a fresh instance has simply never taken it (#983). Take it with:\n"
188
+ "\n"
189
+ f" {BOOTSTRAP_COMMANDS[0]}\n"
190
+ f" {BOOTSTRAP_COMMANDS[1]}\n"
191
+ "\n"
192
+ "Until then the ratchet has no prior position to compare against, so it\n"
193
+ "reports what it finds and does not fail."
194
+ )
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class Branch:
199
+ """One error-handling branch, identified by where its body starts."""
200
+
201
+ path: str
202
+ line: int
203
+ kind: str
204
+ label: str
205
+
206
+ def key(self) -> str:
207
+ return f"{self.path}:{self.kind}:{self.label}"
208
+
209
+
210
+ def _handler_label(node: ast.ExceptHandler) -> str:
211
+ if node.type is None:
212
+ return "except:"
213
+ try:
214
+ return f"except {ast.unparse(node.type)}"
215
+ except Exception: # pragma: no cover - unparse is total on real trees
216
+ return "except <?>"
217
+
218
+
219
+ def error_branches(tree: ast.AST, path: str) -> list[Branch]:
220
+ """Every error-handling branch in one module.
221
+
222
+ Two shapes, both of which have produced fail-opens in this estate:
223
+
224
+ - an `except` handler, whose body runs only when something raised;
225
+ - a bare `return`/`return <literal>` that is the *only* statement of an
226
+ `if`, which is the fallback shape — "if we cannot tell, say yes".
227
+ """
228
+ found: list[Branch] = []
229
+
230
+ for node in ast.walk(tree):
231
+ if isinstance(node, ast.ExceptHandler):
232
+ body = node.body[0]
233
+ found.append(Branch(path, body.lineno, "except", _handler_label(node)))
234
+ continue
235
+
236
+ if isinstance(node, ast.If) and len(node.body) == 1:
237
+ stmt = node.body[0]
238
+ if not isinstance(stmt, ast.Return) or stmt.value is None:
239
+ continue
240
+ # Only constant defaults. A computed return is ordinary control
241
+ # flow; `return True` / `return frozenset()` under a guard is the
242
+ # shape that decides a question by assumption.
243
+ if isinstance(stmt.value, ast.Constant) or (
244
+ isinstance(stmt.value, ast.Call)
245
+ and isinstance(stmt.value.func, ast.Name)
246
+ and stmt.value.func.id in {"set", "frozenset", "list", "dict", "tuple"}
247
+ and not stmt.value.args
248
+ ):
249
+ try:
250
+ label = f"if {ast.unparse(node.test)[:50]} -> {ast.unparse(stmt.value)}"
251
+ except Exception: # pragma: no cover
252
+ label = "if <?> -> <?>"
253
+ found.append(Branch(path, stmt.lineno, "fallback", label))
254
+
255
+ return found
256
+
257
+
258
+ def unexecuted(coverage: dict, root: Path) -> list[Branch]:
259
+ """Error branches whose first executed line never ran under the suite.
260
+
261
+ `root` MUST be the tree the coverage was measured against. This function
262
+ parses `root / rel` to find branches and then asks whether their line
263
+ numbers appear in that report's `executed_lines` / `missing_lines` — so a
264
+ `root` from a different revision looks the report's line numbers up in the
265
+ wrong file, and a change of even one line above a branch shifts every
266
+ verdict below it.
267
+
268
+ That was live in the `workflow_run` gate (#1595), which runs this script
269
+ from the default branch — correctly, so a fork's PR cannot execute its own
270
+ modified analyser — and until `--source-root` existed took the *source*
271
+ from that same checkout too. On tabsii-platform#922 the default branch's
272
+ `admin_app.py` was 35 lines shorter than the commit under test's, and the
273
+ gate reported two covered branches as newly unexecuted at lines that held
274
+ unrelated code. It diverges only on files a commit changes, which is
275
+ exactly the set a gate exists to judge.
276
+ """
277
+ files = coverage.get("files", {})
278
+ out: list[Branch] = []
279
+
280
+ for rel, data in sorted(files.items()):
281
+ source = root / rel
282
+ if not source.is_file():
283
+ continue
284
+ try:
285
+ tree = ast.parse(source.read_text())
286
+ except SyntaxError:
287
+ continue
288
+
289
+ executed = set(data.get("executed_lines", []))
290
+ # A line coverage.py never considered (a comment, say) is not evidence
291
+ # of anything; only count a branch whose body line is one coverage.py
292
+ # tracked and reported as missing.
293
+ missing = set(data.get("missing_lines", []))
294
+
295
+ for branch in error_branches(tree, rel):
296
+ if branch.line in executed:
297
+ continue
298
+ if branch.line in missing:
299
+ out.append(branch)
300
+
301
+ return out
302
+
303
+
304
+ def merge_coverage(reports: list[dict]) -> dict:
305
+ """Combine coverage.json reports so a line executed in ANY of them counts.
306
+
307
+ Built for #637: a line is "unverified" only if nothing that ran ever
308
+ reached it, so the merge is a per-file UNION of executed_lines — the same
309
+ outcome `coverage combine` gives, computed here instead so pulling in a
310
+ second lane (e.g. a real-Postgres test run) needs no extra tool, just a
311
+ second coverage.json.
312
+
313
+ `missing_lines` follows from the merged `executed_lines`, not from a
314
+ separate union: a line coverage.py called "missing" in one report but
315
+ "executed" in another was, in fact, executed — carrying the stale
316
+ "missing" verdict forward would silently re-introduce the exact blind
317
+ spot this function exists to close. A single input is the identity case:
318
+ merging one report must read exactly as if merge_coverage were never
319
+ called, so the single-`--coverage` path (unchanged since #956) still
320
+ behaves the same after this.
321
+ """
322
+ merged_files: dict[str, dict] = {}
323
+ for report in reports:
324
+ for rel, data in report.get("files", {}).items():
325
+ entry = merged_files.setdefault(rel, {"executed": set(), "missing": set()})
326
+ entry["executed"] |= set(data.get("executed_lines", []))
327
+ entry["missing"] |= set(data.get("missing_lines", []))
328
+
329
+ return {
330
+ "files": {
331
+ rel: {
332
+ "executed_lines": sorted(entry["executed"]),
333
+ "missing_lines": sorted(entry["missing"] - entry["executed"]),
334
+ }
335
+ for rel, entry in merged_files.items()
336
+ }
337
+ }
338
+
339
+
340
+ def load_baseline(baseline: Path) -> dict | None:
341
+ """The committed baseline, or None when this repo has never taken one.
342
+
343
+ None rather than an empty baseline. They are different states and used to be
344
+ conflated: an empty baseline means "measured, and found nothing", which for a
345
+ tree this size means the analyser is broken; a missing one means "never
346
+ measured". Reading the second as the first made every branch look NEW and
347
+ red-lit the gate on every instance that upgraded (#983).
348
+ """
349
+ if not baseline.is_file():
350
+ return None
351
+ return json.loads(baseline.read_text())
352
+
353
+
354
+ def main() -> int:
355
+ parser = argparse.ArgumentParser(description=__doc__)
356
+ parser.add_argument("--write", action="store_true", help="record the current set as baseline")
357
+ parser.add_argument("--check", action="store_true", help="fail if the count grew")
358
+ parser.add_argument(
359
+ "--coverage",
360
+ type=Path,
361
+ action="append",
362
+ default=None,
363
+ help=(
364
+ "coverage.json to analyse. Repeatable (#637): pass it more than once "
365
+ "to combine several lanes' coverage (e.g. the Python job's and a "
366
+ "real-Postgres RLS lane's) — a line executed in ANY of them counts "
367
+ "as executed. Defaults to a single coverage.json at the repo root."
368
+ ),
369
+ )
370
+ parser.add_argument(
371
+ "--source-root",
372
+ type=Path,
373
+ default=None,
374
+ help=(
375
+ "tree whose source and baseline to judge the coverage against. "
376
+ "Defaults to this script's own repo, which is correct whenever the "
377
+ "coverage was produced from the same checkout. A caller running "
378
+ "this script from one revision against coverage measured on "
379
+ "ANOTHER — the workflow_run gate, which deliberately executes the "
380
+ "default branch's copy of this script — must point it at the "
381
+ "commit under test, or line numbers are looked up in the wrong "
382
+ "revision's AST."
383
+ ),
384
+ )
385
+ args = parser.parse_args()
386
+ coverage_paths: list[Path] = args.coverage if args.coverage else [COVERAGE_JSON]
387
+ source_root: Path = args.source_root.resolve() if args.source_root else REPO_ROOT
388
+ baseline_path = baseline_for(source_root)
389
+
390
+ present = [p for p in coverage_paths if p.is_file()]
391
+ if not present:
392
+ paths_str = ", ".join(str(p) for p in coverage_paths)
393
+ print(
394
+ f"No coverage data at {paths_str}.\nRun: uv run pytest --cov --cov-report=json",
395
+ file=sys.stderr,
396
+ )
397
+ return 2
398
+
399
+ absent = [p for p in coverage_paths if p not in present]
400
+ for p in absent:
401
+ # Not fatal: a repo that has only just adopted a second lane, or is
402
+ # invoked before that lane has produced its artefact yet, still gets an
403
+ # answer from what IS present rather than refusing outright — but the
404
+ # gap must be LOUD, not a line to scroll past. #637 was filed on exactly
405
+ # this shape: "a check that skips an input it cannot evaluate is not
406
+ # neutral — it shrinks its own scope and reports the remainder as the
407
+ # whole" (AGENTS.md §2). A clean result over fewer lanes than requested
408
+ # has to be falsifiable, so this is a `::warning::` GitHub Actions
409
+ # annotation — the estate's own convention for exactly this
410
+ # (scripts/py-dependency-audit.sh, scripts/js-dependency-audit.sh) —
411
+ # not a "note:" that only shows up if someone scrolls the raw log.
412
+ # Printed unconditionally, not gated on running-in-CI: it is equally
413
+ # true, and equally worth seeing, from a terminal.
414
+ print(
415
+ f"::warning::error-branch coverage: requested artefact not found: {p} — "
416
+ f"proceeding with {len(present)}/{len(coverage_paths)} coverage "
417
+ "file(s). This result does NOT reflect that lane's coverage.",
418
+ file=sys.stderr,
419
+ )
420
+
421
+ # Restated in every summary line below (not just the warning above) so the
422
+ # denominator survives even if the warning scrolls past unread: a reader
423
+ # who sees only the final line still learns how much of the requested
424
+ # picture this result is actually over.
425
+ coverage_note = f"{len(present)}/{len(coverage_paths)} coverage artefact(s)"
426
+ if absent:
427
+ coverage_note += f" — MISSING: {', '.join(str(p) for p in absent)}"
428
+
429
+ reports = [json.loads(p.read_text()) for p in present]
430
+ coverage = merge_coverage(reports)
431
+ found = unexecuted(coverage, source_root)
432
+ keys = sorted({b.key() for b in found})
433
+
434
+ if args.write:
435
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
436
+ baseline_path.write_text(
437
+ json.dumps({"total": len(keys), "branches": keys}, indent=2) + "\n",
438
+ )
439
+ print(f"baseline written: {len(keys)} unexecuted error branches")
440
+ return 0
441
+
442
+ baseline = load_baseline(baseline_path)
443
+ if baseline is None:
444
+ # Report, then stop. Loudly, on stderr, naming both commands — a gate
445
+ # that goes quiet without saying so is the fail-open this whole script
446
+ # exists to hunt.
447
+ print(no_baseline_message(baseline_path), file=sys.stderr)
448
+ for branch in sorted(found, key=lambda b: (b.path, b.line)):
449
+ print(f" {branch.path}:{branch.line} [{branch.kind}] {branch.label}")
450
+ print(f"unexecuted error branches: {len(keys)} (no baseline yet; {coverage_note})")
451
+ return 0
452
+
453
+ known = set(baseline.get("branches", []))
454
+ new = [k for k in keys if k not in known]
455
+
456
+ print(
457
+ f"unexecuted error branches: {len(keys)} "
458
+ f"(baseline {baseline.get('total', 0)}; {coverage_note})"
459
+ )
460
+ for branch in sorted(found, key=lambda b: (b.path, b.line)):
461
+ marker = "NEW " if branch.key() not in known else " "
462
+ print(f" {marker}{branch.path}:{branch.line} [{branch.kind}] {branch.label}")
463
+
464
+ if args.check and new:
465
+ print(
466
+ f"\n{len(new)} error branch(es) added with no test exercising them:",
467
+ file=sys.stderr,
468
+ )
469
+ for k in new:
470
+ print(f" {k}", file=sys.stderr)
471
+ print(
472
+ "\nAn unexecuted error branch is unverified — nobody has observed what it does.\n"
473
+ "Either cover it, or run --write to accept it deliberately.",
474
+ file=sys.stderr,
475
+ )
476
+ return 1
477
+
478
+ return 0
479
+
480
+
481
+ if __name__ == "__main__":
482
+ raise SystemExit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.291.2",
3
+ "version": "0.291.3",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",