Skip to content

fix(hooks): a stale lastfailed entry must not run the whole suite (#984) - #1102

Merged
frankbria merged 6 commits into
mainfrom
fix/984-pretest-lastfailed-stale
Aug 8, 2026
Merged

fix(hooks): a stale lastfailed entry must not run the whole suite (#984)#1102
frankbria merged 6 commits into
mainfrom
fix/984-pretest-lastfailed-stale

Conversation

@frankbria

@frankbria frankbria commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #984.

Reproduced first

Case C (target exists):  pytest --lf --lfnf none  ->  1 failed             (1 test ran)
Case A (target renamed): pytest --lf --lfnf none  ->  1 failed, 2 passed   (ALL 3 ran)

--lfnf none guards only the empty cache. With a non-empty lastfailed whose node IDs no longer resolve, pytest falls back to running everything — carrying -x, so it also aborts the commit on the first unrelated failure it hits. Self-triggering in the most ordinary workflow: rename a test, and the very next commit hangs.

Worth noting from the repro: the cache grows a second key rather than replacing one. After the rename it held both test_beta_fails and test_beta_renamed, so the stale entry never clears itself.

Fix

scripts/pretest_lastfailed.py computes the selection instead of trusting --lf: read lastfailed, drop node IDs that can no longer be collected, run what's left. Collection is scoped to the handful of files named in the cache, so it can never become the full-suite pass it exists to prevent.

Before vs after, same repro, clean caches:

old (--lf --lfnf none) new
Case A (renamed) 1 failed, 2 passed — all 3 ran selection empty, exit 0
Case C (still exists) 1 failed test_gt.py::test_beta_fails, exit 1

Case C still blocks the commit, which is the property that must not regress.

Why option 1, not the issue's weakly-preferred option 2

Option 2 ("scope to changed files") changes what the hook means. Pre-commit passes changed files, which are mostly codeframe/** source files — pytest codeframe/core/workspace.py collects nothing. So option 2 quietly degrades to "run the test files you edited" and drops the hook's actual value: re-running what you previously broke after a source change, which is the common case.

Option 3 taken as well, as the issue suggests: the hook runs under timeout 300, and exit 124 becomes a pass with a warning. A pre-commit hook needs a wall-clock ceiling whatever it decides to run.

Fails safe

An unreadable or malformed cache selects nothing. The one outcome this must never produce is "I couldn't tell, so run everything" — pinned by test_malformed_cache_selects_nothing_rather_than_everything.

The review caught a real hole I opened

codex [P2]: my first version pruned any node ID that didn't collect — and my code comment said "collection errors are fine". Wrong, and the mistake was conflating two things that look identical from outside:

  • a node ID that stopped resolving because the test was renamed → stale, nothing to run, prune it
  • a file that raises during collection because you just made it un-importable → broken, and exactly what a pre-commit hook exists to catch

Pruning the second along with the first meant a syntax error in a previously-failing test file gave an empty selection and exit 0 — the hook passing on the very change it should stop. collectible() now returns errored files separately (verified against real pytest output: ERROR collecting <path>, rc=2), and a cached node ID whose file errored selects the file so pytest re-raises and blocks the commit.

Verification

  • 23 test functions → 46 collected in tests/scripts/test_pretest_lastfailed_984.py (every case parametrized over a plain pytest.ini and one whose addopts starts with -v, matching this repo) — Case A (rename / deleted file / deleted test), Case C (exact selection, no neighbours, mixed stale+live), empty/missing/malformed cache, whole-file keys, the four broken-file cases, the five unparseable-format cases, and end-to-end runs asserting exit codes.
  • Mutation-checked, six guards: removing the pruning fails 4; making the malformed cache non-safe fails 1; treating errored files as stale fails 4; dropping -o addopts= fails 3; making the format guard prune silently fails 6; gating that guard on not errored fails 2.
  • The real hook runs: pre-commit run pytest-check --all-filesRun last failed tests (fast feedback)....Passed. YAML validated by parsing the config and printing the composed entry (the first attempt broke it — a : inside an unquoted plain scalar).
  • Full non-e2e gate green; ruff check clean.

Known limitations

  • This clone's .git/hooks/pre-commit is a secret scanner, not the pre-commit framework's hook, so .pre-commit-config.yaml only takes effect where someone has run pre-commit install. That's pre-existing and out of scope; the config is the tracked artifact and is now correct.
  • The 300s ceiling is a blunt instrument: a legitimately slow set of previously-failed tests will be skipped with a warning rather than run to completion. That is the intended trade for never hanging a commit, and [P2.18] Full test suite takes ~4h locally vs 4m38s on CI — per-test SQLite fsync #979 (merged) makes hitting it much less likely.
  • A cached node ID for a test that still exists but is now deselected by markers is not pruned — collectible() clears addopts, so it still resolves at selection time. It is neutralized later: the real run in main() keeps addopts, pytest exits 5 ("no tests ran"), and the YAML wrapper maps that to a pass. Same outcome, different mechanism than an earlier draft of this note claimed.
  • The five node IDs currently selected in this repo are e2e tests, which the hook will happily run even though CI excludes them. That is unchanged from --lf's behaviour and bounded by the 300s ceiling, but it is a real difference between the hook and the CI gate.

`pytest --lf --lfnf none` guards only the EMPTY cache. With a non-empty
lastfailed whose node IDs no longer resolve — a test renamed, moved or
deleted since it failed — pytest falls back to running everything.
Reproduced exactly as the issue describes:

    Case C (target exists):  --lf --lfnf none -> 1 test ran
    Case A (target renamed): --lf --lfnf none -> all 3 ran

So the hook silently converted from "run nothing" to "run the entire
suite", carrying -x. Self-triggering in the most ordinary workflow: rename
a test and the very next commit hangs.

`scripts/pretest_lastfailed.py` computes the selection itself — drop node
IDs that can no longer be collected, run what is left. Collection is
scoped to the files named in the cache, so it can never become the
full-suite pass it exists to prevent. Case A now selects nothing and exits
0; Case C still selects exactly the failure and still blocks the commit.

Chose the issue's option 1 over its weakly-preferred option 2: pre-commit
passes CHANGED files, which are mostly source files, and `pytest
codeframe/core/workspace.py` collects nothing. Option 2 therefore degrades
to "run the test files you edited" and drops the hook's actual value —
re-running what you previously broke after a source change, the common
case.

Option 3 taken as well: the hook now runs under `timeout 300`, and 124 is
translated to a pass with a warning. A pre-commit hook needs a wall-clock
ceiling whatever it decides to run.

Fails safe throughout — an unreadable or malformed cache selects nothing.
The one outcome this must never produce is "I could not tell, so run
everything".
…984)

codex review [P2]. My own comment said "collection errors are fine" —
wrong, and the mistake was conflating two things that look identical from
the outside.

A cached node ID that no longer resolves because the test was renamed is
*stale*: nothing to run, prune it. A cached file that raises during
collection because you just made it un-importable is *broken* — and that
is precisely what a pre-commit hook exists to catch. Pruning the second
along with the first meant a syntax error in a previously-failing test
file produced an empty selection and exit 0, letting the commit through.

`collectible` now returns the errored files separately, and any cached
node ID whose file errored selects the file itself, so pytest re-raises
the collection error and blocks the commit. Verified against real pytest
output rather than assumed: a collection failure is bannered as
"ERROR collecting <path>" with rc=2.

Four tests: a syntax error is selected and fails the hook, an import error
counts the same, and a broken file does not drag a genuinely-stale
neighbour back into scope.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 6m 42s —— View job


GLM precision review — bug hunt

  • Gather PR context (diff, comments, surrounding code)
  • Analyze scripts/pretest_lastfailed.py for logic defects
  • Analyze .pre-commit-config.yaml for correctness
  • Confirm findings against real code context
  • Post verdict

Scope: concrete defects only (logic errors, unhandled crash/loss paths, contract breaks). Style/architecture/coverage are covered by the general review and intentionally not repeated here.

What I examined closely:

  • select() loop (scripts/pretest_lastfailed.py:106-122) — stale/live/mixed/whole-file-key/errored-file branches. A stale node ID cannot appear in resolved (it didn't collect), so nothing stale is ever wrongly selected; a live node ID always appears in --collect-only -q output and isn't dropped by the ERROR/E filter, so nothing live is wrongly pruned. The errored-file → append-once dedup and the whole-file-key existence branch are correct.
  • read_lastfailed failsafe (:44-51) — malformed/missing/non-dict cache → [] → empty selection → exit 0; never the full-suite expansion the hook exists to prevent.
  • YAML exit-code chain (.pre-commit-config.yaml:30-35) — traced codes 0/1/2/5/124; the timeout 300 wrapper also bounds the inner collectible() subprocess (which has no inner timeout), so there's no unbounded hang. The bash -c '...' quoting defers $?/$code to the inner shell and keeps #984 literal inside double quotes.

✅ GLM review: no defects found.

The one theoretical gap I considered — _COLLECT_ERROR = re.compile(r"ERROR collecting (\S+)") (:35) truncating a path containing a space, which would let a broken file be misclassified as stale — has no reachable trigger in practice: Python test module paths cannot contain spaces (they'd be un-importable and never land in lastfailed). Not reported as a defect.

@frankbria

Copy link
Copy Markdown
Owner Author

Third-party review — codex review --base main

Pass 1 — [P2] Fail cached files that no longer collect (scripts/pretest_lastfailed.py)

When a cached test file now has a syntax/import-time collection error, pytest --collect-only exits nonzero and may emit no node IDs, but this code ignores the return code and treats the empty set as a stale cache. In that scenario the hook exits 0, allowing a commit that broke collection in a previously-failing test file.

Confirmed, and it was a hole I opened while fixing the original one. My code comment literally said "Collection errors are fine" — the mistake was conflating two states that look identical from outside the process:

symptom actual meaning right response
cached node ID no longer resolves, file collects fine test was renamed — stale prune, run nothing
cached file raises during collection you just made it un-importable run it, block the commit

Pruning the second along with the first meant a syntax error in a previously-failing test file produced an empty selection and exit 0 — the hook passing on precisely the change it exists to stop. Worse than the bug I was fixing, in the sense that a hanging hook is at least visible.

Fixed by making collectible() return the errored files separately, verified against real pytest output rather than assumed (ERROR collecting <path>, rc=2). Four tests: syntax error selected and hook fails, import error the same, and — the one that pins the distinction — a broken file does not drag a genuinely-stale neighbour back into scope. Mutation-checked by forcing errored = set(), which fails all four.

Pass 2 (final diff) — clean:

The changes are narrowly scoped to the pre-commit last-failed pytest hook and add tests covering stale cache, live failures, malformed cache, and collection-error cases. I did not find a regression or actionable bug introduced by this diff.

No Critical or Major findings outstanding.


One thing not caught by a reviewer, worth recording: my first .pre-commit-config.yaml edit produced invalid YAML and I nearly didn't notice, because the hook simply wouldn't have run. The entry: value is an unquoted plain scalar, and I put a : inside it (echo "pytest-check: timed out..."), which YAML reads as a nested mapping. Now written as a >- folded block, and verified by parsing the config and printing the composed entry string rather than eyeballing it — a broken hook config fails open, so "it didn't complain" is not evidence.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

Nice, disciplined fix — clear repro, the stale-vs-broken distinction is exactly right, "fails safe = select nothing, never everything" is a good invariant, and the MoreQuietAction-style verbosity gotcha with -x/full-suite fallback was a real footgun worth killing. 23 tests, mutation-checked, manual pre-commit run verification — solid process.

Likely correctness bug: -v in the repo's own addopts cancels the script's -q, so collectible() may never see flat node IDs

collectible() parses pytest --collect-only -q --no-header -p no:cacheprovider <files> and treats any line containing "::" as a resolvable node ID (scripts/pretest_lastfailed.py:73-83). That flat-node-ID output only appears when pytest's effective verbosity is negative. Verbosity is a single counter: -v/--verbose increments it, -q/--quiet decrements it (pytest's MoreQuietAction), and -v -q together nets to the default verbosity — which renders --collect-only as the indented <Module ...> / <Function ...> tree instead, with no "::" on any line.

This repo's own pytest.ini has -v as the very first entry in addopts. Since collectible() runs with cwd=root, pytest auto-loads that ini, so the subprocess effectively runs at verbosity 1 (addopts -v) - 1 (script's -q) = 0. If that's right, resolved comes back empty for every live file, and every recorded node ID with "::" gets pruned as "stale" — including genuinely-still-failing tests. That's exactly Case C, the one property the PR states "must not regress." Case A (renamed test → prune) would still look correct, but for the wrong reason (everything gets pruned, not just the stale entry).

The 23 new tests don't catch this because the project fixture's throwaway pytest.ini is just [pytest]\n — no addopts, so -q alone does push verbosity negative there. The bug (if it's real) only shows up against the actual project config, which the test suite never exercises. The manual verification in the PR description ("pre-commit run pytest-check --all-files → Passed") also wouldn't have caught it, since that was presumably run against an empty/fresh lastfailed cache — select() short-circuits to [] before collectible() ever runs when the cache is empty.

I wasn't able to execute pytest in this review sandbox to confirm byte-for-byte (all subprocess execution is blocked here), so treat this as a strong "please verify" rather than a confirmed break. Easy local check from repo root:

uv run pytest --collect-only -q --no-header -p no:cacheprovider tests/scripts/test_pretest_lastfailed_984.py

If that prints <Module ...>/<Function ...> tree lines instead of tests/scripts/test_pretest_lastfailed_984.py::TestX::test_y flat lines, the bug is confirmed. If it's real, a more robust fix than counting on flag-cancellation math would be to decouple collectible() from whatever verbosity addopts happens to inject — e.g. --override-ini=addopts=... reasserting only what you actually want inherited (the -m "not e2e_llm and not lifecycle" deselection, which the "known limitations" section says is intentional), rather than relying on the sign of verbose_count - quiet_count.

Minor / non-blocking

  • collectible()'s own subprocess.run has no timeout — the whole timeout 300 wrapping in .pre-commit-config.yaml covers it end to end, but a hang during collection (e.g. a blocking import in a conftest) burns the entire 300s budget before the actual re-run of failed tests ever gets a chance to start. Probably fine given the outer ceiling, just noting it's not independently bounded.
  • The _COLLECT_ERROR regex reads result.stdout only. If a collection-time failure ever gets routed to stderr instead (e.g. a --strict-markers/--strict-config usage error rather than an import/syntax error), a broken file could be missed by the "errored" set. Seems unlikely for the syntax/import-error cases you tested, and collection errors are reported by pytest regardless of quiet/verbose level, so this is probably orthogonal to the issue above — just flagging as an untested edge.

Everything else — the JSON/OSError/ValueError handling in read_lastfailed, the stale-vs-broken split in select(), the timeout 300 + exit-124-as-warning translation in the YAML, the exit-5 no-tests-collected normalization — looks correct and is exercised well by the test suite.

claude-review, and this one silently defeated the whole fix in THIS repo.

Verbosity in pytest is a single counter. `pytest.ini` here starts addopts
with `-v`, so the probe's `-q` netted to DEFAULT verbosity, and
`--collect-only` then prints an indented <Module>/<Function> tree with no
"::" on any line. Every recorded node ID parsed as unresolvable, so the
hook selected NOTHING — including tests that were genuinely still failing.
Measured against the real cache: 298 entries, 0 selected. After the fix,
5 selected and 293 correctly pruned as stale.

My tests missed it because the fixture wrote a bare `[pytest]` ini. Every
case is now parametrized over that AND an ini that starts addopts with
`-v`, reproducing this repo's actual configuration.

`-o addopts=` is the fix, and it is load-bearing rather than tidiness: it
also drops `-m` filters and coverage flags, none of which belong in a
collection probe.

Second, more important change: the parser no longer treats "I found no
node IDs" as "everything is stale". If pytest reports collecting tests but
nothing parses, that is an output shape this script does not understand,
and it now says so on stderr and falls back to running the cached files —
still bounded to the handful named in the cache, never the suite. A
conftest can raise verbosity in code where no flag can undo it, so
`-o addopts=` alone is not a complete answer.

Silently selecting nothing is the exact failure mode this issue is about.
It should not be reachable by a parsing surprise.
@frankbria

Copy link
Copy Markdown
Owner Author

This one silently defeated the entire fix, in this repo, and my tests could not see it

This repo's own pytest.ini has -v as the very first entry in addopts … the subprocess effectively runs at verbosity 1 - 1 = 0 … every recorded node ID with "::" gets pruned as "stale".

Confirmed exactly, against the real cache:

$ pytest --collect-only -q --no-header -p no:cacheprovider tests/scripts/...
collected 18 items
<Dir codeframe>
  <Package tests>          <- the tree. no "::" anywhere.

cache entries: 298
selection:     0           <- the hook did nothing, always

After -o addopts=: 5 selected, 293 correctly pruned as genuinely stale.

So the hook I shipped to stop a full-suite run had been converted into a permanent no-op — arguably worse than the bug, because a four-hour hook is at least visible.

Why my tests missed it: the fixture wrote a bare [pytest]. Every case is now parametrized [plain-ini, verbose-ini], the latter reproducing this repo's actual configuration. Under it, 3 tests were RED before the fix. That is the real lesson here — the fixture was a simplified environment, and the simplification was exactly the variable that mattered.

-o addopts= is load-bearing rather than tidiness: it also drops -m filters and coverage flags, none of which belong in a collection probe.

The more important change

Diagnosing this made the deeper flaw obvious: the parser treated "I found no node IDs" as "everything is stale". Those are not the same claim, and conflating them is how a parsing surprise becomes a silent no-op — the precise failure mode this issue exists to fix.

So the parser now refuses to guess. If pytest reports collecting tests but nothing parses, it says so on stderr and falls back to running the cached files — still bounded to the handful named in the cache, never the suite. This is not hypothetical: -o addopts= fixes the ini, but a conftest can raise verbosity in code, where no command-line flag can undo it. That is the case the four new TestUnparseableCollectionFallsBackLoudly tests construct.

Building it also caught a bug in the guard itself — my count regex was ^-anchored, and pytest's summary is ===== 2 tests collected in 0.06s =====, padded. It never matched, so the guard was dead code that mutation testing initially showed surviving. Found it by chasing the surviving mutant rather than assuming the guard was redundant defense-in-depth.

49 tests now. Mutation-checked: dropping -o addopts= fails 3; making the guard prune silently fails 6; making the fallback return [] fails 4.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 6m 33s —— View job


GLM precision review — bug hunt (latest commit)

  • Gather PR context (diff, comments, surrounding code)
  • Analyze scripts/pretest_lastfailed.py for logic defects
  • Analyze .pre-commit-config.yaml for correctness
  • Confirm findings against real code context
  • Post verdict

Scope: concrete defects only (logic errors, unhandled crash/loss paths, contract breaks). Style/architecture/coverage are covered by the general review and intentionally not repeated here. Reviewed the final commit (3b9e7fb, the -o addopts= + unparseable-format fallback), not a re-litigation of the two earlier rounds.

What I examined closely:

  • The new -o addopts= probe (scripts/pretest_lastfailed.py:101-102) — confirmed load-bearing against this repo's pytest.ini:19-39 (addopts opens with -v, so ini -v + probe -q nets to default verbosity → tree output → no ::). Clearing addopts also correctly drops the -m "not e2e_llm and not lifecycle" deselect for the probe only; main()'s real run (:178-181) leaves addopts intact, so marker-deselected tests are resolved at selection but deselected at run → exit 5 → masked to pass. That is the documented known limitation, not a defect.
  • The unparseable-format guard (:115-119) and its fallback (:138-146). Traced that it cannot misfire on a legitimate all-stale cache: in Case A (renamed test) the file still exists with its other tests, so --collect-only -q returns non-empty resolved (the surviving flat node IDs) and not resolved is False — the stale node_id simply fails the in resolvable check and is pruned, no fallback. The guard fires only when resolved AND errored are both empty while tests were collected, i.e. the verbose-tree shape a conftest can force in code; the fallback returns the bounded live_files set, never the suite. Sound.
  • _collected_something / _COLLECTED_COUNT (:41-53) — the match.group(1) or match.group(2) read is correct for both alternatives, and the int(count) > 0 check correctly filters a "0" summary (a truthy non-empty string) so an all-deselected/empty file prunes rather than falls back.
  • select() branches (:148-164) — stale/live/errored/whole-file-key each correct by inspection; the errored-file dedup (if file_part not in surviving) and the whole-file-key existence branch are right. errored path strings come from ERROR collecting <path> printed exactly as the paths are passed (derived from the cache keys), so the file_part in errored match holds.
  • read_lastfailed failsafe (:62-69) — malformed/missing/non-dict → [] → empty selection → exit 0; never the full-suite expansion.
  • YAML exit-code chain (.pre-commit-config.yaml:30-35) — traced 0/1/2/5/124; timeout 300 also bounds the inner probe (no separate inner timeout, already flagged as non-blocking in an earlier round). The [ "$code" -eq 5 ] && code=0 line short-circuits cleanly without set -e, and the >- folded scalar + bash -c '...' quoting keep $?/$code and #984 intact.

✅ GLM review: no defects found.

The one imprecision I considered — the resolved filter's not line.lstrip().startswith(("ERROR", "E ")) (:111) is a prefix match, so a node ID whose path begins literally with ERROR (e.g. a top-level ERRORS/ test dir) would be dropped from resolved and pruned as stale — has no reachable trigger here: this repo's testpaths = tests means every node ID starts with tests/, and a generic repo hitting it with an all-ERROR-prefixed cache would trip the _collected_something guard and fall back to running the cached files anyway. Not reported as a defect.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

This PR went through three solid rounds of self-correction (GLM automated pass, codex review, and a prior Claude review), each catching a real bug that the previous round missed — the collection-error-vs-staleness conflation, and then the -v/-q verbosity-cancellation bug that made the fix a silent no-op against this repo's own pytest.ini. I read the final diff independently rather than re-litigating those, and confirmed -v is indeed the first entry in this repo's addopts (so the third commit's -o addopts= fix is addressing a real, not hypothetical, condition here).

I was not able to execute the test suite or the script directly in this sandbox (command execution required approval that wasn't available), so the below is a manual trace of the code, not a re-run of the 29 parametrized tests.

Correctness — looks solid after the third commit

  • select()'s stale/live/errored/whole-file-key branches (scripts/pretest_lastfailed.py:106-131) are each individually correct by inspection, and the fail-safe default (read_lastfailed[] on any parse trouble) is the right invariant for a hook whose entire point is "never expand scope."
  • The -o addopts= fix for the collection probe is necessary and well-justified — it also correctly avoids clearing addopts for the actual re-run in main(), so -m "not e2e_llm and not lifecycle" still applies there (verified by reading both call sites).
  • CollectFormatError → fallback to running the cached files (never the whole suite) is a good defense-in-depth for a conftest that forces verbosity in code, where -o addopts= can't help.

Minor / non-blocking

  • Narrow gap in the format-error guard: collectible() only raises CollectFormatError when not resolved and not errored (:114). If a conftest forces verbosity and the cache mixes a broken file with a healthy-but-still-failing one, errored would be non-empty (the ERROR collecting regex doesn't care about verbosity) while resolved stays empty (the tree-format output doesn't parse), so the guard doesn't fire — the healthy file's still-failing test would be silently pruned as "stale." This requires stacking two already-unlikely conditions (forced verbosity bypassing -o addopts=, and a broken file coexisting with a live failure in the same cache), and the existing TestUnparseableCollectionFallsBackLoudly tests don't cover that combination. Given how much ground this PR already covers, I'd call this acceptable residual risk rather than something to block on.
  • Doc/test-count drift: the PR description says "23 tests," but the third commit's message says "49 tests now," and the current file has 29 def test_ functions parametrized over 2 ini variants (so the real collected count is higher than either number). Worth a quick pass to make the final count in the description match reality before merge, purely for future readers.
  • Known-limitations wording: the PR body's "a cached node ID for a test that's now deselected by markers is pruned as stale" describes the outcome but not the actual mechanism — collectible() clears addopts, so a marker-deselected test is still resolved at selection time; it only gets neutralized later when the real run (main(), addopts intact) hits pytest's exit-5 "no tests ran," which the YAML wrapper maps to a pass. Functionally equivalent, just a slightly inaccurate description if someone later greps the docstring/PR for how it works.

Other

  • No security concerns — this is a local dev-tooling script operating only on the repo's own .pytest_cache.
  • Test coverage is thorough and, notably, exercises the actual failure mode (parametrizing the fixture's pytest.ini over both a plain and a -v-leading config) rather than just the happy path — that's exactly the right instinct given the bug that slipped through the first two commits.
  • .pre-commit-config.yaml's >- folded block and the exit-code chain (0/1/2/5/124) trace correctly through the bash -c quoting.

Nice, disciplined iteration overall — the repo's own config surfaced a real gap in the first fix, and the response (parametrize the test fixture over the actual environment, not just clear the flag) fixed the root cause rather than papering over the symptom.

…984)

claude-review, narrow but real. The guard was gated on
`not resolved and not errored`, but `errored` comes from an
"ERROR collecting" regex that is verbosity-independent, so it stays
populated while `resolved` goes empty under forced verbosity. A cache
mixing a broken file with a healthy-but-still-failing one therefore
slipped past the guard, and the healthy file's live failure was pruned as
stale.

Dropping `not errored` closes it without a new failure mode: if every file
errored, pytest reports 0 collected, `_collected_something` is False, and
the guard correctly stays quiet.

Pinned by test_it_fires_even_when_one_file_also_errored; restoring the old
condition fails it.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 27s —— View job


GLM precision review — bug hunt (latest commit)

  • Gather PR context (diff, comments, surrounding code)
  • Analyze scripts/pretest_lastfailed.py for logic defects
  • Analyze .pre-commit-config.yaml for correctness
  • Confirm findings against real code context
  • Post verdict

Scope: concrete defects only (logic errors, unhandled crash/loss paths, contract breaks). Style/architecture/coverage are covered by the general review and intentionally not repeated here. Reviewed the new synchronize commit (23f7350, "the format guard must fire even when a file also errored"), not a re-litigation of the three earlier rounds.

The diff is one condition change in collectible():

-    if not resolved and not errored and _collected_something(result.stdout):
+    if not resolved and _collected_something(result.stdout):

What I examined closely:

  • The dropped not errored term (scripts/pretest_lastfailed.py:119). Traced both ways the guard can now resolve:

    • Mixed broken + healthy under forced verbosity (the gap the commit targets): errored is populated by the verbosity-independent ERROR collecting regex while resolved stays empty (conftest-forced tree output has no ::). Old condition: not erroredFalse → guard suppressed → healthy file's live node ID fails the in resolvable check and is pruned as stale → main() runs nothing → exit 0 → silent pass of a real failure. New condition: guard fires → select() catches CollectFormatError and returns live_files (both files) → the healthy file's failure is run → commit blocked. Pinned by test_it_fires_even_when_one_file_also_errored, which asserts "test_gt.py" in selection and would fail under the restored old condition (confirmed by tracing: without the fallback, only test_broken.py survives). Correct.
    • All-errored: pytest interrupts with a ===== N errors in X.XXs ===== summary (no tests collected/collected N items), so _collected_something is False and the guard stays quiet regardless — select()'s errored branch selects the broken cached file and blocks the commit. Even if pytest reported a nonzero collected count here, the guard firing would only route to the bounded live_files fallback (still blocks). No failure mode opened either way.
  • No silent-pass path introduced. Whenever the guard now fires, select() returns live_files — every cached file that still exists on disk — and main() runs pytest -x over them, so any cached failure or any cached-file collection error still surfaces nonzero. The only way the hook exits 0 is if every cached file genuinely passes. Dropping the term widens when the guard fires, never what a fired guard does.

  • No interaction defect with the other branches. errored (:113), the whole-file-key branch (:164), and the resolved filter (:108-112) are unchanged. The guard sits strictly before return resolved, errored, so widening its trigger cannot corrupt either return set — at worst it raises before they're returned, which is the intended escape hatch.

  • .pre-commit-config.yaml is untouched by this commit; the exit-code chain (0/1/2/5/124) and timeout 300 wrapper are unchanged from the prior round and remain correct.

✅ GLM review: no defects found.

The fix is narrow and sound: it removes exactly the term that let a verbosity-forced + partially-broken cache slip past the loud-fallback guard, and the term's removal cannot create a new failure mode because the fallback it gates is itself fail-safe (bounded to cached files, blocks on real failures). No inline findings.

@frankbria

frankbria commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

All three addressed.

Guard gap (mixed broken + live under forced verbosity) — fixed in 23f7350. You're right that errored is verbosity-independent while resolved is not, so gating on not errored let exactly that combination slip through and prune a live failure as stale. Dropping the condition closes it with no new failure mode: if every file errored, pytest reports 0 collected, _collected_something is False, and the guard correctly stays quiet. Pinned by test_it_fires_even_when_one_file_also_errored; restoring the old condition fails it.

I'd have accepted "acceptable residual risk" on the likelihood argument, but the fix was one condition and the test was five lines — cheaper to close than to document.

Test-count drift — corrected. The honest number is 23 test functions → 46 collected, since the fixture is parametrized over both ini variants. The "49"/"51" in my commit messages were whole-directory runs that also picked up test_telemetry_collector.py; I'd conflated two different counts across three commits. The body now says which is which.

Known-limitations wording — you're right, and my description had the mechanism backwards. collectible() clears addopts, so a marker-deselected test does still resolve at selection time; it gets neutralized later when the real run in main() (addopts intact) exits 5 and the YAML wrapper maps that to a pass. Same outcome, different route. Rewritten.

While correcting it I noticed something worth stating explicitly rather than leaving for a future reader to discover: the five node IDs this repo currently selects are e2e tests, which the hook will run even though the CI gate excludes them. That is unchanged from --lf's behaviour and bounded by the 300s ceiling, but it is a genuine difference between the hook and CI — added to Known Limitations.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

Read the full diff (all 4 commits) independently rather than re-litigating the three rounds already caught by GLM/codex/Claude (collection-error-vs-staleness conflation, the -v/-q verbosity cancellation making the fix a silent no-op against this repo's own pytest.ini, and the format-guard gap when a broken file coexists with a healthy one). Confirmed against the actual pytest.ini: addopts does start with -v and there's no cache_dir override, so both of those were real, not hypothetical, conditions in this repo — good catches.

One thing not yet raised: a mixed selection can abort the real run entirely, not just prune wrong

select() can return a selection that mixes a broken whole-file entry (from errored, e.g. "test_broken.py") with a live node ID from a different, healthy file (e.g. "test_gt.py::test_beta_fails") — this is exactly what test_it_fires_even_when_one_file_also_errored constructs. That selection then goes to main()'s real run:

pytest -x --no-header test_broken.py test_gt.py::test_beta_fails

Without --continue-on-collection-errors (not passed here), pytest's default behavior when any requested file fails to collect is to abort the entire session before running anything — "Interrupted: 1 error during collection" — not just skip the broken file. So test_gt.py::test_beta_fails never actually executes or gets reported in that run.

This isn't a safety regression — the invariant that matters (never silently pass) still holds, since pytest exits nonzero on the collection error and the commit is still correctly blocked. But it means the developer sees a collection-error abort, not the actual assertion failure they were expecting to see re-run, which could be confusing to debug ("why didn't my failing test even show up?"). Given how much rigor already went into distinguishing stale-vs-broken, might be worth a one-line note in the module docstring/known-limitations, or splitting the real run into two invocations (errored files, then resolved node IDs) if you want the healthy failure's own output to survive a neighbour's breakage. Not blocking — just flagging since none of the prior rounds exercised main()'s actual run against a mixed broken+live selection (the existing tests check select()'s output, i.e. --print-selection, for that case, not the exit behavior of the real run).

Everything else

  • read_lastfailed, collectible, and select's stale/live/errored/whole-file-key branches all check out by inspection and match the tests.
  • The -o addopts= probe fix is correctly scoped to the probe only — main()'s real run intentionally keeps addopts intact, so the -m "not e2e_llm and not lifecycle" deselect still applies there.
  • .pre-commit-config.yaml's exit-code chain (0/1/2/5/124) and the >- folded block traces correctly; no quoting issues.
  • Test coverage is unusually thorough for a dev-tooling script — parametrizing the fixture over both a plain and a -v-leading pytest.ini is exactly the right instinct given that's the bug that slipped through twice.
  • No security concerns; this only touches the repo's own local .pytest_cache.

Nice, disciplined iteration overall — each round's self-correction fixed the actual root cause rather than papering over the symptom.

…to see (#984)

claude-review, and the first finding in this thread about main()'s real run
rather than select()'s output — the prior rounds all asserted on
--print-selection, so none of them exercised this.

A mixed selection (a broken whole-file entry plus a live node ID from a
different, healthy file) went to one invocation:

    pytest -x test_broken.py test_gt.py::test_beta_fails

Without --continue-on-collection-errors, pytest aborts the entire session
on the collection error before running anything, so the live failure never
executes or reports. Not a safety regression — the exit is nonzero and the
commit is still blocked — but the developer sees "1 error during
collection" and debugs the wrong thing.

Split into two invocations, node IDs first, so the expected assertion
failure is what surfaces; a broken file is still reported after, and
either blocks the commit. Exit 5 between groups is the deselect-all no-op
and is not treated as failure.

Three tests, and both the split AND its ordering are pinned: reverting to
one invocation fails 2, and running the files first fails the same 2.
@frankbria

Copy link
Copy Markdown
Owner Author

Fixed in the latest push rather than documented — you were right that this is the one place the prior rounds never looked.

Confirmed: all three earlier rounds asserted on select()'s output via --print-selection, so nothing exercised main()'s actual run against a mixed selection. pytest -x test_broken.py test_gt.py::test_beta_fails aborts the session on the collection error before running anything, and the live failure never reports.

Agreed the safety invariant held — nonzero exit, commit blocked — but "blocked for a reason you can't see" is its own kind of bad in a hook whose entire purpose is telling you what you broke. Given how much of this PR is about silent wrong behaviour, leaving a second "the output lies to you" case in place would have been inconsistent.

Took the split-into-two-invocations option, node IDs first, so the expected assertion failure is what surfaces; a broken file is still reported after, and either blocks the commit. Exit 5 between groups is the deselect-all no-op and isn't treated as failure.

Both the split and its ordering are pinned, since the ordering is the part that's easy to regress silently: reverting to a single invocation fails 2 tests, and running the whole-file group first fails the same 2. Also added the two boundary cases — a broken file alone still fails, and a broken file is still reported when the live tests all pass (fail-fast must not hide it behind a green run).

57 tests in the directory now. That makes four rounds where the review found something real; the reviewers on this one earned their keep.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 21s —— View job


GLM precision review — bug hunt (latest commit)

  • Gather PR context (diff, comments, surrounding code)
  • Analyze scripts/pretest_lastfailed.py main() split-run change
  • Analyze .pre-commit-config.yaml for correctness
  • Confirm findings against real code context
  • Post verdict

Scope: concrete defects only (logic errors, unhandled crash/loss paths, contract breaks). Style/architecture/coverage are covered by the general review and intentionally not repeated here. Reviewed the new synchronize commit (a01d2a2, the two-invocation split so a broken neighbour doesn't swallow the live failure), not a re-litigation of the four earlier rounds.

The diff is the body of main() (scripts/pretest_lastfailed.py:182-201): partition selection into node_ids (:: present) and whole_files (:: absent), run each group with pytest -x --no-header, continue only on 0/5, return the first other code.

What I examined closely:

  • The partition is clean — no entry can land in both groups, and classification is correct. A whole_files entry originates either from select()'s errored branch (:159-160, the file path) or the whole-file-key branch (:167); both are bare paths with no ::. A node_ids entry requires the node ID to be in resolvable (:162-163). A file in errored cannot simultaneously have a resolvable node ID — collectible() would not report its node IDs as resolved — so the same file cannot appear as both a whole_file and a node_id. The CollectFormatError fallback returns _file_of(...) results (split on ::, hence bare paths) → whole_files. Correct.

  • The exit-5 swallow is load-bearing, not decorative (:199). Traced the case it exists for: this repo's addopts carries -m "not e2e_llm and not lifecycle" (pytest.ini:39), which main() keeps intact. If the node_ids group were entirely marker-deselected, pytest exits 5. Were that treated as failure-and-return, the loop would return 5 before ever reaching a broken whole_files entry, and the YAML wrapper's [ "$code" -eq 5 ] && code=0 (.pre-commit-config.yaml:32) would pass the commit — letting a cached broken file through. code not in (0, 5) correctly continues past a deselected group so the broken file still runs and blocks. A real failure is exit 1/2, never 5, so swallowing 5 cannot mask one.

  • No new silent-pass path; the safety invariant holds in every branch. node_ids failing → return nonzero (block); node_ids passing/no-op → whole_files runs; whole_files failing → return nonzero (block); both empty or clean → return 0. The ordering (node IDs first) is what surfaces the expected assertion failure before a neighbour's collection abort, which is the commit's stated purpose and is pinned by test_the_live_failure_is_actually_reported.

  • Cross-checked the stated known limitation against the real config. The 5 node IDs currently selected are e2e-marked, excluded from CI by --ignore=tests/e2e (.github/workflows path filter) — not by the -m marker — so they are not deselected at run time and the hook does run them. The exit-5 path is therefore not a silent no-op for this repo's current cache; it is only hit for genuinely marker-deselected node IDs, which are legitimately a pass.

  • .pre-commit-config.yaml is unchanged by this commit; the exit-code chain (0/1/2/3/4 block, 5 → 0, 124 → 0) and timeout 300 wrapper remain correct. Note: main() can no longer return 5 itself (the loop swallows it), so the YAML's code=5 branch is now defensive/dead for the script's own output — harmless, not a defect.

✅ GLM review: no defects found.

The one theoretical path I considered — a cache entry beginning with -- being interpreted as a pytest flag because selection items are spread into argv — is pre-existing (not introduced by this commit), requires a malicious local .pytest_cache (out of threat model for a dev-tooling script operating on the repo's own cache), and subprocess.run uses the list form with no shell=True, so there is no shell injection. Not reported as a defect.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

This has already been through five rounds of adversarial review (GLM x3, codex, Claude x4) that caught real, non-overlapping bugs at each step -- the collection-error-vs-staleness conflation, the -v/-q verbosity cancellation that made the fix a silent no-op against this repo's own pytest.ini, the format-guard gap under a mixed broken+healthy cache, and the single-invocation collection abort swallowing a live failure's report. I read the final diff (all 5 commits) independently rather than re-litigating those; they all check out by inspection and are well pinned by the 57 tests.

One residual edge case in main()'s two-invocation split, not yet exercised

main() (scripts/pretest_lastfailed.py:178-193) runs node_ids first, then whole_files, but stops at the first group whose exit code isn't 0/5:

```python
for group in (node_ids, whole_files):
if not group:
continue
code = subprocess.run([...]).returncode
if code not in (0, 5):
return code
```

If the node_ids group contains a genuine, still-failing test (exit 1), the loop returns immediately -- the whole_files group (a broken/un-importable neighbour) never runs in that invocation. So when a cache mixes a live failure with a broken file, only the live failure is reported; the broken file's collection error isn't surfaced until a subsequent commit attempt, after the live failure is fixed.

This doesn't break the safety invariant -- the commit is still correctly blocked either way -- but it's a small gap relative to how the ordering was framed ("a broken file is still reported after, and either blocks the commit"). TestAMixedSelectionReportsBoth only covers the case where the live tests pass (test_a_broken_file_is_reported_when_the_live_ones_pass); there's no test for live-fails-and-file-also-broken, so this particular combination is unverified either way. Given the exit code is already nonzero and the commit is blocked, I'd call this non-blocking -- but worth a one-line note if you want the "reported after" claim to be precisely true, or a quick test to pin the current (early-return) behavior as intentional.

Everything else

  • read_lastfailed, collectible, and select's stale/live/errored/whole-file-key branches all check out and match the tests.
  • .pre-commit-config.yaml's exit-code chain (0/1/2/5/124) and the >- folded block trace correctly; no quoting issues.
  • No security concerns -- local dev tooling operating only on the repo's own .pytest_cache.

Nice, disciplined iteration overall.

…984)

claude-review is right that the behaviour was unverified either way and my
own wording oversold it. I said "a broken file is still reported after",
but -x applies across the groups: if a live test fails we return
immediately and never reach the broken neighbour.

That is the fail-fast contract, not an oversight — the hook exists to show
you the first thing you broke, and the broken file surfaces on the next
attempt once that is fixed. Running both groups regardless would
contradict the -x the hook has always carried.

So: behaviour unchanged, claim corrected, and the early return now pinned
by a test asserting the live failure IS reported and the collection error
is NOT — so a future reader sees it as deliberate rather than a missing
loop.
@frankbria

Copy link
Copy Markdown
Owner Author

Good catch on the imprecision — my wording, not the code.

You're right that "a broken file is still reported after" isn't true when the live test fails: -x applies across the two groups, so we return immediately and never reach the broken neighbour. What I should have written is "either group blocks the commit on its own".

I'm keeping the behaviour and pinning it, rather than making both groups run. The hook has always carried -x; its contract is show me the first thing I broke, and fail-fast across groups is the consistent reading of that. Reporting a broken neighbour underneath a live assertion failure would be the inconsistency. The broken file surfaces on the next attempt, once the thing you were just shown is fixed.

What was genuinely missing is that neither branch was verified — as you note, TestAMixedSelectionReportsBoth only covered live-tests-pass. Added test_a_live_failure_stops_before_the_broken_file_by_design, which asserts the live failure is reported and ERROR collecting is not, so the early return reads as deliberate instead of as a missing loop. The inline comment now says the same thing.

59 tests. Five rounds, five real findings — worth saying that none of them overlapped, which is unusual.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 5m 35s —— View job


GLM precision review — bug hunt (latest commit)

  • Gather PR context (diff, comments, surrounding code)
  • Analyze scripts/pretest_lastfailed.py for logic defects
  • Analyze .pre-commit-config.yaml for correctness
  • Confirm findings against real code context
  • Post verdict

Scope: concrete defects only (logic errors, unhandled crash/loss paths, contract breaks). Style/architecture/coverage are covered by the general review and intentionally not repeated here. Reviewed the final commit (ada131b, pinning -x-across-groups as intentional fail-fast), not a re-litigation of the five earlier rounds.

What I examined closely:

  • The two-invocation split (main() :196-205). The partition is clean: node_ids (:: present) and whole_files (:: absent) are mutually exclusive, and a file in errored cannot simultaneously carry a resolvable node ID (collectible() reports its node IDs as unresolved), so no file lands in both groups. The exit-5 swallow (:204) is load-bearing, not decorative: with addopts intact in the real run, an entirely marker-deselected node_ids group exits 5; were that treated as failure-and-return, the loop would exit before a broken whole_files entry ran, and the YAML's 5 → 0 mapping would let the cached broken file through. code not in (0, 5) correctly continues so the broken file still blocks. A real failure is 1/2, never 5, so swallowing 5 cannot mask one.
  • The fail-fast-across-groups contract the commit pins. When a live node_id fails (exit 1) the loop returns before the broken whole_file runs — the broken neighbour surfaces on the next attempt, once the thing shown is fixed. Traced the same shape in the CollectFormatError fallback path: there every cached entry is reduced to a bare file path, so a broken file sorting first aborts that single pytest -x a.py b.py session before the healthy file's failure reports. That is the same two-attempt pattern (the live failure surfaces next, after the broken file is fixed), so it is consistent with the documented contract rather than a contradiction of it — the commit is still blocked either way.
  • select() branches (:152-167) and collectible() (:108-125): stale/live/errored/whole-file-key each correct by inspection; the errored → append-once dedup (:159) and the whole-file-key existence branch hold. read_lastfailed failsafe (:62-69) still maps malformed/missing/non-dict → [] → empty selection → exit 0, never the suite.
  • .pre-commit-config.yaml (:30-35): exit-code chain traces 0/1/2/5/124 correctly; no set -e, so the failed [ "$code" -eq 5 ] && code=0 line does not abort; #984 sits inside double quotes so it is literal. The 5 → 0 branch is now defensive for the script's own output (the loop swallows 5 internally) — harmless, as the prior round noted.

✅ GLM review: no defects found.

The one code-level imprecision I considered — the errored set is matched as file_part in errored (:155), where file_part = _file_of(node_id) is a bare path, but _COLLECT_ERROR captures whatever token follows ERROR collecting …, which pytest prints with a :: for a sub-node (class/function-level) collection error rather than a module import error — has no finding I can stand behind: the module-level case (ERROR collecting test_gt.py, bare path) is what this repo's own tests construct and is handled and pinned, and I could not verify pytest's exact output shape for sub-node collection errors without running it (out of bounds here), so I cannot state a confirmed failure scenario. Not reported as a defect.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

I read the full diff plus the review history — six rounds already caught and fixed real, non-overlapping bugs: the collection-error-vs-staleness conflation, the -v/-q verbosity cancellation that silently no-op'd the fix against this repo's own pytest.ini, the format-guard gap under a mixed broken+healthy cache, the single-invocation collection abort swallowing a live failure's report, and the imprecise "reported after" wording for the fail-fast-across-groups case. That's an unusually thorough iteration loop for a ~200-line script, and each fix is pinned by a test that fails if reverted (confirmed by reading the mutation-check claims against the actual assertions, e.g. test_it_fires_even_when_one_file_also_errored, test_a_live_failure_stops_before_the_broken_file_by_design).

Independent pass over the current (final) state, focused on things prior rounds may not have exercised:

  • select()'s four branches (stale/live/errored/whole-file-key) are mutually exclusive and correctly ordered — a file in errored can't also have a resolvable node ID (that's what makes errored and resolvable disjoint by construction in collectible()), so no cache key can double-count or fall through unhandled.
  • Whole-file-key handling: a key with no :: (from a historical collection error) is kept whenever the file still exists, with no attempt to check whether the underlying error is now fixed — that's a deliberate "the file existing is the most we can check" choice per the comment, not an oversight, and it can't expand scope beyond the named file, so it's fine.
  • main()'s exit-code fold (code not in (0, 5)) correctly treats 5 as a per-group no-op rather than a terminal signal, which matters because a marker-deselected node_ids group returning 5 must not short-circuit before the whole_files group (a genuinely broken cached file) gets a chance to run.
  • No shell injection / argv risk: both subprocess.run calls use list-form args with no shell=True, so a crafted lastfailed entry can't inject a shell command (it could in principle be interpreted as a pytest flag if it started with --, but that requires a hand-edited local cache, which is outside this script's threat model as a dev-only hook).
  • .pre-commit-config.yaml's exit chain (0/1/2/5→0/124→0) and the bash -c quoting trace correctly; no set -e gotchas since every step is explicit.

I didn't find anything new to add. Test coverage is genuinely strong for a script this size (23 functions × 2 ini variants = 46 cases, plus the mixed/format-fallback/ordering edge cases that came out of the review rounds), and the fail-safe invariant ("unreadable/ambiguous cache → select nothing, never everything") is consistently upheld everywhere I traced it. Good to merge from a correctness standpoint.

@frankbria
frankbria merged commit 4a4e80e into main Aug 8, 2026
13 checks passed
@frankbria
frankbria deleted the fix/984-pretest-lastfailed-stale branch August 8, 2026 10:35
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.

[P2.19] pre-commit pytest --lf runs the FULL suite when a lastfailed test was renamed — --lfnf none doesn't cover it

1 participant