Skip to content

Read an Empty prose_lint --diff as a Failed Base, Not as No Diff - #1566

Merged
ptr727 merged 1 commit into
developfrom
fix/1537-empty-diff-scope
Sep 12, 2026
Merged

Read an Empty prose_lint --diff as a Failed Base, Not as No Diff#1566
ptr727 merged 1 commit into
developfrom
fix/1537-empty-diff-scope

Conversation

@ptr727

@ptr727 ptr727 commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Fixes #1537, which registers on the develop -> main promotion rather than here.

The defect

prose_lint.py decided whether a run was diff-scoped by the truthiness of --diff rather than by its presence, so an empty value read as no --diff at all. The run then widened to the whole tree and reported the existing backlog as though the change had introduced it, which is the outcome the refusal on the very next line exists to prevent. An unresolvable ref took that refusal while an empty string walked past it.

The exposed path is a local run whose caller substitutes a command's output, --diff "$(git merge-base origin/develop HEAD)". Where origin/develop does not exist, as in a single-branch clone of main, git merge-base writes only to stderr and the script receives --diff "". It was found while documenting that invocation for ptr727/aiopurpleair.

The fix

Both the scope computation and the refusal below it test a.diff is not None, so an empty value reaches changed_lines, where git diff '' fails with fatal: bad revision '', and the existing refusal fires naming the value it could not resolve.

The issue's own reproduction, against this branch:

$ python3 prose_lint.py --diff "" -- .
error: cannot diff against '', so the run cannot be scoped to changed lines. Refusing to scan
the whole tree instead, since that reports the existing backlog as though this change introduced
it. Check the ref exists and that the checkout carries its history.
exit=2

It previously exited 1 on a.md:1: semicolon, a line the change never touched.

Tests

Two, because one of them alone would not hold the fix:

  • A unit test asserts the empty value reaches the resolver and takes the refusal, rather than asserting the exit code alone, which the whole-tree path could also produce for its own reasons.
  • A second test runs the same case against a real repository, since the first mocks the resolver and would stay green if git ever resolved an empty revision. Resolving one to an empty scope reads as a clean tree, which is the other half of what the routing closes.

Each was proved by reverting the two production lines, where both fail with 2 != 1, the whole-tree widening itself.

Reach

No caller in the tree can pass an empty-but-present value today, so nothing that works now starts refusing. .github/actions/prose-gate/action.yml rejects an unresolvable $BASE before invoking the script, .github/workflows/validate-task.yml gates that step on a pull request event, and the Husky, pre-commit and hub-fetch-run.py callers all pass a literal HEAD. scope_note already tested base is None, so no truthiness site was left disagreeing with the new one, and gate_provenance keeps truthiness deliberately, where an empty value falling through to the next source is what it wants.

Verification

Full local gate set green on this head: 270 cases in scripts/tests/test_prose_lint.py and 1405 across the suite, ruff format --check, ruff check, mypy, prose_lint.py over the gating rule set, repo_gate.py, spec/validate.py, build_dist.py --check, canonical_review.py check, and docker_lint.py's seven linters. No canonical unit changed, so no carried-content pass was owed.

Three local strict-review passes ran at the strongest available tier. The first raised three findings and the second six, all answered, which is the two-round edit budget spent. The third found no behavioral defect and four prose findings, open and listed here rather than fixed, per that budget: a missing comma after a fronted participle at test_prose_lint.py:1906, a relative pronoun binding to the nearer noun at :2697, an unexplained positive control at :2705, and both new tests asserting the exit code where the sibling refusal test also asserts the message text.

One earlier finding was declined with evidence: that the refusal's closing sentence, "Check the ref exists and that the checkout carries its history", names a remedy that does not fit an empty value. It is pre-existing text this change did not write, the message prints the value with !r so an empty base displays as '', and growing a per-input remedy enumeration has repeatedly cost this repository more than it returned.

🤖 Generated with Claude Code

`prose_lint.py` decided whether a run was diff-scoped by the truthiness
of `--diff` rather than by its presence, so an empty value read as no
`--diff` at all. The run then widened to the whole tree and reported the
existing backlog as though the change had introduced it, which is the
exact outcome the refusal on the next line exists to prevent. An
unresolvable ref took that refusal while an empty string walked past it.

The exposed path is a local run whose caller substitutes a command's
output, `--diff "$(git merge-base origin/develop HEAD)"`. Where
`origin/develop` does not exist, as in a single-branch clone of `main`,
`git merge-base` writes only to stderr and the script receives
`--diff ""`. The composite action is unaffected, since it verifies
`$BASE` resolves before calling the script.

Testing for presence routes the empty value into `changed_lines`, where
`git diff ''` fails with `fatal: bad revision ''`, so the existing
refusal fires and names the value it could not resolve. The
reproduction in the issue now exits 2 with `cannot diff against ''`
where it previously exited 1 on a line the change never touched.

## What changed

- `main` tests `a.diff is not None` in both the scope computation and
the refusal below it, with a comment naming the caller shape that
produces an empty value, so a later edit does not read the presence test
as a stylistic choice.
- A regression test asserts the empty value reaches the resolver and
takes the refusal, rather than asserting only the exit code, which the
whole-tree path could also produce for its own reasons.
- A second test runs the same case against a real repository, since the
first mocks the resolver and would stay green if git ever resolved an
empty revision. Resolving one to an empty scope reads as a clean tree,
which is the other half of what the routing closes.

Fixes #1537

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 12, 2026 04:51
@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: a567fed3-1ace-474f-b112-9b0e82e0b130


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The change is narrowly scoped, preserves existing refusal semantics, and adds targeted tests that cover both mocked and real-git behavior for the empty --diff case.

Pull request overview

This pull request fixes a scoping defect in .github/actions/prose-gate/prose_lint.py where an empty-but-present --diff value (e.g., --diff "") was treated like the option was absent, causing an unintended whole-tree scan and misattributed backlog findings. The change makes diff-scoping depend on --diff presence (is not None) rather than truthiness, and adds tests to pin the behavior for both mocked and real-git execution paths.

Changes:

  • Treat --diff "" as an invalid diff base (presence-based) so it reaches changed_lines and triggers the existing refusal when git cannot diff against it.
  • Add a unit test asserting the empty diff value is passed through to the resolver and results in the refusal exit code.
  • Add an integration-style test asserting a real git diff '' failure is what enforces the refusal (protecting against future behavior drift).
File summaries
File Description
.github/actions/prose-gate/prose_lint.py Switch --diff handling from truthiness to presence to prevent silent widening on empty values and ensure the refusal path triggers.
scripts/tests/test_prose_lint.py Add unit + integration coverage for --diff "" to ensure it is refused and routed consistently.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@ptr727
ptr727 merged commit e4aa5d8 into develop Sep 12, 2026
9 checks passed
@ptr727
ptr727 deleted the fix/1537-empty-diff-scope branch September 12, 2026 04:55
ptr727 added a commit that referenced this pull request Sep 12, 2026
…Base (#1567)

Promotes one commit from `develop` to `main`.

- `e4aa5d8` Read an Empty prose_lint --diff as a Failed Base, Not as No
Diff (#1566)

`prose_lint.py` decided whether a run was diff-scoped by the truthiness
of `--diff` rather than by its presence, so `--diff ""` read as no
`--diff` at all, the run widened to the whole tree, and it reported the
existing backlog as though the change had introduced it. That is what
the refusal on the next line exists to prevent, and an unresolvable ref
took that refusal while an empty string walked past it. The exposed path
is a local run whose caller substitutes a command's output, `--diff
"$(git merge-base origin/develop HEAD)"`, which yields an empty string
wherever the substituted command fails.

Both the scope computation and the refusal now test `a.diff is not
None`, so an empty value reaches the resolver, `git diff ''` fails, and
the refusal fires naming the value it could not resolve. Two tests cover
it, one mocking the resolver and one against a real repository, since
the mocked one alone would stay green if git ever resolved an empty
revision. No caller in the tree can pass an empty-but-present value
today, so nothing that works now starts refusing.

Copilot reviewed the feature pull request at its merged head, full
coverage, no findings and none suppressed.

Closes #1537
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants