Decide the Rule Set From the Repository Being Scanned, Not the Caller's Directory - #666
Conversation
…'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>
There was a problem hiding this comment.
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 exempthome-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.
| File | Description |
|---|---|
| scripts/prose_lint.py | Computes scan_root from the requested scan paths and uses it to decide the operational exemption; refuses multi-root scans. |
| scripts/test_prose_lint.py | Adds 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.
Uh oh!
There was an error while loading. Please reload this page.
…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>
There was a problem hiding this comment.
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_rootscurrently falls back toPath(p).resolve()per requested path, and then refuses whenever there is more than one distinct resolved path. That creates two concrete problems:
It breaks the existing multi-path CLI behavior for non-git inputs (e.g.
prose_lint.py README.md docs/guide.mdoutside a repo): each path resolves to a different absolute path, so the run exits 2 even though there is no workflow model ambiguity.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.(seediscover(): root = p if p.is_dir() else Path('.')), butscan_rootswill treat that same raw argument as its own resolved path, potentially changing whetherhome-pathis 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()))
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>
ptr727
commented
Aug 11, 2026
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
Reproduced exactly as described, against both versions, from a directory under no repository: 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.
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. |
There was a problem hiding this comment.
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_rootfalls back toPath('.')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-pathwill 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_callercurrently passes the directory (loose) intoprose_lint.main(...), so it never exercises the file-argument path wherescan_rootpreviously fell back toPath('.'). This means the regression (caller CWD influencinghome-pathwhen scanning a loose file) can slip through. Updating the test to pass the file path and mockingoperational_checkoutto treatPath('.')as operational will fail on the buggy behavior and pass oncescan_rootuses 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>
ptr727
commented
Aug 11, 2026
Both suppressed findings on the head are correct and both are fixed in
Correct. I chose A file now anchors on its own parent. A bare filename still anchors on Verified live, from an operational checkout scanning a loose file elsewhere:
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 On this PR's pattern. Three rounds, three fixes of mine that each introduced a narrower version of the problem being fixed:
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. |
There was a problem hiding this comment.
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
commented
Aug 11, 2026
Taken, and fixed in
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. |
There was a problem hiding this comment.
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_rootis derived from the raw CLI arguments, butdiscover()treats any non-file/non-directory argument asPath('.')(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 thehome-pathexemption 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:
…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>
ptr727
commented
Aug 11, 2026
Correct, and fixed in
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 A missing path is now refused, naming each one. Two existing cases broke, and that is worth reporting rather than smoothing over. I gave them real directories rather than exempting Worth noting for its own sake: 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. |
There was a problem hiding this comment.
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>
ptr727
commented
Aug 11, 2026
Both suppressed findings on the head are the same point on two messages, and both are fixed in
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 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. |
There was a problem hiding this comment.
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 toPath('.'), 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>
ptr727
commented
Aug 11, 2026
Correct, and fixed in
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. A FIFO, a socket, and a device all pass unusable= [pforpinscan_pathsifnot (Path(p).is_file() orPath(p).is_dir())]Two cases: the missing path, and a real FIFO created with 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. |
Uh oh!
There was an error while loading. Please reload this page.
…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>
…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.
prose_lint.pychose whether to runhome-pathby 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:
After — found, exit 1, and identical to what running from inside the repo gives:
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
--diffsame-root guard is untouched and still correct —git diffgenuinely 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
TestOperationalExemptionalready covers the exemption in five cases. It mocksrepo_rootto 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.pytree-wide, editorconfig-checker.