Skip to content

Commit cbb5684

Browse files
ptr727claude
andcommitted
Refuse a Path That Does Not Exist, Rather Than Scanning the Caller's Directory
`discover` reads any argument that is neither a file nor a directory as `.`, so a typo scanned the caller's directory while the rule set anchored on the missing path's parent. The run then described one tree and judged it by another, which is this PR's own subject in a different guise. A missing path is now refused, naming each one. Two existing cases passed fake paths as a mocking convenience and were refused by the new check before reaching the guard they exist to test. They use real directories now, which preserves what they assert rather than loosening the check to accommodate them. Two cases added: a missing path is refused, and an existing one is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f2037e7 commit cbb5684

2 files changed

Lines changed: 36 additions & 2 deletions

File tree

scripts/prose_lint.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,6 +1110,14 @@ def main(argv: list[str] | None = None) -> int:
11101110
# Only a repository declares a model, so two of them refuse and anything else resolves to one anchor.
11111111
# TestScanRootDecidesTheRuleSet carries the cases and the reason each one exists.
11121112
scan_paths = a.paths or ['.']
1113+
# A path that does not exist is refused rather than absorbed.
1114+
# `discover` reads a non-file, non-directory argument as `.`, so a typo scanned the caller's directory while the rule set anchored on the missing path's parent.
1115+
absent = [p for p in scan_paths if not Path(p).exists()]
1116+
if absent:
1117+
print(f"error: requested path(s) do not exist: {', '.join(sorted(absent))}. Refusing "
1118+
'rather than falling back to the current directory, which would scan one tree and '
1119+
'choose the rule set from another.', file=sys.stderr)
1120+
return 2
11131121
git_roots = {found for found in (repo_root(Path(p)) for p in scan_paths) if found}
11141122
if len(git_roots) > 1:
11151123
print('error: the requested paths span more than one repository (' +

scripts/test_prose_lint.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,20 +1528,26 @@ def test_a_path_under_no_repository_is_refused_too(self) -> None:
15281528
repository-relative ones, so the scope drops every file and the run exits 0. Testing for
15291529
a *different* root missed this, because there is no root to differ from.
15301530
"""
1531+
# A real directory, since a path that does not exist is refused earlier for another reason.
1532+
# That would pass this assertion without ever exercising the diff guard.
1533+
loose = self.tmp / 'loose'
1534+
loose.mkdir()
15311535
with mock.patch.object(prose_lint, 'repo_root',
15321536
side_effect=lambda p: '/hub' if str(p) == '.' else ''), \
15331537
contextlib.redirect_stderr(io.StringIO()) as err:
1534-
self.assertEqual(2, prose_lint.main(['--diff', 'HEAD', '/tmp/loose']))
1538+
self.assertEqual(2, prose_lint.main(['--diff', 'HEAD', str(loose)]))
15351539
self.assertIn('no git repository', err.getvalue())
15361540

15371541
def test_list_files_still_reports_scope_across_repositories(self) -> None:
15381542
"""It reports the scan scope and never consults the diff, so the guard must not stop it."""
15391543
clean = self.tmp / 'clean.md'
15401544
clean.write_text('fine\n', encoding='utf-8')
1545+
other = self.tmp / 'other'
1546+
other.mkdir()
15411547
with mock.patch.object(prose_lint, 'repo_root',
15421548
side_effect=lambda p: '/hub' if str(p) == '.' else '/other'), \
15431549
mock.patch.object(prose_lint, 'discover', return_value=[clean]):
1544-
self.assertEqual(0, prose_lint.main(['--list-files', '--diff', 'HEAD', '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/other/tree']))
1550+
self.assertEqual(0, prose_lint.main(['--list-files', '--diff', 'HEAD', str(other)]))
15451551

15461552
def test_a_matching_repository_is_not_refused(self) -> None:
15471553
"""The guard must not reject the ordinary case it sits in front of."""
@@ -1851,6 +1857,26 @@ def test_paths_spanning_two_repositories_refuse_rather_than_pick_one(self) -> No
18511857
[str(self.release), str(self.operational), '--check', 'home-path']))
18521858
self.assertIn('more than one repository', self.err.getvalue())
18531859

1860+
def test_a_path_that_does_not_exist_is_refused_rather_than_absorbed(self) -> None:
1861+
"""`discover` reads a non-file, non-directory argument as `.`.
1862+
1863+
A typo therefore scanned the caller's directory while the rule set anchored on the missing
1864+
path's parent, so the run described one tree and judged it by another.
1865+
"""
1866+
with mock.patch.object(prose_lint, 'repo_root', return_value=''):
1867+
self.assertEqual(2, prose_lint.main(
1868+
[str(self.tmp / 'no-such-dir'), '--check', 'home-path']))
1869+
self.assertIn('do not exist', self.err.getvalue())
1870+
1871+
def test_an_existing_path_is_not_refused_by_that_check(self) -> None:
1872+
"""The guard must not reject the ordinary case it sits in front of."""
1873+
good = self.tmp / 'good.md'
1874+
good.write_text('Nothing here breaks a rule.\n', encoding='utf-8')
1875+
with mock.patch.object(prose_lint, 'repo_root', return_value=''), \
1876+
mock.patch.object(prose_lint, 'discover', return_value=[good]):
1877+
self.assertEqual(0, prose_lint.main([str(good), '--check', 'home-path']))
1878+
self.assertNotIn('do not exist', self.err.getvalue())
1879+
18541880
def test_several_paths_under_no_repository_are_not_a_conflict(self) -> None:
18551881
"""Only a repository declares a model, so two loose paths are not ambiguous.
18561882

0 commit comments

Comments
 (0)