Skip to content

Decide the Rule Set From the Repository Being Scanned, Not the Caller's Directory - #666

Merged
ptr727 merged 9 commits into
developfrom
fix-prose-lint-scan-root
Aug 11, 2026
Merged

Decide the Rule Set From the Repository Being Scanned, Not the Caller's Directory#666
ptr727 merged 9 commits into
developfrom
fix-prose-lint-scan-root

Conversation

@ptr727

Copy link
Copy Markdown
Owner

prose_lint.py chose whether to run home-path by asking whether the current directory was an operational repository, rather than the path it was asked to scan.

Stand in an operational repo, scan a release repo, and the check is discarded. The skip prints to stderr and the run exits 0.

That silences the one rule written because real paths carrying family names reached a public comment. A gate that switches itself off based on where the caller happens to stand is answering a different question than the one it was asked — and answering it as a pass.

Reproduced, and measured against a real violation

A release repo carrying /home/someuser/project/output.log, scanned from an operational checkout:

Before — silent skip, exit 0, violation missed:

note: home-path is not checked in an operational repository, where an absolute
path is the operator instruction rather than observed data.
exit=0

After — found, exit 1, and identical to what running from inside the repo gives:

README.md:3: home-path: absolute home path '/home/someuser' -> use a constructed path, not an observed one
exit=1

The fix

The scanned path decides. Paths spanning two repositories refuse rather than pick one, since each declares its own workflow model and no single rule set can be right for both. A path git cannot place falls back to itself rather than to ., which would quietly put the caller's directory back in charge of the verdict.

The --diff same-root guard is untouched and still correct — git diff genuinely does run in the current directory. But it only ever ran under --diff, so it never covered this path at all.

Why the existing suite missed it

TestOperationalExemption already covers the exemption in five cases. It mocks repo_root to return one value for every argument, which cannot tell the caller's repository from the scanned one — the exact distinction the defect lives in.

The four new cases set those two roots to different models. Three of them fail against the previous code. The fourth guards the fallback against a future regression rather than reproducing the defect, and I am flagging that rather than counting it as four.

Provenance

Reported by the ESPHome-Config agent, from a symptom I could not reproduce — its scenario exits 2 here on the guard that landed in #520. I went looking anyway and found this adjacent defect, which is real.

Independently hit by the HomeAutomation-Config agent within the hour, which had run the gate from a scratch directory and got a clean result worth nothing. It re-ran from inside the checkout and found 190 violations in prose it was about to submit.

Two agents, two different wrong directories, same hour. The tool gave both of them a pass.

Verification

Full local suite green: all five self-tests, audit --selftest, gh-write-guard --selftest, repo_gate.py, prose_lint.py tree-wide, editorconfig-checker.

…'s Directory
`prose_lint.py` chose whether to run `home-path` by asking whether the
*current directory* was an operational repository. Stand in an operational
repo, scan a release repo, and the check was discarded: the skip printed to
stderr and the run exited 0.
That silences the one rule written because real paths carrying family names
reached a public comment. A gate that turns itself off based on where the
caller happens to stand answers a different question than the one it was
asked, and answers it as a pass.
The scanned path now decides. Paths spanning two repositories refuse rather
than pick one, since each declares its own workflow model and no single
rule set is correct for both. A path git cannot place falls back to itself
rather than to `.`, which would put the caller's directory back in charge.
The same-root guard for `--diff` is unchanged and still correct: `git diff`
genuinely does run in the current directory. It only ever ran under
`--diff`, though, so it never covered this.
Four cases added. Three fail against the previous code; the fourth guards
the fallback against a future regression rather than reproducing the
defect. They set the caller's root and the scanned root to *different*
models, which is the distinction `TestOperationalExemption` cannot draw,
since it mocks `repo_root` to one value for every argument. That is why
this survived a suite that already covered the exemption.
Reported by the ESPHome-Config agent from a symptom I could not reproduce,
and independently hit by the HomeAutomation-Config agent the same hour,
whose clean run from a scratch directory was worth nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 11, 2026 01:00

CopilotAI 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.

Pull request overview

This pull request fixes scripts/prose_lint.py so the active rule set (notably the home-path operational-repo exemption) is determined by the repository being scanned, rather than by the caller’s current working directory. This prevents a false-clean outcome where running the gate from an operational checkout could silently disable home-path while scanning a release repository.

Changes:

  • Determine the scan “root” from the provided scan paths and use it (not .) when deciding whether to exempt home-path.
  • Refuse scans that span multiple repositories/roots to avoid applying a single rule set across incompatible workflow models.
  • Add focused regression tests covering differing caller-vs-scanned repository roots and the “git can’t place this path” fallback.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
scripts/prose_lint.pyComputes scan_root from the requested scan paths and uses it to decide the operational exemption; refuses multi-root scans.
scripts/test_prose_lint.pyAdds regression tests ensuring the scanned repository (not caller cwd) decides exemptions and that multi-root scans refuse.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadscripts/prose_lint.py Outdated
ptr727and others added 2 commits August 10, 2026 18:08
…epository
The multi-root refusal said each root "declares its own workflow model",
which is true of a repository and false of the fallback beside it. A path
git cannot place resolves to itself, so a run mixing a repository with a
loose directory was told both declare a model, sending the reader to look
for one in a directory that has none.
The message now names each root and marks the ones that are not
repositories, and says a repository declares a model rather than claiming
all of them do.
One case added, asserting the loose path appears and is marked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

scripts/prose_lint.py:1121

  • scan_roots currently falls back to Path(p).resolve() per requested path, and then refuses whenever there is more than one distinct resolved path. That creates two concrete problems:
  1. It breaks the existing multi-path CLI behavior for non-git inputs (e.g. prose_lint.py README.md docs/guide.md outside a repo): each path resolves to a different absolute path, so the run exits 2 even though there is no workflow model ambiguity.

  2. It can make the operational exemption decision inconsistent with what discover() will actually scan. discover() treats a path that is neither a file nor a dir as . (see discover(): root = p if p.is_dir() else Path('.')), but scan_roots will treat that same raw argument as its own resolved path, potentially changing whether home-path is exempted (or triggering the new multi-root refusal) before discovery runs.

Consider deriving the scan anchor the same way discovery does (dir -> itself, file -> parent, missing -> .), and only refusing when there are multiple distinct non-empty git roots (i.e., multiple repositories), not merely multiple filesystem paths.

 # A path git cannot place resolves to itself, so the roots are not all repositories.
# The refusal below names which is which rather than calling every one of them a repository.
scan_roots = {}
for raw in (a.paths or ['.']):
found = repo_root(Path(raw))
scan_roots[found or str(Path(raw).resolve())] = bool(found)
if len(scan_roots) > 1:
named = ', '.join(root + ('' if is_repo else ' (no git repository)')
for root, is_repo in sorted(scan_roots.items()))

CopilotAI review requested due to automatic review settings August 11, 2026 01:09

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

The fallback added in this PR made every path its own root, so a run
naming two files outside any checkout was refused as spanning two
repositories. Reproduced: `prose_lint.py README.md docs/guide.md` from a
directory under no repository exits 2 here and 0 on develop. That is a
regression this PR introduced, not a pre-existing defect.
Only a repository declares a workflow model, so only distinct repositories
are ambiguous. The refusal now keys on non-empty git roots, which also
removes the need to mark non-repository roots in the message, since none
appear in it.
With no repository in play, the anchor follows discovery's own rule rather
than a second one invented here, so the rule set is decided over the tree
that will actually be read.
Two cases replace the one that asserted the marked-up message: several
loose paths are not a conflict, and one repository beside a loose path is
not either. Two real repositories still refuse, and the original defect
stays fixed, both checked live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 11, 2026 01:15
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Answering the suppressed finding here, since it carries no thread. It was correct, it named a regression this PR introduced, and it is fixed in 492957a.

It breaks the existing multi-path CLI behavior for non-git inputs (e.g. prose_lint.py README.md docs/guide.md outside a repo): each path resolves to a different absolute path, so the run exits 2 even though there is no workflow model ambiguity.

Reproduced exactly as described, against both versions, from a directory under no repository:

$ prose_lint.py README.md docs/guide.md --check home-path
this PR: exit=2 error: ...resolve to more than one root...
develop: exit=0

My fallback — resolving a path git cannot place to the path itself, added so the caller's directory could not creep back in — made every argument its own root. Two files in one loose directory became "two repositories". That is worse than the defect I was fixing, because it turns a working invocation into a hard refusal.

The refusal now keys on non-empty git roots, as you suggested. Only a repository declares a workflow model, so only distinct repositories are ambiguous. Loose paths conflict neither with each other nor with a single repository.

That also dissolves the thread finding above it. With only git roots in the message, no non-repository root can appear in it, so the marking I added an hour ago is gone rather than corrected. The message went back to naming repositories because it now only ever names repositories.

It can make the operational exemption decision inconsistent with what discover() will actually scan.

Taken. With no repository in play, the anchor now follows discovery's own rule rather than a second rule invented here, so the rule set is decided over the tree that will actually be read.

Cases: the one asserting the marked-up message is replaced by two — several loose paths are not a conflict, and one repository beside a loose path is not either. Three of the six in this class still fail against develop, so the original defect stays covered. Checked live as well: two real repositories still refuse, and standing in an operational repo while scanning a release repo still finds the planted violation.

Worth saying plainly: this is the second time in this PR that a fix of mine introduced a narrower version of the problem it was fixing. Both were caught here rather than by me.

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

scripts/prose_lint.py:1130

  • When none of the scan paths are in a git repo, scan_root falls back to Path('.') for file arguments (scan_root = first if first.is_dir() else Path('.')). That reintroduces the original bug for loose files: if the caller happens to be in an operational repo, home-path will be exempted even though the scanned file is not in that repo. Using the file's parent directory keeps the rule-set decision anchored to what is actually being scanned.
 if git_roots:
scan_root = Path(next(iter(git_roots)))
else:
first = Path(scan_paths[0])
scan_root = first if first.is_dir() else Path('.')

scripts/test_prose_lint.py:1898

  • test_a_path_under_no_repository_falls_back_to_itself_rather_than_the_caller currently passes the directory (loose) into prose_lint.main(...), so it never exercises the file-argument path where scan_root previously fell back to Path('.'). This means the regression (caller CWD influencing home-path when scanning a loose file) can slip through. Updating the test to pass the file path and mocking operational_checkout to treat Path('.') as operational will fail on the buggy behavior and pass once scan_root uses the scanned path’s parent.
 with mock.patch.object(prose_lint, 'repo_root', return_value=''), \
mock.patch.object(prose_lint, 'discover', return_value=[bait]):
# Not a repository, so it carries no operational payload and the rule stays on.
self.assertEqual(1, prose_lint.main([str(loose), '--check', 'home-path']))
self.assertNotIn('operational repository', self.err.getvalue())

The no-repository fallback anchored a file argument on `.`, which is the
caller's directory. Scanning a loose file from inside an operational
repository therefore exempted it, which is this PR's own defect in
miniature, reintroduced by the fix for the previous round's regression.
A file now anchors on its own parent. A bare filename still anchors on `.`
and that stays correct, since `.` is the directory holding it.
The test meant to guard this passed a directory, so it never reached the
branch that broke. It passes the file now, with `operational_checkout`
mocked to treat `.` as operational, which is the arrangement that fails on
the old behaviour. A second case keeps the directory path covered.
Verified live as well: a loose file scanned from an operational checkout
reports the planted violation, and the multi-file invocation outside a
repository still exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 11, 2026 01:23
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Both suppressed findings on the head are correct and both are fixed in 3939f7c. The first is this PR's own defect for the third time, in a narrower form each round.

When none of the scan paths are in a git repo, scan_root falls back to Path('.') for file arguments. That reintroduces the original bug for loose files: if the caller happens to be in an operational repo, home-path will be exempted even though the scanned file is not in that repo.

Correct. I chose Path('.') last round to match discover(), and that was the wrong thing to match. discover() uses . as a root for a tracked-file lookup; the rule-set decision has to anchor on what is being scanned. Matching a function because it is nearby is not the same as matching it because it answers the same question.

A file now anchors on its own parent. A bare filename still anchors on ., and that stays correct, since . is the directory holding it.

Verified live, from an operational checkout scanning a loose file elsewhere:

$ prose_lint.py /tmp/.../loose2/notes.md --check home-path
notes.md:3: home-path: absolute home path '/home/someuser' -> use a constructed path, not an observed one
exit=1

test_a_path_under_no_repository_falls_back_to_itself_rather_than_the_caller currently passes the directory into prose_lint.main(...), so it never exercises the file-argument path.

This is the more useful of the two, because it explains why the first one reached the head at all. I wrote that test specifically to guard the fallback, and it guarded the branch that was not broken. A regression guard aimed at the wrong branch reads exactly like coverage.

It now passes the file, with operational_checkout mocked so . is operational, which is the arrangement that fails on the old behaviour and passes on the new one. It does fail against the previous commit. A second case keeps the directory branch covered rather than trading one for the other.

On this PR's pattern. Three rounds, three fixes of mine that each introduced a narrower version of the problem being fixed:

  1. CWD decided the rule set — the original defect.
  2. The fallback made every path its own root, refusing valid multi-file runs.
  3. The fallback anchored a loose file on CWD, exempting it again.

Each was caught here. The common cause is that I kept fixing the case in front of me and re-deriving the fallback each time, rather than stating the invariant once: the rule set is decided by the thing being scanned, and no code path may consult the process's working directory to decide it. That sentence is now the comment above the block, so the next change has something to check itself against instead of a worked example to imitate.

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

scripts/prose_lint.py:1115

  • The new comment block explains the full history and several edge cases, which is likely to drift over time and makes the core behavior harder to spot. Consider condensing it to 1-2 lines that capture the invariant (rule-set is based on the scanned repository root; multi-repo arguments refuse) and let the tests carry the detailed rationale.
 # The rule set is decided by the repository being scanned, never by the directory the caller stands in.
# Reading it from `.` discarded home-path on a release repository whenever the caller happened to stand in an operational one.
# That silenced the rule that exists because real paths reached a public comment, announcing it on stderr and exiting 0.
# A run spanning two repositories refuses instead of picking one, since the two can declare different models and one rule set cannot be right for both.
# Only a repository declares a workflow model, so only distinct repositories are ambiguous.
# Two loose paths under no repository are not a conflict.
# Refusing on them would break the ordinary multi-file invocation outside a checkout, where every argument resolves somewhere different.

Seven lines of incident history above four lines of code buried the rule
they were explaining, and history is the part that drifts. Three lines now:
the invariant, what follows from it, and where the cases live.
The rationale is not lost. Each case in TestScanRootDecidesTheRuleSet
carries the reason it exists, which is where a reader who wants the history
should be sent, and where it cannot go stale without a test failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Taken, and fixed in f2037e7.

The new comment block explains the full history and several edge cases, which is likely to drift over time and makes the core behavior harder to spot. Consider condensing it to 1-2 lines that capture the invariant and let the tests carry the detailed rationale.

Right on both counts, and the first is the one that matters: seven lines of incident history sat above four lines of code, and history is exactly the part that goes stale without anything failing.

It is three lines now — the invariant, what follows from it, and where the cases live:

# The invariant: the rule set is decided by what is scanned, and no path here reads the working directory to decide it.# Only a repository declares a model, so two of them refuse and anything else resolves to one anchor.# TestScanRootDecidesTheRuleSet carries the cases and the reason each one exists.

The rationale is not dropped. Each of the seven cases in that class carries the reason it exists in its own docstring, which is where it cannot rot without a test failing — a comment claiming why is unverifiable, and a test claiming why fails when the why stops being true.

Worth noting the irony, since it is the same defect class this PR is about: I wrote that block one line at a time across three rounds, each fixing the round before, and never went back to state the rule once. Long comments assembled by accretion are a symptom of that, not a separate style problem.

This closes every finding on this PR: two threads resolved and four suppressed answered here, across five rounds.

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

scripts/prose_lint.py:1123

  • scan_root is derived from the raw CLI arguments, but discover() treats any non-file/non-directory argument as Path('.') (scripts/prose_lint.py:264). If the user passes a typo/nonexistent path, the tool will silently scan the caller’s current directory while selecting the home-path exemption based on the nonexistent path’s parent, so the rule set and the scanned content can diverge and produce confusing/incorrect verdicts. Consider refusing early when any requested path does not exist, instead of falling back to scanning ..
 ', '.join(sorted(git_roots)) + '). Each declares its own workflow model, so no '
'single rule set is correct for all of them. Run the gate once per repository.',
file=sys.stderr)
return 2
# A file anchors on its own parent rather than on `.`, which is where the caller stands.
if git_roots:
scan_root = Path(next(iter(git_roots)))
else:

CopilotAI review requested due to automatic review settings August 11, 2026 01:32

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

…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>
CopilotAI review requested due to automatic review settings August 11, 2026 01:43
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Correct, and fixed in cbb5684.

If the user passes a typo/nonexistent path, the tool will silently scan the caller's current directory while selecting the home-path exemption based on the nonexistent path's parent, so the rule set and the scanned content can diverge.

This is the same defect this PR is about, wearing different clothes: the run describes one tree and judges it by another. Refusing early, as you suggest, is better than making the anchor agree with the fallback, because scanning . after a typo is wrong even when the rule set happens to match.

A missing path is now refused, naming each one.

Two existing cases broke, and that is worth reporting rather than smoothing over.test_a_path_under_no_repository_is_refused_too and test_list_files_still_reports_scope_across_repositories both passed fake paths (/tmp/loose, /other/tree) as a mocking convenience. The new check refused them before they reached the guard each exists to exercise.

I gave them real directories rather than exempting --list-files from the existence check. Loosening the check to accommodate a test would have preserved a green suite while removing the property, and the tests' actual subject is the diff guard, not path existence. Both still assert exactly what they did.

Worth noting for its own sake: --list-files is deliberately not exempt here. It is exempt from the diff guard because it never consults the diff, but reporting a scan scope for a path that does not exist has the same confusion at its root.

Two cases added, one failing against the previous commit and one guarding the ordinary path.

That closes every finding on this PR: two threads resolved, five suppressed answered here, across six rounds.

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

scripts/prose_lint.py:1126

  • The multi-repository refusal message concatenates raw paths without quoting. If a repository root path includes spaces, the message is hard to interpret; quoting each root avoids ambiguity.
 print('error: the requested paths span more than one repository (' +
', '.join(sorted(git_roots)) + '). Each declares its own workflow model, so no '
'single rule set is correct for all of them. Run the gate once per repository.',
file=sys.stderr)

scripts/prose_lint.py:1119

  • The nonexistent-path refusal message prints raw path tokens joined by commas, which is ambiguous when a path contains a comma or whitespace. Quoting each path (e.g., via repr) makes the error actionable and copy/pasteable.

This issue also appears on line 1123 of the same file.

 print(f"error: requested path(s) do not exist: {', '.join(sorted(absent))}. Refusing "
'rather than falling back to the current directory, which would scan one tree and '
'choose the rule set from another.', file=sys.stderr)

…a Separator
Both messages joined raw paths with commas, so a single path holding a
comma read as two and one holding a space read ambiguously. That is worst
exactly when the message matters, since it fires on a path the caller did
not expect to be there.
One `quoted` helper serves both, rather than a repr at each site, since the
two messages drifting apart is the smaller version of the problem this PR
is about.
Two cases added, one per message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 11, 2026 01:57
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Both suppressed findings on the head are the same point on two messages, and both are fixed in 8756f3a.

The nonexistent-path refusal message prints raw path tokens joined by commas, which is ambiguous when a path contains a comma or whitespace.

The multi-repository refusal message concatenates raw paths without quoting.

Correct, and the timing is what makes it worth fixing rather than filing: both messages fire precisely when a path is not what the caller expected, so that is the worst moment for the message to be ambiguous about how many paths there even are.

One quoted() helper serves both rather than a repr at each site — two messages formatting the same thing differently is the small version of the problem this whole PR is about.

error: requested path(s) do not exist: '/tmp/.../no such, dir'. Refusing rather than falling back...
error: the requested paths span more than one repository ('/home/pieter/Blog', '/home/pieter/ProjectTemplate'). ...

Two cases added, one per message, each asserting the quoted form appears.

That closes every finding on this PR: two threads resolved and seven suppressed answered in the conversation, across nine rounds. 209 cases in the file.

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

scripts/prose_lint.py:1130

  • The new scan-root selection refuses missing paths, but it still accepts existing arguments that are neither a file nor a directory (e.g., FIFO/socket/device). In that case discover() falls back to Path('.'), which can reintroduce the original bug: scanning the caller’s directory while selecting the rule set from the argument’s parent. Consider refusing any path that isn’t a file or directory, so the “scan target decides” invariant always holds.
 scan_paths = a.paths or ['.']
# A path that does not exist is refused rather than absorbed.
# `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.
absent = [p for p in scan_paths if not Path(p).exists()]
if absent:
# Quoted, since a path holding a space or a comma is unreadable in a bare comma-joined list.
print(f"error: requested path(s) do not exist: {quoted(absent)}. Refusing rather than "
'falling back to the current directory, which would scan one tree and choose the '
'rule set from another.', file=sys.stderr)

… Absent
Testing for existence covered a typo and nothing else. A FIFO, a socket,
and a device all exist and are neither a file nor a directory, and
`discover` reads each of them as `.`, so the run still scanned the caller's
directory while the rule set anchored on the argument's parent. The
invariant held for the case I pictured and not for its siblings.
The check now asks what the path is rather than whether it is there, which
covers both.
Two cases: the missing path, and a real FIFO created in the test, skipped
where `os.mkfifo` is unavailable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 11, 2026 02:03
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Correct, and fixed in 3124758.

The new scan-root selection refuses missing paths, but it still accepts existing arguments that are neither a file nor a directory (e.g., FIFO/socket/device). In that case discover() falls back to Path('.'), which can reintroduce the original bug.

Right, and it is the same mistake I have now made repeatedly on this PR: I checked for the case I pictured — a typo — rather than for the property the invariant needs. Existence was never the question. discover cares whether the argument is a file or a directory, so that is what the guard has to ask.

A FIFO, a socket, and a device all pass exists() and all fall through to ..

unusable= [pforpinscan_pathsifnot (Path(p).is_file() orPath(p).is_dir())]

Two cases: the missing path, and a real FIFO created with os.mkfifo in the test, skipped where that is unavailable. The FIFO case asserts exists() is true first, so it is testing the gap rather than restating the typo case. It fails against the previous commit.

The pattern, stated once rather than round by round. Every finding on this PR after the first has been a sibling of the one before it: CWD instead of the scanned path, then every path its own root, then a loose file anchored on CWD, then a missing path absorbed, and now a FIFO absorbed. Each fix asked "does this handle the case in front of me" instead of "does the invariant hold for every input shape". The invariant was even written in the comment above the code by round four, and I still did not check the new guard against it.

209 to 210 cases. Every finding on this PR is now answered: two threads resolved, eight suppressed answered here, across ten rounds.

CopilotAI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@ptr727
ptr727 merged commit 8c6fd27 into developAug 11, 2026
7 checks passed
@ptr727
ptr727 deleted the fix-prose-lint-scan-root branch August 11, 2026 02:11
ptr727 added a commit that referenced this pull request Aug 11, 2026
…ed (#668)
Two false cleans reported by the ESPHome-Config agent, both reproduced against `develop` before any fix, and both members of one class: **a run that reads nothing prints what a run with nothing to report prints.**
## The two reports, reproduced
Constructed repositories, one seeded finding each. Verdicts are pre-fix.
| invocation | pre-fix | post-fix |
| --- | --- | --- |
| `prose_lint.py . --diff BASE` | 1 finding, exit 1 | unchanged |
| `prose_lint.py /abs/path --diff BASE`, same directory, same repository, same ref | **silent, exit 0** | identical to the relative form |
| new file, untracked, the whole change | **silent, exit 0** | 2 findings, exit 1 |
| the same bytes, staged | 2 findings, exit 1 | unchanged |
The absolute-path run returned absolute paths from discovery while the diff named repository-relative ones, so the intersection was empty. The same-root guard could not fire, because the run genuinely was in the right repository.
The untracked hole is not a `--diff` quirk. `git ls-files` omits an untracked file exactly as `git diff` does, so a whole-tree sweep passed over it for its own reason, which retires the workaround `OPERATIONS.md` documented.
## The invariant
Every input to a verdict is read from the repository being scanned, and none of them from the directory the process happens to stand in. That covers the rule set, the file set, the diff, and the keys joining the last two. #666 established it for the rule set alone; the other three still read the working directory.
- One `repo_key` helper puts both sides in repository coordinates. It replaces three hand-rolled idioms, one already correct and two not, and that disagreement was the defect.
- Discovery reads tracked plus untracked-and-not-ignored; the diff counts an untracked file as added in full. Ignored paths, generated trees and binaries stay out.
- The diff is taken at the scan root, so `diff.relative` cannot re-anchor it and a subdirectory run works rather than exiting 2 with advice to move.
- **The class fix**: every run states its scope on stderr, including a clean one.
```text
scope: 4 of 117 file(s) read, 344 changed line(s), diff against 'HEAD'
scope: 117 file(s) read, whole tree
```
All five known false cleans exit 0 in silence. Per-route guards only ever close the route somebody thought of, and the sixth is found by a reviewer or not at all.
## One deliberate removal
The #520 guard refusing a scan of one repository while standing in another is gone. It existed because the diff was taken where the process stood; anchoring the diff on the scan root is what it was approximating, so the case is answered rather than turned away. `repo_prefix` goes with it as dead code. Its two cases are replaced: a path under no repository is still refused, now by the diff itself, and scanning one repository from another is asserted to diff the one scanned.
## Verification
Twelve new cases build real git repositories rather than mocking `repo_root` and `discover`, because a mock supplies the join that was broken. **Eight of the twelve fail against the pre-fix source**; the four that pass are exclusion cases, and each was checked rather than assumed redundant. One, a `diff.relative` case, passed because both sides were anchored on the process's directory and agreed by accident, and its docstring now claims only that.
- 221 self-tests, prose gate, `repo_gate` (eol, eol-coverage, sha-pin), `spec/validate.py`, markdownlint over 45 files, editorconfig-checker: all clean locally.
- `--list-files` byte-identical over this repository, 117 files.
- Timings within noise: 0.22s to 0.24s diff-scoped, 1.45s to 1.39s whole-tree.
- CRLF verified byte-wise on all three touched Markdown files.
`TODO.md` line 409 named the wrong-directory false clean as an open objection to running doc gates in the pre-commit hook; that objection no longer applies and the entry says so.
## Answered in review
Five rounds. After round 1 every finding arrived suppressed rather than as a
thread, and five of the seven were real.
- **A subtree argument was pinned** after a finding read `git ls-files` as
returning repository-root-relative names under `-C`. It does not, measured on
git 2.51, and the proposal would have discarded the narrowing the path
argument asks for. The first version of that test passed under the proposal
too, so it now asserts the discovered count rather than the findings.
- **The fallback walk is now gated on `repo_root(base)`**, which is a behaviour
change beyond the description above. `tracked_paths` answers None both for a
tree git cannot describe and for one holding no tracked files, and only the
first justifies a walk. Read as emptiness, a subtree of new files took the
walk, which applies no ignore rules, and scanned an ignored build output while
printing that git could not describe a tree git describes fine. The
conflation predates this branch; untracked files joining the file set is what
made it reachable.
- **Three docs and two docstrings** claimed the file set is what git tracks plus
what it is not ignoring, with no qualifier, over a fallback that consults git
not at all. Each now says where the ignore rules apply.
- **A cited test count was wrong as well as brittle**, 210 rather than 209, and
now carries the commit it was measured at.
- **One finding is disproven**: `contextlib.chdir` raises no Python floor here,
since this module already called `enterContext` in fourteen places.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ptr727 added a commit that referenced this pull request Aug 11, 2026
…ng (#670)
Five commits, `1927e9a..56f4d7d`. Nineteen files, +4241/-136.
## What lands
- **#664** `b0d0d13` — a shellcheck gate in `validate-task.yml`, with
the file list from `git ls-files '*.sh'` so a new script is gated
without editing the step. Also wired `scripts/test_host_gate.py` into
the self-test step, which was running in no workflow at all.
- **#666** `8c6fd27` — `prose_lint.py` chose its rule set from the
working directory rather than the scanned repository, so standing in an
operational repo and scanning a release repo silently discarded
`home-path`, the rule that exists because real paths reached a public
comment.
- **#665** `e2a99f1` — a host stamp at
`~/.claude/agent-safety-stamp.json` plus `--report`, so "is this machine
current" has an answer that is not a tick in an issue. Also fixed
`install.py` taking no arguments while both wrappers passed `"$@"`,
which made `--help` perform a full install.
- **#668** `6864a9b` — the two remaining `prose_lint.py` false cleans,
fixed as a class. An absolute path argument scoped a `--diff` run to
nothing and exited 0, and an untracked file was invisible to both a
diff-scoped run and a whole-tree sweep. Every input to a verdict now
derives from the repository being scanned, and every run states the
scope it read.
- **#667** `56f4d7d` — the host bootstrap tooling under
`host-setup/linux/`, its `bootstrap.sh` loader,
`scripts/test_bootstrap.py`, and the rules the scripts run under.
## Review record
Every one of the five closed its Copilot loop on its own pull request.
#668 ran five rounds and #667 seven, and between them eighteen findings
arrived as suppressed comments rather than as inline threads, thirteen
of which were real. Two of those were defects that would otherwise have
shipped in the gate this promotion carries: a subtree of new files
taking a filesystem walk that applies no ignore rules, and a docstring
count that was wrong as well as brittle.
## Consequence worth stating
The `GOVERNANCE.md` "Hub-Hosted Tooling" paragraph #667 added makes
every carrying repository's copy a past revision once this reaches
`main`. That is the ordinary consequence of a canonical moving rather
than a defect, but a repository meeting it first as a red audit line
will read it as a surprise. HomeAutomation-Config has already
re-vendored it by content rather than by bytes, since a byte copy from a
CRLF hub into an LF repository rewrites every line to change one
paragraph.
## Verified on this head
`develop` at `56f4d7d`, in sync with `origin/develop`. Local run of the
CI invocations: 223 prose self-tests, the prose gate over 117 files,
`repo_gate` (eol, eol-coverage, sha-pin), `spec/validate.py` with 22
cataloged, markdownlint over 45 files, and editorconfig-checker, all
clean.
Merge as a **merge commit**, never a squash, and without
`--delete-branch`: this pull request's head is `develop` itself.
Sign up for freeto 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

@ptr727