The Coverage Floor¶
What was re-checked on 27 September 2026, and what was not
The CI, ruleset and environment claims on this page were re-checked on
27 September 2026 against a7fe76f, 0143ea2 and 3c44b9e: which workflows run
the suites, on which triggers, which checks each branch requires, and what each
deployment environment demands. The floor has been blocking on acc in all three
since 19 September 2026.
The percentages and file counts below were not re-measured on that date. They
were measured on 12 September 2026 against f5bae6a, be6bc54 and 311d732, the
RONL Business API's by running all five suites with coverage rather than reading a
record. They are a dated measurement, and this note is the honest version of that.
A per-file 80% branch-coverage floor, and what it took to enforce it in three repositories
All three application repositories hold new and changed code to 80% branch coverage per file. As of September 2026 all three enforce it natively, in their test runners' own configuration, with no exemptions and no custom script.
That uniformity is recent, and the route each took is more instructive than the destination.
Why per file, and why branches¶
Per file, because a project average lets a well-tested utility pay for an untested component. The branches that matter are precisely the ones nobody has exercised, and an average is designed to hide them.
Branches, because statement and line coverage largely restate "was this file imported", and function coverage rewards splitting code into more functions. A branch is a decision the code makes; an uncovered branch is a decision no test has ever checked.
| Repository | Mechanism | State |
|---|---|---|
CPSV Editor (ttl-editor) |
Native thresholds in one runner | Clean, no exemptions |
| Linked Data Explorer | Native thresholds in both runners | Clean, no exemptions |
| RONL Business API | Native thresholds in all five runners | Clean, no exemptions |
A functions floor is a separate decision¶
80% functions is not a safe companion setting, and this was measured rather than assumed. In RONL Business API at the time the branch floor landed, adding a functions floor at the same number would have failed 31 files:
| Workspace | Files below 80% functions |
|---|---|
| frontend | 11 |
| pa-cockpit | 10 |
| pa-demo | 7 |
| public-site | 3 |
| backend | 0 |
public-site/TopBar.tsx is the illustration: 100% branches, 66% functions.
The two measure different things and are not interchangeable.
That count is the one taken when the floor landed, and it is the count the five runner configs still carry in their comments. Re-measured at v2026.09.9 on 20 September 2026 it is 26 — the work since has closed five of them, without anyone setting out to. The current figure, per workspace, is on the application's own coverage page; where the two disagree, the measured one wins.
The asymmetry runs both ways. In the CPSV Editor, ConceptsTab.jsx reads 80.55%
on branches but 71.62% on statements and 63.33% on functions, and App.jsx
reads 81.48% / 71.65% / 53.70% — the uncovered code there is largely
branch-free, whole handlers no test calls, so a branch floor steps straight over
it. Worth knowing what the column you gate on does not see.
Why one repository needed a script first¶
Vitest cannot express a partial rollout of this policy. Its thresholds
block accepts glob keys that look like per-file overrides, but they are
additive rather than overriding — from Vitest's own source, "Global
threshold is for all files, even if they are included by glob patterns". A file
matching 'src/App.jsx': { branches: 34 } is still measured against the global 80
as well, and the build fails anyway. perFile: true is all-or-nothing.
The CPSV Editor was not at 80% everywhere, so it carried
scripts/check-branch-coverage.mjs — a DEBT list working as a ratchet that
tightened from both ends:
| Condition | Result |
|---|---|
| Below its pin | Fail — a regression |
| More than 10 points above its pin | Fail, asking for the pin to be raised |
| At or above the floor | Fail, asking to be deleted |
| Naming a file that no longer exists | Fail |
The script's own header stated it was temporary and named its own deletion as the
last step of the work rather than an afterthought. That is what closed
ttl-editor#103: DMNTab.jsx,
1855 lines and the largest file in the repository, went from 45.73% to 98.34%
branch coverage, the last DEBT entry went with it, and the script was deleted in
favour of four lines of config.
Three things from that run are worth carrying:
- The ratchet's upper bound fired for real, once. An increment took
DMNTab.jsxpast its pin by more than the slack allowed, and the gate failed naming the new value. A ratchet that only catches regressions decays into an exemption list — this one did not. - Measure in isolation and in the full suite. Both readings were identical, which is what proves no other file's tests were propping the number up. A per-file floor read only from a full run cannot tell the difference.
- The last few branches are usually unreachable, and that is the honest place to
stop. Seven remain, all guards the UI cannot reach —
if (!uploadedFile)under a button that only renders once a file exists, and three of the same shape.
Reaching the floor has a load cost, and it lands somewhere else¶
Not a threshold mechanic, but it surfaced on the same change and would surface in any repository pushed to this floor.
Bringing one file to 80% meant 56 new tests, 680 → 736. The suite then began
failing intermittently, in unrelated files — a different one each run, always
passing in isolation. Not a defect in the new tests and not one in the old:
Testing Library's findBy* gives up after one second by default, and under
coverage instrumentation with every file running in parallel, a control that
appears in tens of milliseconds on an idle machine can take longer than that on a
saturated one.
Measured rather than assumed, which is the only way to tell contention from a real order dependency: three consecutive full runs clean with the new files moved aside, one failure in three with them present.
Fixed at its own boundary — asyncUtilTimeout: 5000 and testTimeout: 15000,
both commented as contention headroom. Not by serialising the suite, which
would diverge from CI, cost real time on every run, and hide the order
dependencies parallelism is good at exposing. Raising a wait is not a defect mask:
an element that is genuinely never rendered still fails, only later.
The same shape at a much smaller dose. The Linked Data Explorer added 31 tests
(1042 → 1073) and saw exactly one parallel-only failure: a ShaclValidator test
timing out at the 5000 ms default in a full run, passing 37 of 37 in isolation,
in a file the change had not touched. It did not recur, and no timeout was raised
for it. Two readings, both worth carrying:
- The effect scales with how loaded the run is, not with how many tests you added. 31 was enough to surface it once.
- A parallel-only failure is not a finding until it fails in isolation. That one command is what separates contention from a real order dependency, and changing code on the strength of a full-run failure alone is how a healthy suite acquires defensive edits it never needed.
Raising coverage without writing hollow tests¶
Writing tests to raise a number is the failure mode this whole policy exists to avoid. Three mechanics kept the Linked Data Explorer's twelve-file push honest, and all three transfer.
Mutation-check every test — one written after the code cannot fail on its own merits¶
A test written against code that already exists passes on its first run, which
proves nothing about whether it can fail. So each new test had the branch it
targets deliberately broken in the production file, and had to fail before being
kept — then the file was restored. Cheap to automate: a shell loop over sed
one-liners, one file-scoped run each.
That caught five of the thirty-one passing vacuously, which would otherwise have shipped as coverage with nothing behind it:
- Two guard tests used a response fixture with no
datafield at all, sodata ?? []and the realsuccess && Array.isArray(data)guard behaved identically. The fixture had to carry a payload that survives the guard's removal before the test could fail. - Four component tests asserted "nothing was added" / "nothing was saved" — which stays true when the handler throws partway through.
On any React codebase, \"nothing happened\" is not a safe assertion
React surfaces an error thrown inside a click handler on window's error
event rather than rejecting the click, so an assertion on the DOM sees a
successful no-op either way. The fix is a listener around the interaction that
fails the test if anything was raised.
This is the transferable one. A test that asserts an absence needs something watching for the throw, or it passes for the wrong reason.
Some branches are unreachable, and leaving them is the honest move¶
Three guards sat behind a submit button already disabled on exactly the same
condition — filename.trim() || chainName under disabled={!filename.trim()}.
Covering them would mean invoking the handler directly, which tests nothing a user
can do, and is why two files stop at 98.08% and 94.44% rather than 100%.
This is the same finding the CPSV Editor recorded about DMNTab.jsx's seven
remaining guards — reached independently, in a different codebase and a different
framework. Taken together they suggest a rule: a per-file floor in the high
nineties is usually the ceiling, and the last few points are dead code asking to be
documented rather than tested.
Say which mutations a test does not catch¶
Two branches are covered but behaviour-preserving: a pair of
if (!templates) return null guards whose removal only produces a throw the
surrounding try/catch already swallows, and a ?? '' feeding an Array.join
that coerces undefined anyway. Their mutations survive by construction. They are
worth keeping — they assert the returned contract — but a reader deserves to know
which mutations they do not catch, and the comments say so rather than implying
more.
One file deliberately left at the bottom¶
GraphView.tsx stays at 82.26% — 51 of 62 branches. The eleven uncovered ones
are inside d3's force-simulation tick and drag handlers: d.x || 0 fallbacks that
need a node at the origin, and if (!event.active) guards that need synthesised
D3DragEvents. Reaching them means standing up a d3 harness and asserting on d3's
mechanics rather than on the component.
It has one branch of slack, and that is worth stating numerically rather than as a feeling: a twelfth uncovered branch still reads 80.95% and passes; the thirteenth fails. The config comment records it, so whoever next meets the floor there knows the answer is to test their new branch rather than lower the threshold.
Runner mechanics¶
- Jest takes a glob key (
'./src/**/*.ts'), which it applies to each matching file individually. - Vitest takes
thresholds: { branches: 80, perFile: true }. It reports "global threshold" in its failure message even in per-file mode — that is its wording, not a misconfiguration. Naming the file rather than reporting the package average is what demonstrates per-file behaviour.
How to verify a threshold actually bites¶
Do not trust a green run. Add a temporary file with a few uncovered branches and confirm both that the run fails and that it names the file:
Jest: ".../src/__threshold-probe.ts" coverage threshold for branches (80%) not met: 0%
Vitest: ERROR: Coverage for branches (0%) does not meet global threshold (80%) for src/__threshold-probe.ts
Naming the file is the part that matters — it proves the threshold is per-file rather than being satisfied by a healthy package average. The CPSV Editor proved its native threshold the equivalent way, by raising it to 99 and watching it name eight files.
Margins differ sharply, and "clean" means different things¶
All three were measured clean before enforcing. That word hides a lot:
| Files measured | Lowest branch coverage | |
|---|---|---|
| RONL Business API backend | not derived | comfortable — 92.31% across the package |
| Linked Data Explorer backend | 38 of 44 | sparql.service.ts 82.85% |
| Linked Data Explorer frontend | 68 of 78 | ChainBuilder/TestCasePanel.tsx exactly 80.00% — since raised to 100% |
| CPSV Editor | 32 of 41 | useDsoImport.js 80.39% — since raised to 92.15% |
The RONL Business API's five workspaces were re-measured on 12 September 2026 and
every one passes the per-file floor, with these package averages on branches: backend
92.31%, frontend 89.78%, pa-cockpit 88.52%, pa-demo 95.65%,
public-site 96.41%. A package average is the number this page exists to distrust,
so read them only as how much room the package has, not as evidence any file is
covered; the floor is what speaks per file, and it passed in all five runners.
"Files measured" counts files carrying at least one branch — 68 of the 78 in
the Linked Data Explorer's frontend report, counted from coverage-final.json
rather than from the printed table, which elides rows. A file with no branches
cannot fail a branch threshold, so including it inflates the denominator without
telling you anything.
The Linked Data Explorer rows were re-measured at v2026.09.4. The frontend reads
69 of 78, one more file carrying a branch, with GraphView.tsx at 82.26% the
only one under 85%. The backend reads 38 of 44, and its lowest file has not
moved. That backend row said 49 until 11 September 2026, copied from the comment
jest.config.js carries — "measured clean when this landed — 49 files" — and 49
is neither 38 nor the 44 files the report contains at all. It is the same class of
figure as the frontend's 64: quoted from a record rather than recounted under the
rule this table states. The CPSV Editor's row said 41, which is the number of files
in its report; 32 of them carry a branch. Its lowest file has not moved.
This table named a file that does not exist, and the error travelled
Until 9 September 2026 the Linked Data Explorer row here read
CaseworkerCasePanel.tsx. No such file has ever existed in that
repository — the one sitting at exactly 80.00% was
ChainBuilder/TestCasePanel.tsx. The wrong name reached a commit message, a
config comment and a cross-repository record before anyone checked whether the
file was real, and this page copied it from there rather than verifying it.
The count was wrong too: 64 is not reproducible from any report, which is why the rule is now stated rather than the number quoted.
Worth keeping as a caution about this page's own method. A margins table reads like measurement, so it invites transcription — but only the percentages had been measured, and the file names beside them had not.
Thresholds pass at >= 80, so a file at exactly 80.00% is green with zero
margin — the first uncovered branch added to it turns CI red on an otherwise
unrelated change.
The Linked Data Explorer closed that gap in v2026.09.2: twelve files raised,
package branches 90.59% → 92.88%, files under 85% from fourteen to one, tests
1042 → 1073. No production code changed — test files only, plus the config
comment. The CPSV Editor has not yet, and arrived at the same position by a
different route: its three lowest files — useDsoImport.js 80.39%,
ConceptsTab.jsx 80.55%, ChangelogTab.jsx 80.70% — are each one uncovered
branch from failing, and the ratchet's pins that used to absorb a slip are gone.
The first uncovered branch added to any of them turns CI red on an otherwise unrelated change. That is the floor working as designed, but it is worth meeting in a config comment rather than in a surprising failure.
A floor only gates where the tests run before the merge¶
A threshold enforced after a merge is a report, not a gate. This is where the three diverge most:
| Repository | Tests on a pull request |
|---|---|
| CPSV Editor | ✅ both Static Web Apps workflows run npm run test:ci on push and pull_request — and the acc one is a required check since 19 September 2026 |
| Linked Data Explorer | ✅ backend and frontend, acc workflows — all three acc deploy checks required since 19 September 2026 |
| RONL Business API | ✅ all five workspaces since v2026.09.7 — the four acc build and deploy checks required since 19 September 2026 |
All three now run their suites before the merge. The RONL Business API was the last
to close that gap, and it had two halves. Its backend workflow triggered on push
alone, so its suite — 2008 tests at the time — ran only after a merge and its backend
branch threshold gated nothing on a pull request
(#87). And @ronl/pa-cockpit,
a library with no deploy workflow of its own, ran nowhere in CI at all — its 476
tests were measured only on a developer's machine; both frontend workflows now run its
suite, placed before the frontend's own because the frontend imports it.
The backend trigger proved itself on the pull request that carried it: an axios 1.18
security bump broke the backend build there, with 1859 tests passing and one suite
failing to compile on a widened header type. Before the trigger existed, that lands on
acc.
The Linked Data Explorer had the same gap and closed it in v2026.09.2: its backend suite — 1151 tests at v2026.09.4 — now runs on the pull request rather than only after the merge. It is the worked example, because it had already let a genuine defect sit on a pushed branch for days — no pull request ever ran the test that caught it.
Running before the merge became blocking it, on 19 September 2026
The floor is now enforced at the merge on acc in all three. Each repository's
acc ruleset requires the build and deploy checks alongside audit and scan,
and those jobs run the suites — coverage thresholds included — before they build.
A pull request that drops a file below 80% turns a required check red, and the
merge button goes with it — for a source change. In the RONL Business API a
lockfile-only pull request still runs only the backend's suite: the three site
workflows' changes patterns do not name the root lockfile.
Until that date this said the opposite, correctly: the workflows that enforce the
floor were named by no ruleset, so a sub-80% file stopped the deploy and left the
merge button available. The obstacle was mechanical rather than political — a
required check must report on every pull request, and these workflows were filtered
at their trigger, so a pull request touching nothing they watched reported nothing
and would have hung forever. Moving the filter into a changes job, whose skip
reports success, is what made the ruleset edit possible; see
how a path-filtered workflow became requireable.
On main the old sentence still stands. No main ruleset in any of the three
names a build or a test, so a promotion carrying a coverage regression is stopped by
the deploy, not by the merge.
The fix is not identical, and the difference matters before copying one into the other
The Linked Data Explorer's backend workflow deploys to Azure, so its
pull_request trigger had to come with five deploy-side steps gated on the
event — arranged as per-step conditions rather than a job split, so the check
name stays stable and no ruleset entry changes.
RONL Business API's backend workflow did not deploy when its trigger landed in v2026.09.7 — it ended at an uploaded artifact, so the change was the trigger alone. Since v2026.09.11 it deploys too, over OIDC, and its four deploy-side steps are gated on the event the same way: the two workflows have converged.
A package outside the measurement is not covered — it is unmeasured
@ronl/shared has no test runner, deliberately: it holds types, constant data and
re-exports, and there is nothing there to test. The consequence is sharper than an
exemption, though. A function placed in that package is not under-tested; it is
outside the measurement entirely, so no run fails and no reviewer sees a number
move. That happened once — v2026.09.4 moved a branching label helper out of the
package for exactly that reason, and it was caught by hand, by someone who happened
to look.
v2026.09.7 replaced the convention with a check
(#84): check-shared fails
the audit job on a function, class, conditional or loop anywhere in the package.
It parses with the TypeScript compiler API rather than matching text, because an
arrow in an interface is a type and the same syntax assigned to a const is logic,
and no regular expression separates the two. It runs in audit rather than a
workspace suite because audit has no paths filter and is the required check — so
every pull request reaches it, including the one that first adds a function to a
package no filter yet watches.
The general rule this leaves: where a package cannot be measured, enforce what makes it unmeasurable rather than measuring nothing and calling it covered.
Production backend workflows are deliberately excluded from that treatment.
main is promoted from acc, and those commits already ran the full suite on their
own acc pull requests; a second pre-merge run would re-test the same commits. The
Linked Data Explorer used to give a heavier reason first — its production
environment required a reviewer, so a pull_request trigger would have put a human
approval in front of the tests — and that reviewer was removed on 24 September 2026
(#210). Both repositories
now exclude it on the promotion argument alone. The Linked Data Explorer's two
production site workflows do build on a promotion pull request, as a preview.
Adopting this in a fourth application¶
- Measure before enforcing. Native thresholds are all-or-nothing per file. A repository not yet at 80% everywhere needs the ratchet approach instead — and should treat that script as temporary from the day it is written. The CPSV Editor's carried one file and was deleted with it, which is the intended lifespan.
- Gate on branches only until a functions floor has been measured separately. It is not a free companion setting.
- Check where the tests actually run. A floor enforced only after the merge is retrospective.
- Prove the threshold by making it fail, and confirm the failure names the file.
- Expect a load cost when a large file is brought up, and fix it at its own boundary rather than by serialising the suite.
Related: Supply-Chain Pinning for what the audit job gates,
and Build Provenance for identifying which build a running
app is.