Skip to content

fix(proof): an empty ledger is not a pass (#1118) - #1137

Merged
frankbria merged 6 commits into
mainfrom
fix/1118-vacuous-proof-pass
Aug 10, 2026
Merged

fix(proof): an empty ledger is not a pass (#1118)#1137
frankbria merged 6 commits into
mainfrom
fix/1118-vacuous-proof-pass

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #1118.

Before / after

$ cf proof run
Running proof obligations (scope-filtered)...
No applicable obligations found.        ← in green
$ echo $?
0                                        ← reads as "PROOF9 gates passed"
$ cf proof run
Running proof obligations (scope-filtered)...
Nothing was verified.
There are no proof obligations in this workspace, so this run checked nothing —
it is not a pass.

Capture your first requirement with:
  cf proof capture
$ echo $?
2

Why exit 2, not 1

An empty ledger is not a failure — but it cannot be 0 either, or CI stays
green on it, which is the whole complaint. A third code lets a script tell "the
gate failed"
from "the gate had nothing to check"; those need different
responses.

code meaning
0 obligations ran and none failed
1 an obligation failed
2 nothing was verified

--allow-empty opts back into 0 where that is genuinely expected — and still
prints the explanation, so opting into the exit code does not silence the reason.

cf proof status says the same thing

It previously said the neutral "No proof requirements." Both surfaces now
frame an empty ledger as nothing is being verified, so they cannot disagree
about what it means.

The docs already said this

docs/QUICKSTART.md, verbatim before this change:

exits 0 — there is nothing to verify yet, which is not the same as passing.

The documentation had the right model; the behaviour did not match it. It now
describes the real exit codes.

AC #4 — the merge gate, and where I drew the line

The #731 gate now reports an empty ledger instead of passing in silence, but
still allows the merge.

Making an empty ledger a merge blocker would be a much larger change than this
issue asks for, and it would immediately block this repository's own PRs — this
repo has no proof requirements. #731's gate is specifically about open
requirements. I read AC #4's "consistently" as both surfaces must stop implying
verification happened
, which they now do. Say the word if you meant the
stronger reading and I will make it blocking.

Acceptance criteria

  • Zero applicable obligations is reported distinctly from a checked pass
  • Visible in the exit code, with --allow-empty defaulting to the safe behaviour
  • The message says how to get a first obligation
  • The merge gate treats an empty ledger consistently (see above)
  • Tests cover the empty-ledger case explicitly

10 new tests. ruff clean. Full suite: 6390 passed, 49 skipped.

Note on the #614 harness

scripts/quickstart-cleanroom/walkthrough.sh will now record 5-proof-run as
FAIL / exit 2. That is the honest record of the state the harness exists to
surface, and it does not abort the walkthrough (set -uo pipefail, no -e).

Contract change

test_run_no_requirements_exits_zero asserted the old behaviour and is
rewritten, with a companion covering --allow-empty. Anything scripted against
cf proof run returning 0 on an empty ledger needs --allow-empty — which is
the intended migration, since that is precisely the silent green this removes.

`cf proof run` is the quickstart's PROVE step, on the product's stated
differentiator. On a fresh workspace it printed "No applicable obligations
found." in green and exited 0 — which reads as "PROOF9 quality gates passed"
when nothing had been checked. Every new workspace is in that state, and it held
even after an agent run had written code and tests.

Exit 2, not 1. An empty ledger is not a failure, but it cannot be 0 either or CI
stays green on it. A third code lets a script tell "the gate failed" from "the
gate had nothing to check" — different problems needing different responses.
--allow-empty opts back into 0 where that is genuinely expected, and still
prints the explanation.

The message now says nothing was verified, that this is not a pass, and how to
capture a first requirement. `cf proof status` says the same rather than the
neutral "No proof requirements".

For the #731 merge gate: it now reports an empty ledger instead of passing in
silence, but still allows the merge. Blocking every merge in a workspace with no
requirements is far beyond this issue and would immediately block this repo's own
PRs; #731's gate is about *open* requirements. The consistency AC #4 asks for is
that both surfaces say "nothing was verified", which they now do.

docs/QUICKSTART.md already said "exits 0 — ... which is not the same as
passing". The doc had the right model and the behaviour did not match it; it now
describes the real exit codes.

test_run_no_requirements_exits_zero pinned the old contract and is rewritten,
with a companion for --allow-empty. 10 new tests.

The #614 harness records this step as FAIL / exit 2 now, which is the honest
record of the state it exists to surface. It does not abort (set -uo pipefail,
no -e).

Full suite: 6390 passed, 49 skipped.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 56s —— View job


GLM bug-hunting review — #1118

Scoped to concrete defects only (logic, contract, security, data loss). Style, naming, architecture, and test coverage are covered by the general review.

  • Gather PR context (diff + existing comments)
  • Read changed files + callers/callees (run_proof, scope.py, _check_merge_gate)
  • Analyze for logic/contract/security defects
  • Post confirmed finding
  • Post summary

1 finding.

severity file:line finding
major codeframe/cli/proof_commands.py:187-207 The empty-results branch conflates two distinct run_proof outcomes — a truly empty ledger vs. a scope-filtered run that matched no requirement — and prints "There are no proof obligations in this workspace" (and exits 2) even when several obligations exist but simply didn't intersect the change set.

Why it's real: run_proof returns {} both when there are no open/satisfied requirements (runner.py:300-314) and when every requirement is skipped as out-of-scope in a default (non---full) run (runner.py:336-342 + scope.py:intersects). The added tests only cover the truly-empty case (fresh workspace), so the misleading-message + CI-fail regression on legitimate out-of-scope changes is uncaught. The pre-PR wording "No applicable obligations found." was correct for both cases.

Reviewed and cleared (no defect):

  • pr_commands.py:471-481 — the merge-gate empty-ledger warning calls list_requirements(workspace) (all statuses) and is wrapped in a reporting-only try/except; correct, can't affect the merge decision.
  • --allow-empty Typer option — correct bool flag, default False, matches tests.
  • status_cmd and QUICKSTART.md changes — message text only, no logic.

Comment thread codeframe/cli/proof_commands.py
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review: #1137 — an empty ledger is not a pass

Overall this is a well-scoped, well-tested change to cf proof run/status, and the merge-gate follow-up (_check_merge_gate in codeframe/cli/pr_commands.py:463-475) is careful in a way worth calling out explicitly: it re-queries ledger.list_requirements(workspace) with no status filter to distinguish a genuinely empty ledger from "no blocking requirements" (e.g. everything satisfied/waived). status_cmd (codeframe/cli/proof_commands.py:458) does the same. Good — that's exactly the right way to answer "is the ledger empty," and it avoids the bug below.

Bug: cf proof run conflates "empty ledger" with "nothing in scope for this run"

codeframe/cli/proof_commands.py:187-206 treats run_proof() returning falsy as "there are no proof obligations in this workspace" and exits 2 with the "capture your first requirement" message. But run_proof (codeframe/core/proof/runner.py:267-436) returns an empty dict in two very different situations:

  1. _requirements_for_run returns nothing at all — the ledger really is empty (line 299-314, short-circuits early).
  2. _requirements_for_run returns open requirements, but every one of them is scope-skipped because none of their scope intersects the current diff's changed files (the scope_skipped accumulation at line 334-341 / _report_scope_skipped at line 233). results stays {} here too — nothing distinguishes this from case 1 by the time it reaches the CLI.

So on an ordinary PR that only touches, say, docs/ while the workspace has active requirements scoped to codeframe/core/, cf proof run (the default, non---full mode) will now print "There are no proof obligations in this workspace" (false — there are, they're just out of scope for this diff) and exit 2, telling the user to cf proof capture a first requirement they already have. Note run_proof itself still persists that run as overall_passed=True in the ledger (the all_passed = all(executed) if executed else True vacuous-pass logic at the bottom of run_proofexecuted is empty here too), so the persisted record says "passed" while the CLI simultaneously tells the user "not a pass" and fails the process. That's the exact vacuous-pass-vs-honest-signal mismatch this PR is trying to eliminate, just relocated to the scope-skip path instead of removed.

This matters because scope-filtered mode is described in the CLI help as "the cheap behaviour" — presumably the default, everyday invocation (e.g. in CI on a normal PR). Any project that accumulates requirements scoped to specific subsystems will hit this on routine changes that don't happen to touch those files, turning a healthy, tracked ledger into a CI-failing false positive. That's arguably a worse outcome than the vacuous pass this PR set out to fix, since it actively misinforms the user that the ledger is empty.

Suggested fix: check ledger.list_requirements(workspace) directly (as _check_merge_gate/status_cmd already do) to decide whether the ledger is actually empty, and give the scope-skip case its own message (e.g. "N requirement(s) exist but none apply to this run's scope — use --full to check them") rather than routing both through the same "capture your first requirement" copy and exit code.

Test coverage gap

Consistent with the bug above: tests/cli/test_proof_empty_ledger_1118.py's workspace_dir fixture only ever creates a workspace with zero requirements captured — there's no test with a requirement present but out of scope for the diff (e.g. mocking get_changed_scope, the way tests/core/test_proof_runner_outcomes.py sets up scope fixtures). That's the gap that let the conflation above through. Also no test exercises the new "PROOF9: the ledger is empty" print added to _check_merge_gate in codeframe/cli/pr_commands.py — worth at least one test asserting it fires only when ledger.list_requirements is truly empty, and not when requirements exist but are all satisfied/waived (i.e. blocking_reqs empty for a non-empty-ledger reason).

Minor

  • codeframe/cli/proof_commands.py:197-199: the "no proof obligations in this workspace" phrasing is the symptom of the bug above — fixing the underlying conflation will naturally fix the message too.

Nothing else stood out — the exit-code scheme (0/1/2), --allow-empty semantics, and the docs/QUICKSTART.md update are all consistent with each other and with the stated design (empty ledger reports but does not block merge). No security concerns, no new HTTP surface — CLI-only, consistent with the Golden Path constraint.

Review finding, and a regression I was introducing rather than one I inherited.

run_proof returning {} is ambiguous: the ledger may be empty, or it may hold
requirements none of which intersect the changed scope. My first version treated
both as the vacuous-pass case, so a workspace with three requirements scoped to
src/** and a doc-only PR would have been told "there are no proof obligations in
this workspace ... capture your first requirement" and exited 2 — failing CI
because the scope filter did exactly its job.

The two states now report separately:

  empty ledger            -> "no obligations in this workspace", exit 2
  nothing in scope        -> "N requirement(s) exist, but none apply to the
                             changed files", points at --full, exit 0

Both still say "nothing was verified", which is true either way; only the first
is the silent-green this issue is about. A ledger that cannot be read falls
through to the unverified case rather than claiming scope filtering.

The tests patch run_proof rather than staging changes through git: with no
working-tree changes the scope detector fails closed and runs everything, so the
real filter cannot produce this state in a fixture. The ambiguity under test is
in how the CLI reports an empty result.

Full suite: 6394 passed, 49 skipped.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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


GLM bug-hunting review — #1137 (re-review after fix push)

Scoped to concrete defects only (logic, contract, security, data loss). Style, naming, architecture, and test coverage are covered by the general review.

  • Gather PR context (current diff + existing comments)
  • Read changed files + callers/callees (run_proof, _requirements_for_run, list_requirements, _check_merge_gate)
  • Analyze for logic/contract/security defects
  • Post confirmed finding
  • Post summary

The conflation bug from the first pass is fixed correctly: the empty-results branch now consults ledger.list_requirements(workspace) (all statuses) and routes a truly-empty ledger to exit 2 and a non-empty one to the scope-filtered message at exit 0. I verified the exit-code logic against runner._requirements_for_run — exit 2 fires only when the ledger is genuinely empty, so there is no false-positive CI failure on a healthy ledger.

1 finding.

severity file:line finding
minor codeframe/cli/proof_commands.py:201-214 The non-empty-ledger branch hard-codes the scope-filter framing, but it is also reached in --full mode (where scope is never computed). For a workspace whose requirements are all WAIVED, cf proof run --full returns {}, hits this branch, and prints "none apply to the changed files" + "Run cf proof run --full" — false and circular (the user just ran --full). Exit code (0) is correct; only the message/remediation is wrong. Inline comment + suggestion posted.

Reviewed and cleared (no defect):

  • Exit-code split itself — list_requirements returns all statuses, so a non-empty ledger never reaches the exit-2 path and an empty ledger always does; no false "not a pass" on a populated ledger, no vacuous green on an empty one.
  • pr_commands.py:465-479 (_check_merge_gate) — empty-ledger warning is guarded by if not blocking_reqs (computed from list_blocking_requirements), queries list_requirements for the true emptiness check, and is wrapped in a reporting-only try/except that cannot affect the merge decision. Correct.
  • --allow-empty semantics and the status_cmd / QUICKSTART.md changes — message text and a correct bool flag; no logic.
  • The empty-ledger test suite (tests/cli/test_proof_empty_ledger_1118.py) patches codeframe.core.proof.runner.run_proof, which the CLI re-imports per call inside run() — so the patch takes effect correctly and the assertions match the implementation.

Note (not a defect in this diff, pre-existing in untouched runner.py): run_proof still persists the empty-ledger run as overall_passed=True (runner.py:300-314), so the ledger record says "passed" while the CLI now says "not a pass." That is outside this PR's files, but it is the data-layer half of #1118 if you want cf proof history/the web UI to agree with the new exit code.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review: #1137 (update) — an empty ledger is not a pass

The second commit (57c5a81) correctly fixes the scope-vs-empty conflation I flagged in my previous review: cf proof run now distinguishes a genuinely empty ledger (exit 2, "capture your first requirement") from a scope-filtered run where requirements exist but none intersect the changed files (exit 0, points at --full). The new TestAScopeFilteredRunIsNotAnEmptyLedger tests cover that distinction well, including the "must not fail CI on a doc-only PR" case. Good fix, and the commit message is honest about it being a regression in the PR's own first version rather than an inherited bug.

New finding: the scope-skip message conflates scope with status, and ignores full

codeframe/cli/proof_commands.py (the if all_reqs: branch, ~line 197-207):

```python
all_reqs = _ledger.list_requirements(workspace) # ALL statuses, unfiltered
...
if all_reqs:
console.print(f"... {len(all_reqs)} requirement(s) exist, but none apply to the changed files.")
console.print("Run cf proof run --full to check all of them regardless of scope.")
return
```

all_reqs uses ledger.list_requirements(workspace) with no status filter, so it's true whenever any requirement row exists — regardless of why run_proof returned {}. But run_proof/_requirements_for_run (codeframe/core/proof/runner.py:249-264, 298-314) can return {} for reasons that have nothing to do with file scope:

  • All requirements are WAIVED. _requirements_for_run only ever considers OPEN (+ SATISFIED under --full); WAIVED is excluded unconditionally. If a workspace's only requirements are waived, _requirements_for_run returns [] and run_proof short-circuits at line 300 before scope filtering ever runs — for both --full and non---full. The CLI still reports "none apply to the changed files" (false — scope was never evaluated) and, worse, tells the user to cf proof run --full even if they already passed --full, since the branch never checks the full variable that's already in scope in this function.
  • All requirements are SATISFIED and this is a non---full run. Same early-return path; the "run --full" advice happens to be correct here, but the stated reason ("none apply to the changed files") is still wrong — it's a status filter, not a scope filter.

Concretely: cf proof run --full on a workspace whose only requirement is waived will print "1 requirement(s) exist, but none apply to the changed files. Run cf proof run --full to check all of them regardless of scope." — even though the user just ran --full and no amount of --full will ever check a waived requirement.

This is narrower than the original bug (exit code is still correct — 0, and it still honestly says "nothing was verified" up top), but the explanatory line is actively misleading in the waived-only case and the remedy suggestion doesn't degrade gracefully when full is already True. Worth branching on the actual reason (e.g. check _requirements_for_run's inputs directly, or at least skip the "--full" suggestion when full is already true and/or when the only non-open requirements are waived) rather than inferring "scope" from "a row exists somewhere in the ledger."

Still open from my last review

No test was added for the "PROOF9: the ledger is empty" print added to _check_merge_gate (codeframe/cli/pr_commands.py:471-481) — tests/cli/test_pr_commands.py and tests/cli/test_merge_gate_subdir_926.py are unchanged in this PR. The logic itself still looks correct (unfiltered list_requirements is the right check for "is the ledger empty," and it's wrapped in a reporting-only try/except that can't affect the merge decision), just untested.

Everything else

Exit-code scheme (0/1/2), --allow-empty default/behavior, status_cmd wording, and the docs/QUICKSTART.md update all still look consistent and correct. No security concerns, no new HTTP surface — CLI-only.

Comment thread codeframe/cli/proof_commands.py
Second review finding. The "requirements exist but nothing ran" branch
hard-coded the scope-filter explanation, but it is reachable in --full mode too —
where runner.py skips the scope check entirely. An all-waived ledger lands there:
_requirements_for_run excludes WAIVED from both modes, so the runnable set is
empty while list_requirements() is not.

The result was a message that was confidently wrong ("none apply to the changed
files" when scope was never consulted) followed by circular advice ("run --full"
to someone who just did).

Now branched on the mode:

  scoped  -> "none apply to the changed files", points at --full
  --full  -> "none are runnable (N waived)", points at cf proof status and
             notes a waiver is an accepted risk that no run re-checks

Exit code was already correct (0) in both; only the explanation was wrong.

Full suite: 6396 passed, 49 skipped.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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


GLM bug-hunting review — #1137 (re-review after latest synchronize)

Scoped to concrete defects only (logic, contract, security, data loss). Style, naming, architecture, and test coverage are covered by the general review.

  • Gather PR context (current diff + existing comments)
  • Read changed files + callers/callees (run_proof, _requirements_for_run, list_requirements, _check_merge_gate)
  • Analyze for logic/contract/security defects
  • Post confirmed finding
  • Post summary

The --full/all-waived misreport found last round is fixed correctly: proof_commands.py:206-220 now branches on full and gives the waived case its own message. The exit-code split itself is sound — list_requirements returns all statuses, so a non-empty ledger never reaches the exit-2 path and an empty ledger always does. No false "not a pass" on a populated ledger, no vacuous green on an empty one.

1 finding.

severity file:line finding
minor codeframe/cli/proof_commands.py:225 The scoped branch's "none apply to the changed files" also fires when there are no OPEN requirements (all SATISFIED/WAIVED) — run_proof short-circuits at runner.py:300 before scope is evaluated, so the stated reason is false. Symmetric to the all-waived/--full case fixed in this same commit. Exit code (0) unaffected.

Reviewed and cleared (no defect):

  • _check_merge_gate (pr_commands.py:465-479) — the empty-ledger warning is guarded by if not blocking_reqs, queries unfiltered list_requirements for the true emptiness check, and sits in a reporting-only try/except that cannot affect the merge decision. Correct.
  • Exit-code logic — --allow-empty only relaxes the empty-ledger path (2→0); the non-empty-ledger / empty-result path already exits 0, so the flag is correctly inert there.
  • ReqStatus import inside the guarded try (proof_commands.py:194) — on import failure all_reqs falls back to [] (→ exit 2), a deliberate defensive choice the comment calls out.
  • status_cmd and QUICKSTART.md changes — message text only, no logic.

Note (pre-existing, outside this PR's files): run_proof still persists the empty-ledger run as overall_passed=True (runner.py:300-314), so the ledger record says "passed" while the CLI now says "not a pass" — the data-layer half of #1118 if cf proof history/the web UI are meant to agree with the new exit code.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review: #1137 (update after 9cafa2f) — an empty ledger is not a pass

The third commit fixes the second finding cleanly: cf proof run --full on an all-waived ledger no longer tells the user to re-run --full (which they just did). It now branches on full and, in that mode, counts and names the waivers, pointing at cf proof status instead. test_full_mode_does_not_blame_the_changed_files and test_full_mode_names_waivers_when_that_is_the_reason cover it. Good fix.

One more instance of the same conflation (minor)

codeframe/cli/proof_commands.py:202-233 still collapses two different reasons for an empty non---full result into one message:

  • Genuinely scope-skipped: _requirements_for_run (runner.py:261) returns OPEN requirements, but every one has a scope that doesn't intersect the changed files (runner.py:334-342). "none apply to the changed files" is accurate here.
  • All matching requirements are already SATISFIED: in non---full mode, _requirements_for_run only loads ReqStatus.OPEN (runner.py:261-263) — SATISFIED is deliberately excluded so a default run never re-verifies it, mirroring how --full deliberately excludes WAIVED. If a workspace's only requirements are SATISFIED, reqs is [] and run_proof hits the early return at runner.py:300-314 — scope is never consulted at all (changed_scope/intersects are only reached after that early return).

Both land in the else branch (full is False in both) and get the same "N requirement(s) exist, but none apply to the changed files. Run cf proof run --full" text. For the SATISFIED case that's not true — the requirements may well apply to the changed files, they're just already verified and non---full mode doesn't re-check them. The suggested remedy (--full) happens to still be correct advice here (unlike the waived case fixed in this commit, it isn't circular — --full really does include SATISFIED and will re-run them), so this doesn't reproduce the CI-failure or circular-advice bugs from the last two rounds. It's a smaller version of the same root cause: the branch infers "why nothing ran" from the full flag alone rather than from which requirements exist and why they were excluded.

Same shape as the two prior findings, so flagging for consistency; not blocking given the milder impact (exit code still correct at 0, and the fallback advice still works).

Still open: merge-gate print has no test

Noted in both previous reviews — still true after this commit. The "PROOF9: the ledger is empty" print added to _check_merge_gate (codeframe/cli/pr_commands.py:466-481) has no coverage; tests/cli/test_pr_commands.py and tests/cli/test_merge_gate_subdir_926.py are untouched by this PR. Logic still looks correct (unfiltered list_requirements is the right emptiness check, wrapped in a reporting-only try/except), just unverified by a test.

Everything else

Exit-code scheme (0/1/2), --allow-empty, status_cmd, and the docs/QUICKSTART.md wording are unchanged from the last round and still consistent. No security concerns, no new HTTP surface — CLI-only, consistent with the Golden Path constraint.

Comment thread codeframe/cli/proof_commands.py Outdated
Third review finding of the same shape, so this stops patching branches and
changes the approach.

The CLI was inferring the reason for an empty result from the --full flag.
That is wrong because run_proof short-circuits when the runnable set is empty,
*before* scope is ever computed — so an all-SATISFIED scoped ledger, or an
all-WAIVED one in either mode, was reported as "none apply to the changed files"
when scope had not been consulted at all.

The root cause is two places independently deciding what "runnable" means. The
CLI now calls the runner's own _requirements_for_run, so it cannot drift.

Four causes, four accurate messages:

  ledger empty              "no obligations in this workspace"   exit 2
  nothing runnable          "none are runnable (1 waived)"       exit 0
  nothing in scope (scoped) "none apply to the changed files"    exit 0
  runnable, no results      states only what is known            exit 0

The scope branch is now guarded on `not full`, since --full never evaluates
scope; the remaining --full case says what is known rather than inventing a
fourth reason.

Test hygiene: assertions were matching against Rich's hard-wrapped output, so
"changed files" failed to match text that was present. Added a whitespace
flattener — otherwise those tests fail cosmetically and get "fixed" by being
weakened.

Full suite: 6398 passed, 49 skipped.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 50s —— View job


GLM bug-hunting review — #1137 (re-review after latest synchronize)

Scoped to concrete defects only (logic, contract, security, data loss). Style, naming, architecture, and test coverage are covered by the general review.

  • Gather PR context (current diff + existing comments)
  • Read changed files + callers/callees (run_proof, _requirements_for_run, list_requirements, _check_merge_gate)
  • Analyze for logic/contract/security defects
  • Post confirmed finding
  • Post summary

The status-vs-scope / waived-vs---full conflations from the prior three rounds are fixed correctly: the CLI now consults _requirements_for_run so its notion of "runnable" cannot drift from the runner's, and the exit-code split itself is sound — a non-empty ledger never reaches the exit-2 path and an empty one always does.

1 finding.

severity file:line finding
minor codeframe/cli/proof_commands.py:212-224 The scoped branch (if runnable and not full:) reports "none apply to the changed files" for every empty-results scoped run with runnable reqs, but a scoped run also yields {} when in-scope requirements produce no obligation results (--gate filter / enabled_gates / zero obligations) — runner.py only records a req in results when req_results is non-empty. The message and the --full remedy are then false. Exit code (0) is correct. Inline comment + suggestion posted.

Reviewed and cleared (no defect):

  • Exit-code routing — runnable is a subset of all_reqs (OPEN ⊆ all; OPEN+SATISFIED ⊆ all), so the branch ordering (runnable → all_reqs → empty) is exhaustive and non-overlapping; the empty-ledger path is the only one that can reach exit 2, and --allow-empty relaxes only that path (2→0), correctly inert on the exit-0 branches.
  • _check_merge_gate (pr_commands.py:465-484) — the empty-ledger warning is guarded by if not blocking_reqs, queries unfiltered list_requirements for the true emptiness check, and sits in a reporting-only try/except that return Nones either way, so it cannot affect the merge decision. Correct.
  • The try/except around the CLI's ledger queries (proof_commands.py:203-210) — on read failure all_reqs, runnable = [], routing to the conservative "nothing was verified" / exit-2 path rather than inventing a reason. Deliberate and safe.
  • status_cmd and QUICKSTART.md — message text only, no logic.

Note (pre-existing, outside this PR's files): run_proof still persists an empty-ledger run as overall_passed=True (runner.py:300-314), so the ledger record says "passed" while the CLI now says "not a pass." That is the data-layer half of #1118 — relevant if cf proof history / the web UI are meant to agree with the new exit code, but it is unchanged by this diff and was already surfaced in earlier rounds.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review: #1137 (final pass, at 2e52d569)

This has already been through four commits and several rounds of review (both mine and the GLM bot's), each catching a real regression in the previous fix. Re-verified the current state against codeframe/core/proof/runner.py rather than re-deriving the logic from scratch:

The scope/status conflation is now correctly fixed

The last two rounds flagged that the "nothing ran" branch in codeframe/cli/proof_commands.py kept inferring why from the full flag alone, which mis-attributed all-SATISFIED and all-WAIVED ledgers to "out of scope." The current version (proof_commands.py:199-201) fixes this properly: it calls _requirements_for_run(workspace, full=full) directly — the same selector run_proof uses internally (runner.py:299) — instead of re-deriving an approximation of it. That closes off the whole class of bug the last three rounds were chasing one branch at a time, since the CLI's notion of "runnable" now can't drift from the runner's. I checked the branch ordering against runner.py's actual short-circuit behavior (scope is only ever consulted when not full, confirmed at runner.py:326-328) and the four cases (runnable and not full → scope; runnable in full mode → can't actually happen since every runnable req executes; all_reqs non-runnable → satisfied/waived breakdown; truly empty → the #1118 case) are now mutually exclusive and exhaustive given ReqStatus only has three values (OPEN/SATISFIED/WAIVED). Good fix.

Still open from earlier rounds (unaddressed across all 4 commits)

_check_merge_gate's new "PROOF9: the ledger is empty" print (codeframe/cli/pr_commands.py:465-479) still has no test. This was raised in three consecutive review rounds and tests/cli/test_pr_commands.py / tests/cli/test_merge_gate_subdir_926.py remain untouched. The logic itself is still correct on inspection — unfiltered list_requirements is the right emptiness check, and it's wrapped in a reporting-only try/except that can't affect the merge decision — but it's the one piece of this PR's behavior with zero coverage. Given how much iteration the reasoning logic in proof_commands.py needed to get right, I'd want at least one regression test here too before this ships.

New observation: CLI/API surfaces now disagree

codeframe/ui/routers/pr_v2.py::merge_pull_request (the web UI's merge gate, same list_blocking_requirements check) has no equivalent of the "ledger is empty" notice this PR adds to the CLI's _check_merge_gate. Both call sites compute blocking_reqs the same way and both allow the merge when it's empty, but only the CLI now explains why it's empty vs. genuinely verified. That's not a regression this PR introduces — pr_v2.py isn't touched here — but the PR's own framing ("both surfaces must stop implying verification happened," from the description) currently only holds for cf proof run/cf proof status, not for the merge gate's two entry points. Worth a follow-up issue if the intent is for the web UI merge flow to say the same thing.

Everything else

Exit-code scheme (0/1/2), --allow-empty default and scope, status_cmd wording, and the docs/QUICKSTART.md update are all consistent with each other and with the PR description. No security concerns, no new HTTP surface — this PR is CLI-only. The pre-existing overall_passed=True ledger-record mismatch noted in the second GLM pass (runner.py:300-314 persists the empty-ledger run as passed even though the CLI now says "not a pass") is real but outside this PR's files, as already noted — flagging again only because it's the natural next step if cf proof history/the web UI need to agree with the new exit code.

Recommendation: the core logic is now sound after four rounds of iteration — approve modulo adding a test for the merge-gate empty-ledger print, which is a small gap relative to everything else this PR got right.

Comment thread codeframe/cli/proof_commands.py Outdated
Fourth review round on the same message, and the answer is to stop guessing.

run_proof computes the reasons and discards them: it builds scope_skipped
(runner.py:334), hands it to a logger (runner.py:435), and returns only results.
So an empty dict has at least six causes — empty ledger, excluded by status,
excluded by scope, excluded by --gate, disabled in proof_config.json, or no
obligations defined — and the caller has no way to tell them apart. Each of my
previous fixes was a better guess at unknowable information, and each was wrong
somewhere else:

  round 1: claimed scope when the ledger was empty
  round 2: claimed scope in --full mode, where scope is never evaluated
  round 3: claimed scope for an all-WAIVED ledger, where run_proof
           short-circuits before scope is computed
  round 4: claimed scope when --gate excluded every obligation of an
           in-scope requirement

The eligible-requirements branch now states what is known and lists the
candidates, naming the actual --gate value when one was passed. The one case
that IS knowable — runnable set empty while the ledger is not — keeps its
precise message ("none are runnable (1 waived)").

#1138 tracks having run_proof report its reason, which would let this be exact.
That is a core change across ~24 call sites and outside what #1118 asked for.

Full suite: 6399 passed, 49 skipped.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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


GLM bug-hunting review — #1137 (re-review after latest synchronize)

Scoped to concrete defects only (logic, contract, security, data loss). Style, naming, architecture, and test coverage are covered by the general review.

  • Gather PR context (current diff + existing comments)
  • Read changed files + callers/callees (run_proof, _requirements_for_run, list_requirements, _check_merge_gate, scope.py)
  • Analyze for logic/contract/security defects
  • Post confirmed finding
  • Post summary

The 0893cbe rewrite is the right shape: it stops inferring why run_proof returned {} from the --full flag and instead asks the runner's own _requirements_for_run, so the CLI's notion of "runnable" cannot drift from the runner's. The exit-code split is sound — verified against ReqStatus (exactly OPEN/SATISFIED/WAIVED, models.py:61-66), so the three branches (runnableall_reqs → empty ledger) are mutually exclusive and exhaustive, and only the truly-empty-ledger path reaches exit 2 (--allow-empty relaxes only that path, 2→0).

1 finding.

severity file:line finding
minor codeframe/cli/proof_commands.py:229-233 The "Possible reasons" candidate list always includes "none of them cover the changed files", but --full mode never consults scope (runner.py:326-328,339), so that reason is impossible there and misleads the user. Inline comment + suggestion posted.

Reviewed and cleared (no defect):

  • Exit-code routing — runnable (OPEN, +SATISFIED under --full) is a subset of all_reqs (all statuses), so the branch ordering is exhaustive and non-overlapping; only an empty ledger reaches exit 2, and --allow-empty is correctly inert on the exit-0 branches.
  • _requirements_for_run double-call consistency — the CLI's call after run_proof agrees with the runner's internal call: check_expired_waivers (runner.py:294) runs before both, and the empty-results path performs no status mutations in the loop (mark_satisfied/reopen_requirement/save_requirement only fire when req_results is non-empty, runner.py:380), so a post-expiry waiver can't make the two disagree.
  • _check_merge_gate (pr_commands.py:465-482) — empty-ledger warning is guarded by if not blocking_reqs, queries unfiltered list_requirements for the true emptiness check, and sits in a reporting-only try/except that return Nones either way, so it cannot affect the merge decision. Correct.
  • The try/except around the CLI's ledger queries (proof_commands.py:203-210) — on read failure all_reqs, runnable = [], routing to the conservative exit-2 "not a pass" path rather than inventing a reason. Deliberate and safe.
  • gate_filter.value access — guarded by is not None (proof_commands.py:230); Gate enum .value is valid. Safe.
  • status_cmd and QUICKSTART.md changes — message text only, no logic.

Note (pre-existing, outside this PR's files): run_proof still persists an empty-ledger run as overall_passed=True (runner.py:300-314), so the ledger record says "passed" while the CLI now says "not a pass." That is the data-layer half of #1118 — relevant if cf proof history / the web UI are meant to agree with the new exit code, but it is unchanged by this diff and was already surfaced in earlier rounds. #1138 (runner reporting its reason) is the tracked follow-up.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review: #1137 (final pass, at 0893cbe)

Five commits and six rounds of review (mine and the GLM bot's) later, each catching a real regression in the previous fix — the pattern held once more. Re-checked the final diff against the --full-never-consults-scope invariant that rounds 2–4 of this same thread established (runner.py:326-328, confirmed in earlier reviews), because the last commit's own message says it's now "stating what's known" rather than guessing.

New finding: the "possible reasons" list still asserts scope in --full mode

codeframe/cli/proof_commands.py, the if runnable: branch added in 0893cbe:

reasons = ["none of them cover the changed files"]
if gate_filter is not None:
    reasons.append(f"--gate {gate_filter.value} excluded their obligations")
reasons.append("their obligations are disabled in proof_config.json")
reasons.append("they have no obligations defined")
console.print("Possible reasons: " + "; ".join(reasons) + ".")
if not full:
    console.print("Try cf proof run --full to ignore scope, or cf proof status ...")
else:
    console.print("See cf proof status for the ledger.")

The remedy line is correctly gated on not full (fixing the circular "run --full" advice from round 2). But "none of them cover the changed files" is unconditionally the first item in reasons, printed in --full mode too. That's the exact conflation rounds 2–4 fixed three separate times: in --full mode scope is never evaluated, so this isn't a "possible" cause among several — it's one that cannot have occurred, stated as if it might have.

Concretely: an OPEN requirement whose obligations produce no results under --gate unit --full (or where enabled_gates disables them) now prints "Possible reasons: none of them cover the changed files; --gate unit excluded their obligations; ..." — still telling a --full user their scope might be the problem.

This looks like it slipped through because the test that used to guard exactly this (test_full_mode_does_not_blame_the_changed_files, asserting "changed files" not in result.output) was renamed to test_full_mode_does_not_offer_full_as_the_remedy in this commit and now only asserts "--full" not in result.output — the assertion that would have caught this regression was dropped at the same commit that reintroduced the bug it was guarding against.

Suggested fix: gate the first reason the same way the remedy line already is, e.g. build the list conditionally:

reasons = []
if not full:
    reasons.append("none of them cover the changed files")
if gate_filter is not None:
    ...

Still open from earlier rounds

_check_merge_gate's "PROOF9: the ledger is empty" print (codeframe/cli/pr_commands.py:465-479, added in the first commit) still has no test across all five commits — raised in three previous rounds. Logic still looks correct on inspection (unfiltered list_requirements, reporting-only try/except), just unverified.

Everything else

The scope/status/waived conflations from rounds 1–4 are otherwise fixed correctly, and I re-verified the exit-code split (0/1/2), --allow-empty default, and the empty-ledger vs. "nothing runnable" branching are sound. docs/QUICKSTART.md and status_cmd wording are unchanged and still consistent. No security concerns, no new HTTP surface — CLI-only, consistent with the Golden Path constraint.

Comment thread codeframe/cli/proof_commands.py Outdated
Fifth review round. The candidate list always included "none of them cover the
changed files", but --full never consults scope (runner.py:339), so the honest
enumeration itself contained an impossible cause.

Candidates are now filtered to the invocation:

  scoped, no gate    scope, config, no obligations
  scoped + --gate    scope, --gate <value>, config, no obligations
  --full + --gate    --gate <value>, config, no obligations

Two tests pin both omissions: scope absent under --full, and the --gate
candidate absent when no gate was passed.

Full suite: 6401 passed, 49 skipped.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 2m 40s —— View job


GLM bug-hunting review — #1137 (re-review after latest synchronize)

Scoped to concrete defects only (logic, contract, security, data loss). Style, naming, architecture, and test coverage are covered by the general review.

  • Gather PR context (current diff + existing comments)
  • Read changed files + callers/callees (run_proof, _requirements_for_run, list_requirements, _check_merge_gate, reasons enumeration)
  • Analyze for logic/contract/security defects
  • Post confirmed finding(s)
  • Post summary

The f173a73 fix is correct: the "none of them cover the changed files" candidate is now gated on not full, matching the runner's --full-never-consults-scope invariant (runner.py:327,339changed_scope isn't even computed under --full, and the scope-skip at runner.py:339-342 is guarded by not full). Listing scope as a possible cause under --full would have been an impossible cause; it is now correctly omitted there and retained on scoped runs.

I re-derived the candidate list against the actual causes of {} when runnable is non-empty (the only path that reaches the enumeration):

  • scope filter — only consulted when not full (runner.py:339) → gated ✓
  • --gate filter — only applied when gate_filter is not None (runner.py:355) → gated ✓
  • enabled_gates config — mode-independent (runner.py:359) → always listed ✓
  • no obligations defined — mode-independent (empty for obl in req.obligations: loop, runner.py:353) → always listed ✓

These four are exhaustive for an empty results with a non-empty runnable set (_run_gate catches its own exceptions and returns FAILED rather than dropping a result, so a gate error can't empty results). The reasons list can never be empty (config + no-obligations are unconditional), so "; ".join(reasons) has no edge case.

✅ GLM review: no defects found.

Reviewed and cleared (no defect):

  • Exit-code routing — runnable (OPEN, +SATISFIED under --full) is a subset of all_reqs (all statuses), so the branch ordering runnable → all_reqs → empty ledger is exhaustive and non-overlapping. Only a truly-empty ledger reaches exit 2; --allow-empty relaxes only that path (2→0) and is inert on the exit-0 branches.
  • _requirements_for_run double-call consistency — the CLI's post-run_proof call agrees with the runner's internal call (runner.py:299): check_expired_waivers runs before both (runner.py:294), and the empty-results path performs no status mutations (mark_satisfied/reopen_requirement/save_requirement only fire when req_results is non-empty, runner.py:380), so a waiver expiring mid-run cannot make the two disagree.
  • gate_filter.value access (proof_commands.py:235) — guarded by is not None; Gate enum .value is valid. Safe.
  • The try/except around the CLI's ledger queries (proof_commands.py:203-210) — on read failure all_reqs, runnable = [], routing to the conservative exit-2 "not a pass" path rather than inventing a reason. Deliberate and safe.
  • _check_merge_gate (pr_commands.py:465-482, unchanged this commit) — the empty-ledger notice is guarded by if not blocking_reqs, queries unfiltered list_requirements for the true emptiness check, and sits in a reporting-only try/except that return Nones either way, so it cannot affect the merge decision. Correct.

Note (pre-existing, outside this PR's files, tracked as #1138): run_proof still persists an empty-ledger run as overall_passed=True (runner.py:300-314), so the ledger record says "passed" while the CLI now says "not a pass." That is the data-layer half of #1118 — relevant if cf proof history / the web UI are meant to agree with the new exit code, but it is unchanged by this diff and was already surfaced in earlier rounds.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review: #1137 (at f173a739, 6th commit)

This has already been through five commits and multiple review rounds (mine and the GLM bot's), each catching a real regression in the one before. I re-verified the current state against codeframe/core/proof/runner.py rather than trusting the prior threads.

The last finding is fixed correctly

The previous round flagged that "none of them cover the changed files" was unconditionally the first item in the reasons list, even under --full (where runner.py:326-328,339 never evaluates scope). The current codeframe/cli/proof_commands.py:229-233 now gates it:

```python
reasons = []
if not full:
reasons.append("none of them cover the changed files")
```

I checked this against test_full_mode_omits_scope_from_the_candidates — it asserts "changed files" not in flat under --full --gate unit, which is exactly the regression this closes, and it exercises the actual code path rather than a proxy assertion (the previous round's bug slipped through because the guarding test was renamed to check something weaker at the same commit that reintroduced the bug — worth remembering next time a test rename accompanies a logic change here).

With this fix, the four-way split (runnable non-empty → possible-reasons; all_reqs non-empty but nothing runnable → satisfied/waived breakdown; truly empty → the #1118 case) is now internally consistent: every claim in every branch is something the CLI can actually know, derived from _requirements_for_run (the same selector run_proof uses internally) rather than inferred from the --full flag. That closes off the class of bug the last four rounds were chasing one branch at a time.

Still open, unaddressed across all six commits

_check_merge_gate's "PROOF9: the ledger is empty" print (codeframe/cli/pr_commands.py:465-482, added in the first commit) still has no test. This has been raised in at least four previous review rounds; tests/cli/test_pr_commands.py and tests/cli/test_merge_gate_subdir_926.py remain untouched by every commit in this PR. The logic is still correct on inspection — unfiltered list_requirements is the right emptiness check, and it's wrapped in a reporting-only try/except that returns None either way so it can't affect the merge decision — but given how many iterations it took to get the other empty-result branches right, I'd want this covered before merge too. It's a small, mechanical addition at this point (patch list_requirements to return [], invoke _check_merge_gate, assert the yellow line and that the merge still proceeds).

Also still true, non-blocking

codeframe/ui/routers/pr_v2.py::merge_pull_request (the web UI's merge gate) has no equivalent "ledger is empty" notice — it computes blocking_reqs the same way and allows the merge the same way, but only the CLI now explains why an empty result isn't a verification. Not a regression introduced here (pr_v2.py isn't touched), and the PR description's "both surfaces must stop implying verification happened" framing was scoped to cf proof run/cf proof status, which is honestly satisfied. Flagging again only as a natural follow-up if the web UI merge flow should say the same thing.

Also unchanged from earlier rounds: run_proof still persists an empty-ledger run as overall_passed=True (runner.py:300-314) — the ledger record says "passed" while the CLI now says "not a pass." Outside this PR's files; the tracked follow-up (#1138) for the runner to report its own reason would also let this be resolved precisely instead of via the CLI's best-effort re-derivation.

Everything else

Exit-code scheme (0/1/2), --allow-empty default and behavior, status_cmd wording, and the docs/QUICKSTART.md update are all consistent with each other and with the PR description. No security concerns, no new HTTP surface — this PR is CLI-only, consistent with the Golden Path constraint. CI is green except Backend Unit Tests still in progress at review time.

Recommendation: the core reasoning logic is now sound and well-covered after six rounds of iteration. The one gap worth closing before merge is the missing test for the merge-gate empty-ledger print — everything else here looks ready.

@frankbria
frankbria merged commit dcfb4fc into main Aug 10, 2026
17 checks passed
frankbria added a commit that referenced this pull request Aug 11, 2026
`run_proof` returned only `dict[req_id, [(gate, outcome)]]`, and an empty dict
has six causes. The runner computed them and threw them away — `scope_skipped`
got as far as a log line — so `cf proof run` had to infer the reason from the
flags. Four consecutive review rounds on #1137 caught it inferring wrong, each
time about a different cause.

`run_proof_with_diagnostics` returns `(results, ProofRunDiagnostics)`.
`run_proof` stays as a one-line wrapper, so the ~60 call sites that only want
the dict are untouched — the additive approach the issue asks for.

`EmptyReason` distinguishes: no requirements, excluded by status, excluded by
scope, excluded by the --gate filter, excluded by enabled_gates, excluded by
the two filters jointly, no obligations defined, and MIXED. Diagnostics carries
the per-bucket requirement ids, not just a verdict: with three requirements
dropped for three reasons, any single reason is wrong, so `reason` collapses
only when the collapse is honest and `describe()` enumerates otherwise.

The CLI now names the cause and gives the matching remedy instead of printing a
candidate list. `_report_scope_skipped` is unchanged (#922's warning still
fires); the empty-ledger exit-2 behaviour from #1118 is unchanged.

The joint-filter case came from review and is worth spelling out: with
obligations {unit, sec}, `--gate unit` and `enabled_gates: ["sec"]`, NEITHER
filter excludes everything on its own, so both `all(...)` checks were false and
the requirement fell into no bucket — surfacing as an unclassified MIXED with
no hint at all. Each filter is now tested for sufficiency separately, and the
joint case has its own reason and its own remedy.

Updating tests/cli/test_proof_empty_ledger_1118.py was not optional: it patched
`runner.run_proof`, which the CLI no longer calls, so the patch quietly stopped
applying and the tests began asserting against a live run. That failed loudly
here, but it is the same class of thing as #1077 — a guard patching a function
nothing calls. Most of those cases are now driven for real instead: a waived
ledger, a --gate that matches no obligation, and a requirement with no
obligations are all reachable states. Only the scope filter still needs a patch,
because with no working-tree changes the detector fails closed.
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.32] cf proof run exits 0 with 'No applicable obligations found' — the quickstart's PROVE step is a vacuous pass

1 participant