Skip to content

Commit 0bb43c5

Browse files
authored
Read the Line-Ending Pins Against the Tree, Not Just Each Other (#634)
`repo_gate.py --check eol` compares .gitattributes with .editorconfig and never compares either against the repository, so both documents can agree perfectly and both be wrong. ptr727/Blog carried two defects in that gap while the gate reported clean: an extensionless shebang script systemd runs unattended, matched by no pin, and two pins naming paths never tracked there. The dead pins are what hid the live one, since a pin that binds nothing still reads as coverage. `eol-coverage` adds the filesystem-facing direction as a separate check, so `--check eol` keeps meaning exactly what it means today. A pin block marked `forward-declared` is exempt from the dead reading, because in a carried baseline a pattern matching nothing is a declaration for whichever consumer adds the file, and the mark travels with the copy. Closes#633.
1 parent c2ce145 commit 0bb43c5

5 files changed

Lines changed: 463 additions & 15 deletions

File tree

‎.gitattributes‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ scripts/test_pr_review.py text eol=lf
3232

3333
# The uv tool regenerates uv.lock with LF on every platform, so pin it here.
3434
# That makes git enforce LF on checkout and renormalize, so a CRLF-default repo does not fight the tool on every `uv lock` or `uv sync`.
35-
# A repo with no lockfile is unaffected.
35+
# A repo with no lockfile is unaffected, so this pin is forward-declared.
3636
uv.locktexteol=lf
3737

3838
# Dockerfiles must be LF, because a CRLF breaks RUN heredocs and line continuations.
39+
# A repo with no Dockerfile is unaffected, so these pins are forward-declared.
3940
Dockerfiletexteol=lf
4041
*.Dockerfiletexteol=lf

‎TODO.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,33 @@ The review loop ends by replying on a thread and resolving it, and both halves f
345345
-**Checked** - `develop` at `0e4a1c2` on 2026-08-08, reading the exit-code table in the `scripts/pr_review.py` module docstring.
346346
-**Detail** - This is the failure the suppressed-findings count already exists for, where a step that stopped running reads exactly like a step that passed.
347347

348+
### Watching a Downstream Pull Request Touch Hub-Owned Content
349+
350+
One pull request adding the observer the fleet has no equivalent of, reading merged pull requests across the fleet and resolving every changed path against what the hub declares it owns. The tools today read standing state, so a divergence is visible only once it is already there, and a repo-local file the manifest never names is invisible at every stage.
351+
352+
**State**`ready`. **Touches** a new `spec/carry_watch.py` with its self-test, [`reports/`][reports], [`AUDIT.md`][audit-doc], and [`.github/workflows/validate-task.yml`][validate-task]. **Cost** one hub script, hub-only, plus a first run whose output is a triage backlog rather than a change.
353+
354+
-**Read merged fleet pull requests and classify each changed path against the manifest.** The gap is a whole reading rather than a missing field, since nothing anywhere enumerates pull requests.
355+
-**Blocked by** - Nothing.
356+
-**Issue** - None filed. [#633][issue-633] is the instance that prompted it, raised by a downstream agent after the maintainer noticed it editing hub-managed CI files, and nothing mechanical had reported that.
357+
-**Checked** - `develop` at `c2ce145` on 2026-08-08, reading [`spec/files.json`][files], [`spec/divergences.json`][divergences] and [`registry/repos.json`][repos], and running the enumeration query live against the owner.
358+
-**Open** - How a window wider than a thousand results is split, since the GitHub search API caps there and a silent truncation is the false clean this whole class of tool exists against. The split has to be visible in the output rather than inferred.
359+
-**Open** - Whether a `SECTION` or `CONTRACT` classification reads content in the same pass or defers to a human, since the path alone says a carried file moved and not which region of it.
360+
-**Settled** - The enumeration is one query rather than a per-repo loop, measured live: `search(query: "org:ptr727 is:pr is:merged base:main merged:>=<DATE>", type: ISSUE)` returned 112 pull requests over a fortnight with per-pull-request `files` and `repository` inline. The `base:` term comes from each repo's registry `groundTruthBranch` rather than a hardcoded `main`.
361+
-**Settled** - It belongs in `spec/` beside [`spec/fidelity_honesty.py`][fidelity-honesty], which is its sibling in every respect that decides placement, being owner-initiated, absent from CI, an importer of `audit` as a library, and a writer of a generated report. [`scripts/`][scripts] holds gates run against one repo named by `--root`.
362+
-**Settled** - The classes are `OVERSTEP` for a `verbatim` whole unit, `SECTION` for a path declaring verbatim sections, `CONTRACT` for an `interface` unit, `GAP` for a path the hub tracks that the manifest never names, and `CANDIDATE` for a path absent from the hub changed in a pull request that also touched one of the others.
363+
-**Settled** - `intent` and `presence` units are deliberately not watched, being downstream-owned by design, and that exclusion is what makes suppression keyed on the ledger correct rather than over-broad.
364+
-**Settled** - `GAP` plus `CANDIDATE` is the pair that means a downstream wired local tooling into a workflow the hub authored, which is exactly `ptr727/Blog#69`: it changed `.gitattributes`, `.github/workflows/validate-task.yml` which is a ledger `gaps` entry dispositioned `investigate`, and a `checks/check-eol-pins.py` the hub has never heard of. A sibling pull request shows the same shape over a whole `checks/` tree.
365+
-**Settled** - Triage needs no new store. [`spec/divergences.json`][divergences] already carries `upstream-candidate` in its disposition vocabulary, meaning the downstream carries an improvement the hub should adopt, and nothing in the ledger uses it today. A dispositioned pair prints with its disposition and everything else renders `UNTRIAGED`, which is what [`reports/divergences.md`][divergences-report] already does.
366+
-**Settled** - The rule goes in [`AUDIT.md`][audit-doc] section 9 rather than [`GOVERNANCE.md`][governance], whose sections are verbatim fleet law, so an edit there puts every downstream repo into verbatim drift until re-vendored for a sentence that is procedure rather than law.
367+
-**Settled** - Two floors are not optional. A run reading zero pull requests reports that rather than a clean zero, and a repo the search surfaces with no registry entry is reported separately, which feeds "Registry Membership Coverage" above.
368+
-**Settled** - This is not the deferred audit automation recorded under "Standalone Chores". That entry rejected three scheduled and hook-driven shapes on three blockers, and this is owner-run and on demand like [`spec/fidelity_honesty.py`][fidelity-honesty], so it lands on none of them.
369+
348370
## Standalone Chores
349371

350372
Small work with no research to preserve, selectable one bullet at a time.
351373

374+
-**Answer the symmetric reading of [`.editorconfig`][editorconfig], a path-specific section naming files that do not exist**, which is the half of [#633][issue-633] the `eol-coverage` check deliberately left open. The dead-pin reading it does ship is the `.gitattributes` side, and the same question on the other document is not the same shape: this repo's `[.github/workflows/*]` and `[catalog/snippets/workflows/*]` sections are legitimately broad, and the issue's own first attempt at it produced false positives because the matcher did not expand brace syntax, which [`scripts/repo_gate.py`][repo-gate] already implements. Measure the exemption against the live corpus before building the gate rather than after, since a stale exemption hands out a work list that damages correct documents, and decide whether `forward-declared` carries across or whether an editorconfig section needs its own marker.
352375
-**Reconsider whether the pre-commit hook runs the doc gates now that they are diff-scoped.**[`scripts/README.md`][scripts] records the current decision and its reason, that doc linters stay out of the hook so it stays fast, which was sound when the only mode was a whole-tree sweep, and a diff-scoped run finishes in about a second. The failure it would prevent is the most repeated one on record, comment sentences wrapped across lines caught after the commit rather than before it. Weigh it against the standing preference for a fast hook and against a hook that runs the gate from the wrong directory, which is its own false clean.
353376
-**Audit the fleet's shell surface by size and branching, and decide per script whether Python with unit tests is cheaper.** The evidence is the review record rather than a language preference, since a non-trivial shell script earns findings round after round while every gate under [`scripts/`][scripts] carries a test file beside it and converges in one or two. The measure is lines, branch count, and the review rounds each has cost. `repo-config/configure.sh` and the agent-safety installer are the two worth measuring, and a bootstrap script that needs the Python it exists to install is not a rewrite worth having, which protects the installer more than the config script.
354377
-**Make a table of contents standard for a long document rather than for the README alone.**[`spec/readme-structure.md`][readme-structure] fixes one at README position 4 and no other hub file carries one, which leaves the three longest documents without it, `CODESTYLE.md` at 516 lines, `GOVERNANCE.md` at 436 and `WORKFLOW.md` at 301, measured on `develop` at `3d1a0b1` on 2026-08-06. Settle the threshold in headings or lines so the audit can check it, and settle how it sits with the reference-link exception, since the four agent-instruction files keep inline links exactly because they are read one section at a time, which is the property that makes a contents list worth having in them. The mechanical constraint is that the list is filled by the Markdown All in One extension on save, so a file nobody opens in the editor grows a stale list, which is worse than absent because it is read as current.
@@ -474,6 +497,7 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i
474497
[issue-597]: https://github.com/ptr727/ProjectTemplate/issues/597
475498
[issue-607]: https://github.com/ptr727/ProjectTemplate/issues/607
476499
[issue-623]: https://github.com/ptr727/ProjectTemplate/issues/623
500+
[issue-633]: https://github.com/ptr727/ProjectTemplate/issues/633
477501

478502
<!-- Pull requests -->
479503

@@ -493,6 +517,8 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i
493517
[copilot-instructions]: ./.github/copilot-instructions.md
494518
[divergences]: ./spec/divergences.json
495519
[divergences-report]: ./reports/divergences.md
520+
[editorconfig]: ./.editorconfig
521+
[fidelity-honesty]: ./spec/fidelity_honesty.py
496522
[files]: ./spec/files.json
497523
[governance]: ./GOVERNANCE.md
498524
[markdownlint]: ./.markdownlint-cli2.jsonc
@@ -503,6 +529,7 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i
503529
[prose-gate]: ./.github/actions/prose-gate/action.yml
504530
[readme-sections]: ./spec/readme-sections.json
505531
[readme-structure]: ./spec/readme-structure.md
532+
[repo-gate]: ./scripts/repo_gate.py
506533
[reports]: ./reports/
507534
[repos]: ./registry/repos.json
508535
[scripts]: ./scripts/README.md
@@ -513,6 +540,7 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i
513540
[standup]: ./STANDUP.md
514541
[third-party-tools]: ./spec/third-party-tools.json
515542
[type-model]: ./spec/type-model.md
543+
[validate-task]: ./.github/workflows/validate-task.yml
516544
[workflow]: ./WORKFLOW.md
517545
[workflows]: ./catalog/snippets/workflows/
518546
[write-guard]: ./host-setup/agent-safety/gh-write-guard.py

‎scripts/README.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,35 @@ Every rule in the default set is clean tree-wide, which is what lets the CI gate
7676

7777
## `repo_gate.py`
7878

79-
Two deterministic checks:
79+
Three deterministic checks:
8080

8181
-`sha-pin`: every workflow `uses:` naming an action is a 40-hex commit SHA that resolves, with the one documented `dotnet/nbgv@master` exception allowed. A local reusable workflow (`./.github/workflows/...`) names no action and carries no ref to pin, so it is skipped.
8282
-`eol`: every path pinned LF in [`.gitattributes`][gitattributes] has the matching [`.editorconfig`][editorconfig] override the line-ending rule requires, with EditorConfig brace syntax expanded. One direction only: an `.editorconfig` LF glob with no git pin is legitimate, since `.editorconfig` governs what the editor writes where git enforces a class it must not guess at.
83+
-`eol-coverage`: the same pins read against the tree instead. A tracked file opening `#!` that git does not resolve to `eol=lf` is an interpreter line a CRLF checkout breaks, and a pin matching no tracked file is dead unless its block is marked `forward-declared`.
8384

8485
```sh
8586
python3 scripts/repo_gate.py
8687
python3 scripts/repo_gate.py --check sha-pin
8788
```
8889

90+
**`eol` and `eol-coverage` are separate checks because they answer different questions**, and widening the first in place would have made three published descriptions of it wrong at once. `eol` compares the two line-ending documents with each other, which is worth asking on its own, and it is document-to-document by design rather than by omission. What it cannot see is the tree: both documents can agree perfectly and both be wrong about the repository they describe, and nothing mechanical ever asked whether a pin binds a file that exists. [`spec/files.json`][files] marks `.gitattributes` as `intent`, so what stood in that gap was an agent reading the file for meaning during an audit.
91+
92+
The gap is measured rather than argued. `ptr727/Blog` at `392de22` carried both shapes while `--check eol` reported `0 issue(s)`: `ops/vps-backup-pull` is an extensionless shebang script systemd runs unattended on a backup host, matched by `*.sh` and by no `.py` pin and left passive by `* -text`, and two pins named `deploy/` paths that have never been tracked in that repository in any commit. Run against the same commit, `eol-coverage` reports all three. The repository is where the issue that raised this came from (`ptr727/ProjectTemplate#633`), and it fixed its own copy first in `ptr727/Blog#69`.
93+
94+
**The dead pin is the worse half, and not because a no-op pin costs anything.** The comment above Blog's two read "the deploy shell is an extensionless shebang script that matches no rule above", so the file asserted the extensionless case was handled while the one real instance sat unpinned twenty lines up. A dead pin does not merely fail to bind. It reads as coverage, which is what hid the live defect from every human and agent who opened that file.
95+
96+
**A pin block marked `forward-declared` is exempt from the dead reading**, because in a carried baseline a pattern matching nothing is a declaration for whichever consumer adds the file, not a dead pin. Three of this repo's pins are exactly that today (`uv.lock`, `Dockerfile`, `*.Dockerfile`), and each goes live the moment a derived repo adds a lockfile or a Dockerfile, which [`.gitattributes`][gitattributes] already said in prose before anything read it. The mark reaches to the next blank line, which is how that file already groups a pin with its rationale, and it travels with the carried copy so a python repo holding the baseline without a lockfile stays exempt too. Two alternatives were rejected. An exception list inside this script restates data the scanned repository already carries and goes stale in the one direction nobody checks. Diffing against the hub's own `.gitattributes` needs no convention at all, and it was rejected because it makes a repository gate change verdict when a file in another repository changes, and it cannot run standalone.
97+
98+
What the mark trades away is stated rather than left to be found: it reaches to the next blank line, so a pin appended directly under a marked block inherits an exemption nobody wrote for it, and that fails open. A case in the suite therefore names the three pins the marking is for and fails the moment a fourth arrives, rather than the parser growing a second rule.
99+
100+
**Matching is gitattributes matching, not pathspec matching.**`git ls-files -- <pattern>` looks like the cheap way to ask whether a pin binds anything and is a different language: there `*` crosses a `/`, so `capture/*.py` also matches `capture/sub/x.py` and a dead pin reads as live. The attribute side has no such risk and takes no such care, since it delegates to `git check-attr` and so cannot disagree with what a checkout actually applies.
101+
102+
**The shebang floor lives in the suite rather than in the check.** A source-only configuration repository shipping no scripts at all is legitimately clean, so a gate that failed on an empty scan would report a false finding in the common fleet case. What must not go unnoticed is this repository's own scan going quiet, and a case holds that instead. Every run still prints what it covered, for the reason `sha-pin` does.
103+
104+
`eol` gained one `note:` of its own. Where `.editorconfig` sets `end_of_line = lf` for `[*]`, the matching override the check looks for is satisfied by the global default for **any** path, one that does not exist included, so the check is vacuously true for every pin it will ever read and its result carries no information about pin content. Blog is shaped that way, and so are the fleet repositories that declare `lineEndings: lf` in [`registry/repos.json`][repos]. The check does not fail there, since nothing is wrong with such a repository, and it says what it did not read.
105+
106+
One question is deliberately **open**: the symmetric reading of `.editorconfig`, a path-specific section naming files that do not exist. It is not implemented here because this repo's own `[.github/workflows/*]` and `[catalog/snippets/workflows/*]` sections are legitimately broad, so the exemption needs measuring against the live corpus before a gate is built on it rather than after.
107+
89108
**`sha-pin` resolves the pin as well as reading its shape**, because forty hex characters is a format any fabricated string satisfies, and an agent hand-writing a plausible SHA into a workflow is a failure this repo has seen rather than a hypothetical one. The `gh-write-guard` hook cannot cover it: the hook watches Bash, and an editor tool writing the same string into a file never reaches it. Resolving also catches the neighboring case, a pin whose commit was reachable only from a branch since squashed and deleted, which breaks a downstream gate long after the change that caused it.
90109

91110
The resolution pass is **scoped to the scanned repository's own owner**, which is where the fleet's actions live and where that decay comes from, since a squash merge deletes the branch a pin was taken from and the pin outlives the commit. A third-party action's tag is stable by comparison, and reading one would make every local run of this gate depend on a stranger's repository answering. The cost is real and is stated rather than left to be found: a fabricated pin on a third-party action is still only shape-checked. Every run therefore prints what the pass actually covered, because a scope that resolves nothing prints the same `0 issue(s)` a full sweep does. On the hub today it covers nothing, since this repo's own `uses:` refs are all under other owners and the fleet's `ptr727` pins live in the downstream repos that consume [`prose-gate`][prose-gate-action]. Those are reached by running this gate from a hub checkout with `--root`, per the hosted-and-reached model above.
@@ -171,4 +190,5 @@ The match is on the block's heading rather than anywhere in the body, and on the
171190
[governance]: ../GOVERNANCE.md
172191
[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling
173192
[prose-gate-action]: ../.github/actions/prose-gate/action.yml
193+
[repos]: ../registry/repos.json
174194
[section-model]: ../spec/section-model.md

0 commit comments

Comments
 (0)