@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.
- package/_skeletons/plugin-template/.github/workflows/ci.yml +18 -1
- package/_skeletons/plugin-template/_gitignore +10 -0
- package/_skeletons/plugin-template/pyproject.toml +12 -1
- package/_skeletons/plugin-template/scripts/error_branch_coverage.py +482 -0
- package/_skeletons/sibling-template/.github/workflows/ci.yml +25 -1
- package/_skeletons/sibling-template/_gitignore +8 -1
- package/_skeletons/sibling-template/scripts/error_branch_coverage.py +482 -0
- package/package.json +1 -1
|
@@ -67,7 +67,24 @@ jobs:
|
|
|
67
67
|
with:
|
|
68
68
|
enable-cache: true
|
|
69
69
|
- run: uv sync --all-groups
|
|
70
|
-
-
|
|
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
|
-
|
|
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())
|