ADR 0013 — The pytest plugin (pytest-digline)¶
- Status: accepted — the text first, the implementation written against it on
pytest-plugin, the way ADR 0006, ADR 0007, ADR 0008 and ADR 0011 were - Date: 2026-09-10
- Assumes: ADR 0001 §1 (three states, and an error
is neither green nor a regression);
ADR 0002 §8 (a baseline is an
approved reference);
ADR 0006 §5 (the interval is
the baseline's), §8 (the planned call count, announced before the first call);
ADR 0008 §1–§2 (a verdict exists only against an
approved reference; the exit code is the contract);
ADR 0011 §1 (
promoteabsent by construction), §3 (a separate package importing digline as a library), §6–§7 (wire/andhost/, and the gate that keeps a front end from importing another) - Turns into surface:
AGENTS.md§1 (the agent proposes, the human approves), §6 (the exit codes are the contract), §7 (say what a hunt will cost before starting it) - Requires: digline 0.9.0, for
report.check_line(§6). This package ships with that release and never below it - Touches:
CLAUDE.md's Structure section — a third front end joinscli/anddigline-mcpat the top of the dependency chain, under the same rule. Nothing in the fixed section is amended: decisions 3, 6 and 8 are reaffirmed, and §3 below extends ADR 0011 §1's construction to a second front end rather than restating it
Context¶
digline's gate is an exit code. digline run writes a run, digline compare
holds it against the committed baseline, and the process exits 0, 1 or 2.
That is the primary route, it is language-neutral, and nothing here replaces it.
What it is not is a route a Python team already has a habit for. A repository
with an LLM feature in it has a tests/ directory, a CI job that says
pytest -q, and a developer whose muscle memory is pytest -k thing --lf. For
that reader the gate arrives as a second command, in a second place, reporting
in a second vocabulary — and the tool that measures their model's output is the
one thing in their build that does not show up in their test report.
So: a plugin that puts the comparison into pytest's own report, one row per
check. It is a bridge, not a second engine. Every number it shows was
computed by digline.core, every sentence it prints was rendered by
digline.report, and the run it reads was written by the same driver the CLI
uses. If any of that stops being true the plugin has become a fork of the
product with a nicer report, which is the failure this record exists to prevent.
There is a second hazard and it is about money. pytest is a command people run
on a keystroke, in a watcher, in a pre-commit hook, dozens of times an hour. A
plugin that called a provider on collection would turn pytest --collect-only
— a command whose entire purpose is to list test names — into a hundred model
calls. §2 makes that structurally impossible rather than merely discouraged.
§1 — The unit is the check, and there are four states¶
One pytest item per AssertionDelta: one assertion, on one case. Run-scope
verdicts — precision, recall, the per-group aggregates of
ADR 0010 — become items too, carrying no case
and saying so.
eval/suite.py::how-do-i-return::llm_rubric FAILED
eval/suite.py::how-do-i-return::contains .
eval/suite.py::<run>::precision .
eval/suite.py::refund-status SKIPPED
The check and not the case, because the check is digline's own unit of
verdict. AssertionDelta is one assertion on one case; summary_lines emits
one line per regressed or unjudged check; compare counts checks. A per-case
item would have to fold several verdicts into one, and the folding rule would be
invented here — in a front end, where no ADR governs it and no test in
digline.core can see it. Two front ends folding differently is the drift
digline.wire exists to prevent, arriving through the door marked "convenience".
It also loses a fact the trichotomy is built on. A single case can hold a
regression and an error at once. Per case, the item has to pick which one to
report, and picking hides the other — that is exit_code()'s precedence
problem, solved once for a process, re-created at every row of a table.
The states map onto pytest's own, and the fourth is the reason this mapping is better than the exit code's:
| digline | pytest | why |
|---|---|---|
| fine | passed | — |
| worse | FAILED | a statement about behaviour that got worse |
| could not judge | ERROR | ADR 0001 §1: an error is neither green nor a regression, and pytest has a state for "this never ran" |
| suspended | SKIPPED, with the reason | a decision somebody made, not an outcome |
A suspension is the one digline state the exit code cannot express — it never
fails, so it disappears into 0 — and pytest has carried exactly that idea
since it was written. SKIPPED [1] eval/suite.py::refund-status: the refund API
is down, ticket 412 shows the set-aside case, in its own state, with the
mandatory reason attached, where no reader can mistake it for a check that
passed.
Which delta is which is not re-derived here. "Unjudged" is not
outcome == "errored": rule 1 of compare() classifies an absent counterpart
as new before rule 2 can call it errored, so a case added today that fails
on its first day is new and errored at once. report._summarized() already
holds the correct rule, and the plugin applies it per row in the same order
exit_code() applies it per run:
d.outcome == "regressed" -> FAILED
d.current is not None and d.current.status == "error" -> ERROR
otherwise -> passed
Regression is tested first, so a regression outranks an unjudged check here for
the reason it outranks one there: the louder fact has to be the one reported.
Note that compare() cannot in fact produce both — rule 2 classifies an errored
verdict before rule 3 can call it a regression — so the ordering is defensive.
It is written in that order anyway, because the day a rule changes upstream this
front end should agree with exit_code() by construction rather than by luck.
One counting consequence, stated because a reader will meet it. Headline
counts unjudged cases; these rows are checks. A case with two errored
checks reads as "1 case could not be judged" beside two Es. Both are right and
the documentation says so rather than leaving somebody to reconcile them.
§2 — Compare-only by default; running is typed, announced, and impossible under --collect-only¶
Comparing costs nothing: two documents read off disk and a pure function. Running costs money. So the default is the free half.
Default: compare the suite's latest stored run against its committed
baseline. No provider is called, nothing is written, and the invocation is
repeatable in a watcher. The pytest run is the gate; producing the run stays a
separate act — digline run, the image, the Action.
Opt-in: --digline-run runs the suite first and compares afterwards. Three
constraints on it, and the third is the one that matters:
- it is a command-line flag, so the cost is visible in the line somebody typed and in the CI log that recorded it;
- it prints
planned_calls(suite).sentence()on stderr before the first call, which is AGENTS §7 and ADR 0006 §8 unchanged — the multiplication is the part that surprises people; - under
--collect-onlyit refuses, with a message, rather than running. A command whose entire purpose is to list test names must never be able to spend a hundred model calls, and the refusal is a gate rather than a convention because the person who types--coafter--digline-runis always doing it to find out what would happen.
A marker was the obvious alternative and is rejected in §10: a marker is per test where the run is per suite, and a marker that spends money hides the cost in a source file instead of in the command line.
acknowledge_calls, ADR 0011 §2's gate, is deliberately not copied here.
It exists because an agent typed nothing; a person who typed --digline-run
typed it. The stderr sentence stays mandatory either way.
When there is no stored run at all, collection fails with the host's own sentence — "no runs stored for suite 'support' in tenant 'northwind', so there is no latest one. Run it first." — because that is already the right answer, in words this project already owns.
§3 — No promote surface, absent by construction¶
There is no flag, no fixture, no marker and no ini option that promotes a
baseline. Not refused, not gated: absent, exactly as promote is absent
from the MCP server (ADR 0011 §1), and for the same reason. A refusal is a
conversation — it can be argued with, retried, worked around — and an absence is
not.
The construction is checked rather than asserted: an AST sweep over the
package's sources fails on any reference to promote_baseline, in the shape
tests/test_layering.py already uses to keep a front end from importing
another. A baseline is an approved reference (ADR 0002 §8) and promote writes
into .digline/<tenant>/baselines/, which is committed. Anything landing there
arrives in somebody's diff and must arrive because they put it there — and a
green pytest run is the single most likely place for a promotion to be
performed by accident, because "the tests pass" is the sentence people act on
without reading.
§4 — The committed baseline is the only reference¶
The plugin has no thresholds of its own, no tolerances of its own, and no configuration beyond pointing at the suite. Every bar it reports against was declared in the suite and frozen in the baseline somebody promoted.
This is the line that separates digline from the pytest-native tools it will sit
beside. Where a threshold lives in the test file, the test file is the reference
and it moves when somebody edits it; here the reference is a document that was
approved, is versioned, and appears in a diff when it changes. A plugin that
grew a --digline-threshold would dissolve that in one flag, and it would
dissolve it in the place where it is least visible — a CI invocation line.
Fixed decision 3 applies unchanged and is worth restating in this context: no vacuously green assertion. A plugin invoked with no suite collects nothing (§8), and collecting nothing is pytest's exit code 5, not a pass.
§5 — Discovery is naming, never convention¶
The plugin does not hook pytest_collect_file on files called suite.py,
and does not walk the tree looking for suites. digline.host.loader's own
record settles it — "it never goes looking: a file found by convention is a
file that runs by accident" — and it settles it harder here than in the CLI: a
suite is a file that executes, and pytest is a tool that recursively visits
directories a user did not enumerate.
Two ways to name one, both explicit, the command line winning:
pytest --digline-suite eval/suite.py # repeatable
[pytest] digline_suites = eval/suite.py # an ini option, in pytest's own config
An ini option rather than a [tool.digline] block, because it is pytest
configuration: it lives where the rest of this project's pytest settings live,
the plugin owns it through parser.addini, and a reader who knows pytest knows
where to look. It is also the whole of the configuration §4 permits — pointing
at the suite — and it is what makes pytest with no arguments gate an
already-configured repository.
--digline-root names the perimeter and defaults to pytest's rootdir. It is
--root and it means what --root means.
No fixture and no marker in this version. The collector is the way in. A
fixture for folding a comparison into an existing test — assert_no_regression,
the shape a Python reader will expect from elsewhere — is a second way in, and
a second way in before anybody has asked for one is a surface invented ahead of
its use. When somebody asks, it is additive and this record gets an amendment.
§6 — check_line: one rendering of the truth, promoted out of the report¶
A failing row has to say what moved, and the sentence that says it already
exists: report.render._detail() fills both the report's "what happened" column
and compare's summary lines, which is why a terminal and a document cannot
describe one delta two ways. It is private, and summary_lines() emits only the
whole list, joined and truncated.
So digline.report gains one public function:
and summary_lines() is re-expressed over it. This is a refactor, not a new
rendering: the string is the one that was already being produced, given a name
so a third front end can ask for it instead of writing its own. The alternative
— the plugin composing its own sentence out of delta.current.score,
delta.threshold and the noise interval — is a fourth prose rendering of one
comparison with nothing binding it to the other three, and the first divergence
would be found by a user reading two descriptions of one regression.
It is dated in tests/test_plugin_floors.py's INTRODUCED at 0.9.0, which
sets this package's floor at digline>=0.9.0. Nothing else in the core moves.
The failure message is that line, the case and check that own it, and the judge's own words:
digline: how-do-i-return · llm_rubric
0.910 → 0.640 (threshold 0.700), outside the baseline's
0.880–0.950 across 5 samples
reason: signed=True, concise=False
and no traceback. There is no Python frame worth showing: the exception was
raised by this plugin, one line from where the message was composed, and a
stack trace through _pytest/runner.py tells a reader nothing about a rubric
score. That is not free, and the mechanic is recorded here because it is not
discoverable from the documentation: Item.repr_failure is consulted only for
the call phase (_pytest/reports.py:_format_failed_longrepr), so the ERROR
rows of §1 — which must be raised in setup() to be errors at all — would print
a full internal traceback. The plugin therefore also implements
pytest_runtest_makereport as a wrapper and replaces report.longrepr for its
own items. Both halves are needed; either alone leaves one of the two states
printing a stack trace.
The headline sentence is printed once, in pytest_terminal_summary, not
stamped on every failing row. It is a statement about the run — "3 checks got
worse, 1 case could not be judged, the rules are unchanged" — and forty copies
of one sentence is forty lines a reader learns to skip, taking the row detail
beside it. Same sentence as compare's and the report's, from headline(),
because a gate and a document must never say two different things about one run.
§7 — What pytest's exit code cannot carry, said out loud¶
AGENTS §6 calls the exit codes the contract: 0 proceed, 1 stop and report
what got worse, 2 stop, nothing downstream is meaningful. pytest cannot
carry that distinction, and pretending otherwise would be worse than losing
it. A pytest run with only errors exits 1, exactly as a run with only failures
does; the process code says "something was not green" and nothing finer.
So the trichotomy survives where it can and the loss is documented where it cannot:
- in the report it survives —
FandEare different characters, they are counted separately,-rElists the errored rows on their own, and every machine-readable pytest output (--junit-xml,--report-log) keeps failure and error apart; - in the process exit code it does not, and a job that needs
1versus2runsdigline compare, which is one command away and is the contract.
Stating this is the whole of the section. A gate built on a distinction the runner erases is a gate that will one day treat "could not be judged" as "got worse" and act on it, and the person who built it will have had no way to know.
§8 — Inert when no suite is named¶
Installing this package must change nothing about a repository that has not
asked for it. With no --digline-suite and no digline_suites, the plugin
collects nothing, prints nothing, adds no header line and registers no hooks
that do work.
This is not politeness, it is self-defence: uv sync --all-packages installs
every workspace member into this repository's own development environment, so
the pytest11 entry point is active for digline's own test suite. The
inert-when-unconfigured rule is what stops a plugin under development from
changing the result of the gates that judge it, and it is the first test
written rather than the last.
§9 — A separate package, and what it depends on¶
packages/pytest-digline/, a workspace member, importing digline as a library —
ADR 0011 §3's shape, for its reasons: a front end is not part of the core, and a
core that shipped a pytest integration would put pytest in the dependency tree
of everyone who does not use one.
- distribution
pytest-digline, modulepytest_digline. The module name is forced, not chosen:.github/dist_manifest.pyderives the post-publish import check from the wheel name with-→_, so a package whose top-level module is named anything else is published and never verified — the failure that looks like success, arriving through the automation that removed the hardcoded list it replaced; - entry point
[project.entry-points.pytest11], nameddigline, so-p no:diglineis what a user types to switch it off; digline>=0.9.0(§6),pytest>=8.0, and nothing else. No provider SDK, no third-party helper. The floor on pytest is verified against a real 8.0 environment rather than asserted, because a floor is a promise about a version that is not present — which is precisely whattest_plugin_floors.pyexists to stop us making on trust;- no upper cap on pytest, unlike
digline-mcp'smcp<3. That cap records a rename that actually happened; pytest has not movedpytest11in a decade, and a cap here would fight the one dependency the user certainly already has; - it imports
digline.host,digline.coreanddigline.report, and neverdigline.cli—tests/test_layering.pyalready sweepspackages/for that and needs no amendment to cover it.
§10 — Alternatives rejected¶
One item per suite. A single pass/fail/error for a whole comparison. This is
digline compare with a pytest-shaped wrapper around it: it adds a dependency
and a plugin and buys nothing the exit code does not already give, while
discarding the rows that are the only reason to cross the bridge. If the answer
is one character, the command that produces it should be the one that already
does.
One item per case. Rejected in §1: it invents a folding rule in a front end and it hides one of two simultaneous states.
A marker — @pytest.mark.digline. Rejected in §2. The run is per suite and
a marker is per test; and a marker that spends money puts the cost in a source
file, where a reader of the CI log cannot see it and a person adding a case
cannot feel it.
A fixture instead of a collector. Deferred, not rejected — §5. The collector
gives -k, --lf, the node id and the four states for free; a fixture gives an
assertion inside a test somebody else wrote, and reaches none of them.
Reading compare --json by subprocess. Rejected: it would make the plugin a
consumer of the CLI's stdout, which is the coupling ADR 0011 §7 drew wire/ and
host/ to remove. The plugin imports the same functions the CLI imports.
Promoting on green, behind a flag. Rejected without qualification — §3, and AGENTS §1 and §2 are the reasoning. The first green run is green partly on merit and partly on luck, and a plugin that promoted on green would launder exactly that run into the reference, on the invocation where nobody is reading.
Rendering the failure line inside the plugin. Rejected in §6: a fourth prose rendering of one comparison, bound to the other three by nothing.
§11 — Test plan¶
Beyond a failing case for every rule, which the conventions already require.
Driven by pytester, so each one is a real pytest run:
Inert when unconfigured, first. A pytest run with the plugin installed and no suite named collects the same items, prints the same header and exits the same code as one without it. This is §8 and it is the test that protects every other test in this repository.
--collect-only never calls a provider. With --digline-run and
--collect-only, the invocation refuses and the target is never called — proved
with a target that fails the test if it is called at all, not by counting calls
after the fact. §2's third constraint.
The four states, from one fixture comparison. A suite whose comparison
carries a regression, an errored check, a suspended case and several fine ones
yields exactly one F, one E, one S and the rest passed — asserted through
RunResult.assert_outcomes, which distinguishes failed from errors.
An error is never a regression. A check that was passing and now cannot be
judged is one ERROR and no FAILED. Rule 2 of compare() runs before rule 3,
so a delta cannot be both regressed and errored and the ordering inside
Check.setup() is defensive rather than reachable — which is worth knowing
before somebody writes a test that cannot fail. This is the reachable half, and
it is the test to read first if the mapping is ever doubted.
new and errored is unjudged. A case added since the baseline whose check
errors is an E, not a pass — the trap _summarized() documents, checked here
because a per-row mapping is exactly where somebody would write
outcome == "errored" and be wrong.
No traceback, in both states. The failure text of the F row and of the E
row each contain check_line's sentence and neither contains _pytest or
Traceback. The E half is the one that fails without the
pytest_runtest_makereport wrapper, and it is written so that removing the
wrapper turns it red.
One headline, once. The headline sentence appears exactly once in the
terminal output of a run with several failures, and it is byte-for-byte
headline(...).sentence for the same comparison.
The line is the report's. A row's message is byte-for-byte
check_line(delta, locale=...) for that delta, and the same delta's line inside
summary_lines() — the check that would catch §6's refactor growing a second
rendering.
No promote surface. The AST sweep of §3, plus a run that goes green leaving the baseline file's digest unchanged.
Exit codes, and the one that is lost. A regressed-only run and an
errored-only run both exit 1 from pytest and are asserted to do so — the test
that records §7 as a known property rather than leaving it to be discovered.
Consequences¶
A third front end exists, and the rule that governs it is already written.
cli/, digline-mcp and now pytest-digline sit at the top of the dependency
chain, import host/, wire/ and report/, and import nothing of each
other's. tests/test_layering.py covers the newcomer without amendment, which
is the sign ADR 0011 §7 drew the layer in the right place.
report.check_line is public and can never quietly change. It is now on
three renderings' critical path, and the test that binds a row to a summary line
is what will notice.
A repository can gate on digline without learning a second command, and the cost of that is one more package with a version line, a floor to keep honest and a pending publisher to register. That trade is the whole of the adoption release.
Somebody will want to promote from pytest and will not be able to. That is §3, it is deliberate, and the message they get points at the command that does it — which is a person's command, run by a person who read the comparison.
The suspended state gets a home it never had. The exit code cannot express a set-aside case; pytest can, and does. It is the first thing this bridge carries that the primary route cannot.