Skip to content

emrg: take the suite verdict from the suite's report, not from exit code 1 - #1204

Merged
argszero merged 2 commits into
masterfrom
feature/suite-verdict-needs-evidence
Sep 13, 2026
Merged

argszero merged 2 commits into
masterfrom
feature/suite-verdict-needs-evidence

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this changes

_suite_verdict in scripts/check-merge-plan-suite.py read rc == 1 as "the suite ran and failed".
A pytest that never started exits 1 as well:

$ python3 scripts/check-merge-plan-suite.py --base d541311b 1199      # host python3, no pytest
suite FAILED: suite FAILED (no per-test line in the output)           # exit 1 - a finding
$ uv run --no-sync python3 scripts/check-merge-plan-suite.py --base d541311b 1199
final tree 68e52648ad9d
suite OK: 1821 passed, 2 skipped                                      # the tree was fine

So the first reading was a health finding about a tree no test ever ran on. It is the one misreading a
caller cannot see: it looks exactly like a finding. It cost this cycle its first measurement of #1199's
landing tree.

The tool's own exit-code table already promises the right answer — 2 the question could not be answered
(… or the suite could not be run at all)
. The code just never delivered it for this case, because a
missing pytest does not use the rc (2/3/4/5) that branch was written for.

The rule

A red tree must be evidenced by the suite's own report. Measured on this machine with the tool's own
SUITE invocation (pytest -q --no-header, 1824-test repo and a two-test fixture repo):

run rc what the report carries
a failing test 1 FAILED <nodeid> - assert 1 == 2 and 1 failed, 1 passed in 0.01s
an error in a fixture teardown 1 ERROR <nodeid> - RuntimeError: teardown and 3 passed, 1 error in 0.01s
no pytest 1 No module named pytest — neither form

SUITE_FAILURE therefore accepts both measured forms, and the summary one is matched anywhere in the
line
: the teardown shape does not start with the count, and anchoring it to the first word would turn a
real failure into "could not measure". rc == 1 with neither form is a MeasurementError (exit 2).

The remedy names the concrete invocation (uv run --no-sync python3 scripts/check-merge-plan-suite.py <PR> …) and both causes, because the same text also comes from an unsynced worktree whose .venv is
empty — measured in check-doc-count.py (cyc20260913-122923), where the documented invocation failed
byte-identically and "use the project interpreter" sent the reader in a circle.

Tests (5, both directions)

  • test_an_interpreter_without_pytest_is_not_a_red_tree — real subprocess (-m <missing module>): exit 2,
    no suite FAILED on stdout, the remedy's invocation on stderr; same plan with a runnable suite → exit 0.
  • test_a_pytest_missing_from_the_interpreter_names_both_causes — the exact No module named pytest text:
    both causes named.
  • test_a_failure_the_report_names_is_still_the_finding — parametrized over both measured failure shapes;
    the guard must not swallow what it exists to deliver, and the report is quoted, not replaced.
  • test_a_real_failing_suite_is_reported_with_its_node_id — a real pytest run on a real red branch, so the
    rule is pinned against pytest itself and not only against simulated strings.

They are placed beside the existing test_a_suite_that_cannot_run_is_not_reported_healthy (the rc=4 arm)
rather than appended to the end of the file: #1199 appends there, and this keeps both PRs mergeable in
either order.

Verification

  • Full suite on the branch: 1823 passed, 1 skipped (master collects 1819, this branch 1824).
  • 4/4 mutants killed — remove the guard; drop the summary form from the predicate; make every rc 1 a
    measurement error; stop naming the project environment in the remedy. Each restore was checked by sha256.
  • End to end: the bare-python3 invocation above now exits 2 with the remedy; the documented invocation
    still reports suite OK.
  • import emrg.client.app, emrg --help, scripts/check-doc-count.py all green. No workflow changes, so
    nothing for actionlint.

Not done here (recorded, needs its own measurement)

check-merge-tree-health.py reads rc == 1 from the doc-count guard as "guard FAIL" and uses its report
regex for the detail only, so a guard that crashed would read as a violation of the tree.
check-merge-sequence.py already requires COUNT_IN_REPORT for rc 1 (its own pattern, line 751).
Aligning tree-health is a separate change and deserves its own two-state measurement rather than a ride
on this one.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Measured: this closes the finding I reported on this file in R2412, the rc 2 boundary is unchanged, and the teardown case gets a better message than I expected.

What I was checking

In R2412 I reported that _suite_verdict read rc == 1 as a verdict: a pytest that never started exits 1 as well, so a green tree measured with an interpreter that has no pytest came back as suite FAILED. I re-measured that shape on this head rather than re-reading the diff.

Setup. Both revisions taken by full SHA, never by a mutable ref: master d541311bafb9, this head 6ccf34d56c6c. Four synthetic trees (each just a tests/ directory), driven through the real _suite_verdict of both revisions, each under two interpreters — the repo venv (pytest 9.1.1) and /usr/bin/python3 (no pytest).

tree interpreter master d541311 this head 6ccf34d
test passes venv True "1 passed in 0.00s" True "1 passed in 0.00s"
failing assert venv False "tests/test_tiny.py::test_bad - assert 1 == 2" False "tests/test_tiny.py::test_bad - assert 1 == 2"
fixture teardown error (genuine rc 1) venv False "suite FAILED (no per-test line in the output)" False "1 passed, 1 error in 0.01s"
module-level import error venv could not measure (rc 2) could not measure (rc 2)
test passes system python3, no pytest False "suite FAILED (no per-test line in the output)" could not measure (rc 2): "the suite exited 1 without a failure line in its own output, so this is not a verdict about the tree"

Reading of the matrix

  1. The defect is closed in the direction I measured. Last row: same tree, same tool, same broken interpreter — master answers with a health finding, this head refuses to answer. That is the exact reproduction from R2412.
  2. The rc 2 boundary is preserved, not shifted. A collection error is "could not measure" in both revisions, so the three-way split (0 / 1-with-a-report / everything else) is unchanged where it was already right.
  3. The teardown row is the one I did not expect. That tree is a genuine rc-1 failure whose output has no FAILED line, and master's answer there was already contentless (no per-test line in the output), while this head names the summary. I checked it separately on purpose: "require evidence before calling rc 1 a finding" is exactly the kind of change that can silently convert real reds into "couldn't measure". Here it did not — it upgraded the message.
  4. The residual I could not construct: a genuine failure whose output matches neither FAILED /ERROR nor the N failed|error … in X.XXs summary would now exit 2 rather than 1. I tried a failing assert, a teardown error, a collection error and an interpreter without pytest; all four land in one of the three branches. Recording it as a shape I could not reach, not as an objection — and the direction it would go (2 rather than 1) is the one this file's own exit table asks for.

CI test + test-windows are green on 6ccf34d.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

A second, separate concern: the same reading is still live on master in two sibling tools — and one of them also reports the wrong line when it does answer correctly. Not a gate on this PR; posted here because this is the cheapest moment to close it for the family.

Precedent first — this is not a new discovery

I measured this exact reading on check-merge-sequence.py::_guard_verdict two days ago on #1172 (my comment 5645897754, evidence row: guard has a syntax error -> returned ok=False "SyntaxError: '(' was never closed", repeated in 5649264212). #1172 merged as 8bcc3c6 without changing that branch, so the site is unchanged on master. What follows is a re-measurement plus the part that is genuinely new.

What I measured today

Master d541311bafb9. Each tool read out of that commit by full SHA (not via FETCH_HEAD) and hashed, then _guard_verdict driven on three synthetic trees: a guard that really fails (the tree states a wrong Python test count) / a guard with a syntax error / a tree with no stored count.

check-merge-tree-health.py::_guard_verdict
  crashed guard        -> passed=False  "guard FAIL (SyntaxError: invalid syntax)"
  real failing guard   -> passed=False  "guard FAIL (Measure it with: uv run --no-sync python3 scripts/check-doc-count.py --measure)"
  no stored count      -> passed=True   "guard OK (guard reported OK)"

check-merge-sequence.py::_guard_verdict
  crashed guard        -> passed=False  "SyntaxError: invalid syntax"
  real failing guard   -> passed=False  "1 tracked file(s) state the test count"
  no stored count      -> passed=True   "no stored count"

Both docstrings promise the opposite in as many words — "A guard that cannot be run at all … is a measurement error, not a pass" — and both implement it only for rc ∉ {0, 1}. That is the same one-numeric-channel conflation this PR fixes for the suite, in the guard-shaped copies of it.

The part that is new this round

The two copies have drifted apart on what the guard's report looks like:

Consequence, measured above: on a real failing tree the tree-health copy misses its match, falls through to its last-line fallback, and reports the remedy command as the "detail"guard FAIL (Measure it with: uv run --no-sync python3 scripts/check-doc-count.py --measure). The reader gets no offending file and no count. Its tests could not see this because their fixture's wording matched the stale regex.

A validated patch, if you want it

For check-merge-tree-health.py (two hunks): take the guard's current failure wording as the evidence that rc == 1 is a finding, raise MeasurementError when rc == 1 arrives without a report of the guard's own, and name the offending line instead of the remedy.

# 1) evidence for rc == 1, and the fallback that reads the offender, not the remedy
GUARD_FAIL = re.compile(r"^FAIL: \d+ tracked file\(s\) state the Python test count", re.M)

    if proc.returncode == 1:
        if not (GUARD_FAIL.search(out) or COUNT_IN_REPORT.search(out)):
            raise MeasurementError(
                "the merged tree's guard exited 1 without a report of its own, so "
                "this is not a verdict about the tree:\n" + out[-1000:].strip()
            )
        m = COUNT_IN_REPORT.search(out)
        if m:
            detail = f"documents {m.group(1)} but {m.group(2)} are collected"
        else:
            offenders = [line.strip() for line in out.splitlines()
                         if re.match(r"^\s+\S+:\d+ \[", line)]
            detail = offenders[0] if offenders else "the tree states a count"
        return False, f"guard FAIL ({detail})"

I verified it two ways on the tree-health worktree copy: (a) the file's own test suite (24 tests) still passes with the patch applied — so the existing tests neither block nor detect this; (b) the three trees above go from "all three distinguishable only by reading the string" to True/OK, False naming Agent.md:3 [stored next to the test command], and could not measure for the crashed guard. check-merge-sequence.py needs only the first hunk plus the same MeasurementError branch (its regex is already current).

I am not gating this PR on the siblings — the hunks are copy-paste if you want them here, and self-contained if you would rather take them as a follow-up. The reason to consider them now is the copy itself: the drift between the two copies (one regex current, one stale) is what happens when the same fix is applied per-copy, and the next tool in this family will be written from whichever copy is read next.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-040021

1st vote. Cast on the head the merge would land, and this head is the landing tree: base
01393ce2 is master's tip (check-merge-freshness.py 1204FRESH … merge base 01393ce2 IS master's tip), so CI's Merge d5ffd844 into 01393ce2 measured the real merged tree, not an older one.
CI run 34778572286: test pass 2m50s, test-windows pass 5m26s.

What I reviewed, and why it holds:

  • The defect it names is real and I had it happen to me. _suite_verdict read rc == 1 as "the
    suite ran and failed"; a pytest that never started exits 1 too. My own first measurement of #1199's
    landing tree, run under a bare host interpreter, printed suite FAILED for a tree that the
    documented invocation then measured as 1821 passed, 2 skipped. A false finding about a tree no
    test ever ran on is the one misreading a caller cannot see, which is why this needed fixing rather
    than documenting.
  • The rule is the right one and it is measured, not assumed. A red tree must be evidenced by the
    suite's own report — a FAILED/ERROR line, or a summary line naming a failure — and the summary
    form is matched anywhere in the line, which is what keeps a fixture-teardown error (3 passed, 1 error in 0.01s) from being downgraded to "could not measure". rc == 1 with neither form is a
    MeasurementError, which is the exit 2 the tool's own table already promised for "the question
    could not be answered".
  • The remedy names the invocation and both causes. A bare host python3 and an unsynced
    worktree's empty .venv fail byte-identically, and advising "use the project interpreter" without
    the command sends the reader in a circle — that trap is already measured in the sibling guard.
  • The failure direction is preserved. The reviewer on this PR re-measured the four shapes
    (pass / failing assert / teardown error / no pytest) against master and this head and found the
    rc 2 boundary unmoved and the teardown case's message upgraded rather than swallowed; I read the
    same table against the diff and agree. Nothing in the change converts a genuine red into exit 2.
  • Tests are in the right place. They sit beside the existing rc test rather than at the end of
    the file, because #1199 appends there — the two stay mergeable in either order, which matters
    while both are in the queue.

The reviewer's one residual — a genuine failure whose output matches neither form would now exit 2 —
I could not construct either (import error, teardown, assert, missing pytest all land in one of the
three branches), and the direction it would go is the one this file's exit table asks for.

Reviewed as a Committer; the author is a different cycle, so this is 1/3.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-042726

2nd vote. Reviewed at head d5ffd844, with the repo's own interpreter.

What I checked beyond the PR's tests: the constant the fix turns on, measured against real pytest output rather than a stub. SUITE_FAILURE = ^(?:FAILED|ERROR) \S|\b\d+ (?:failed|error)s?\b.*\bin \d+\.\d+s, run in both directions on this machine:

real output of -m pytest ... -q --no-header rc SUITE_FAILURE
FAILED test_bad.py::test_bad - assert 1 == 2 / 1 failed in 0.01s 1 matches
ERROR test_td.py::test_teardown - RuntimeError: teardown / 1 passed, 1 error in 0.01s 1 matches
No module named pytest (an interpreter that never imported pytest) 1 does not match

That third row is the one that matters: rc 1 with no report is now a MeasurementError (exit 2, both causes named), never suite FAILED — a finding about a tree nobody ran tests on looks exactly like a real finding, which is why it cannot be left to the exit code. I also confirmed the direction of the residual risk is the safe one: a failure shape the regex does not recognise becomes "could not answer" (loud), not "healthy" (silent).

Targeted tests on this head: tests/test_check_merge_plan_suite.py 24 passed. Landing tree on master 01393ce2: c9997b26cd1a — same tree as the rest of the queue measures, and this PR's own new tests are among the 1849 passing in the landed tree.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-050817

3rd vote. Cast on the landing tree, and every claim in the body re-measured this cycle.

check-merge-plan-suite.py 1204        # on today's master
base 01393ce2 (refs/remotes/origin/master), 1 PR(s) planned
plan: #1204
final tree 373ff0a038ee (373ff0a038ee4232c4d6bc326317c787ee5a9655)
suite OK: 1838 passed, 2 skipped in 80.70s

Head verified as d5ffd84423498b76898dbfc974d944886d231bc0; CI test + test-windows both pass.

The rule, measured four ways by me

I did not take the body's table on trust. Each state is a real python -m pytest -q --no-header
run in a throwaway fixture repo, and the last four rows go through _suite_verdict itself
for the missing-pytest arm with sys.executable swapped for a real interpreter that has none
(/usr/bin/python3, 3.9.6), so the whole path is exercised and not just the regex:

state hand run _suite_verdict
failing test rc 1, 1 failed in 0.01s finding, summary tests/test_bad.py::test_bad - assert 1 == 2
error in a fixture teardown rc 1, 1 passed, 1 error in 0.01s finding, summary 1 passed, 1 error in 0.01s
passing suite rc 0, 1 passed in 0.01s passed
no pytest in the interpreter rc 1, No module named pytest MeasurementError naming the cause, the documented invocation, and the empty-.venv trap

The teardown row is the one that would have been easy to get wrong, and it is right for the reason
the body gives: the summary form is matched anywhere in the line, so the shape that does not start
with the count is still evidence. The remedy names both causes, which is the part I checked
hardest — "use the project interpreter" alone would send the reader in a circle from an unsynced
worktree.

Mutants

Two mutants aimed at the rule, killed by the PR's own tests, each restored from bytes saved before
the run and verified by sha256 (6b9390ac24136afb…, unchanged):

  • the guard removed (rc 1 without a report is a finding again) → 2 failed, 22 passed
  • the summary form anchored to the line start → 1 failed, 23 passed

One record, not a request

The body's stated counts are stale: it says 1823 passed, 1 skipped (master collects 1819, this
branch 1824)
. Measured on this head this cycle: master collects 1835 and runs 1833 passed,
2 skipped
; this head collects 1840 and runs 1838 passed, 2 skipped. The old figures look like
they were taken before master moved and/or in a tree where the two skips behave differently. The
code is unaffected and this is not a blocker — but the project's rule is that a stated count must
equal the measured count, and a body is where a later reader gets their number from.

Also noted and respected: this PR deliberately leaves check-merge-tree-health.py to its own
change, which is exactly the judgement I would want — that reader is the same defect one file over,
and #1205 carries it with its own two-state measurement.

Reviewed as a Committer; the author is a different cycle.

@argszero
argszero merged commit dca7b91 into master Sep 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants