Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/actions/prose-gate/prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -1846,8 +1846,10 @@ def main(argv: list[str] | None = None) -> int:
return 0

discovered = len(files)
scope = changed_lines(a.diff, scan_root) if a.diff else None
if a.diff and scope is None:
# Presence rather than truthiness, since an empty value is a base that failed to compute.
# Reading it as no diff at all is the silent widening the refusal below exists to prevent.
scope = changed_lines(a.diff, scan_root) if a.diff is not None else None
if a.diff is not None and scope is None:
# Widening to the whole tree answers a different question, and answers it silently.
# A caller scoping to a change gets the backlog reported as though the change made it.
# A CI adoption hits this first, where an unresolvable base walls off the first run.
Expand Down
33 changes: 33 additions & 0 deletions scripts/tests/test_prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -1899,6 +1899,24 @@ def test_a_failed_diff_is_an_error_rather_than_a_wider_or_narrower_scan(self) ->
):
self.assertEqual(2, prose_lint.main(["--check", "dupword", "--diff", "HEAD"]))

def test_an_empty_diff_value_takes_the_refusal_rather_than_widening(self) -> None:
"""An empty value is a base that failed to compute, never a request for a whole tree.

A caller substituting a command's output, `--diff "$(git merge-base origin/develop HEAD)"`,
passes an empty string whenever that command fails. Read as truthiness the option
vanishes, the refusal `main` holds for a base it cannot resolve is never reached, and the
run reports the whole tree's backlog as though the change introduced it.
"""
bait = self.tmp / "bait.md"
bait.write_text(f"{DUP} thing\n", encoding="utf-8")
with (
mock.patch.object(prose_lint, "discover", return_value=[bait]),
mock.patch.object(prose_lint, "changed_lines", return_value=None) as changed,
):
self.assertEqual(2, prose_lint.main(["--check", "dupword", "--diff", ""]))
# Pins that the resolver received the empty value itself, rather than a coerced stand-in.
self.assertEqual("", changed.call_args.args[0])

def test_list_files_prints_the_scope_and_reports_nothing(self) -> None:
"""The audit path for the sweep scope exits 0 even on a tree full of findings."""
bait = self.tmp / "bait.md"
Expand Down Expand Up @@ -2672,6 +2690,21 @@ def test_scanning_one_repository_while_standing_in_another_diffs_the_one_scanned
self.git(there, "commit", "-qam", "change")
self.assertEqual(1, self.run_in(here, str(there), "--diff", "HEAD~1"))

def test_an_empty_base_is_refused_by_a_real_git_rather_than_by_a_mock(self) -> None:
"""The precondition the empty-value refusal rests on, asserted against git itself.

`main` routes an empty `--diff` into the resolver rather than treating it as no diff at
all, which is only a refusal because git rejects an empty revision.
`TestCli.test_an_empty_diff_value_takes_the_refusal_rather_than_widening` mocks the
resolver, so it would stay green if git ever resolved one. Resolving one to an empty scope
reads as a clean tree, which is the false pass the routing exists to close.
"""
root = self.repo()
(root / "DOC.md").write_text(self.BAIT, encoding="utf-8")
self.git(root, "commit", "-qam", "change")
self.assertEqual(1, self.run_in(root, ".", "--diff", "HEAD~1"))
self.assertEqual(2, self.run_in(root, ".", "--diff", ""))

def test_a_subtree_argument_reads_the_untracked_files_inside_it(self) -> None:
"""`git ls-files` prints names relative to its `-C` directory, for `--others` as well.

Expand Down