repo_gate.py --check eol compares .gitattributes against .editorconfig and never compares either against the filesystem. Two defect shapes live in that gap, and both were sitting in ptr727/Blog while the gate reported clean. Raising it here because the pin list is carried content and the check that guards it is hub-managed, so a fix here reaches every repo.
What the gate does today
scripts/README.md states the scope plainly, and the implementation matches:
eol: every path pinned LF in .gitattributes has the matching .editorconfig override the line-ending rule requires, with EditorConfig brace syntax expanded. One direction only.
That direction is correct and worth keeping. The limitation is a different axis: it is document-to-document. Both documents can agree perfectly and both be wrong about the repository they describe.
spec/files.json marks .gitattributes as "fidelity": "intent", "whole": true, so what remains is an agent reading it for meaning during an audit. Nothing mechanical ever asks whether the pins match the tree.
The two shapes, both found in Blog
Unpinned. ops/vps-backup-pull is an extensionless shebang script, run unattended by systemd on the backup host. *.sh does not reach it, the .py pins do not reach it, and * -text keeps git passive, so a CRLF checkout would hand systemd a broken interpreter line. Copilot caught this one on the promotion PR; no gate did.
Dead. .gitattributes pinned two paths that have never been tracked in that repository, in any commit:
| Pattern |
Tracked files matched |
deploy/blog-deploy-shell |
0 |
deploy/authorized_keys |
0 |
Both live on the server, not in the repo. This is the worse half, and not because a no-op pin costs anything. The comment above those two read "The deploy shell is an extensionless shebang script that matches no rule above" — so the file asserted the extensionless case was handled, and the one real instance sat unpinned twenty lines up. A dead pin does not merely fail to bind. It reads as coverage, and it is what hid the live defect from every human and agent who read the file.
The gate reports clean on exactly that tree
Not argued from the code, measured. ptr727/Blog at 392de22 carried both defects:
$ python3 scripts/repo_gate.py --root <blog@392de22> --check eol
[ok ] eol 0 issue(s)
There is a second reason it cannot fail there. Blog's .editorconfig sets [*] end_of_line = lf, so the "matching override" the check looks for is satisfied by the global default for any path, including a path that does not exist. In a repo shaped that way the eol check is vacuously true for every pin it will ever read, so its result carries no information about pin content at all. That is likely to be common across the fleet rather than peculiar to Blog.
What Blog now runs
Fixed in ptr727/Blog#69 as checks/check-eol-pins.py, wired into Lint sources job. Two directions:
- unpinned — a tracked file opening with
#! whose resolved eol is not lf. The shebang is the test rather than the mode bit, because the two move independently and it is the interpreter line a CRLF breaks.
- dead — a pattern matching no tracked file.
It asks git check-attr for the resolved attribute rather than re-implementing gitattributes matching, so it cannot disagree with what git actually applies on checkout, and it fails rather than passing if it finds no shebang files at all.
Each direction was verified by reintroducing the real defect and watching the gate fail on it, rather than by trusting a green run. It also caught its own file before that was staged.
The design caveat, which matters for adopting this here
The dead-pattern direction cannot be adopted by the hub unmodified. Run against ProjectTemplate@c40cd0a, three patterns match no tracked file:
DEAD uv.lock
DEAD Dockerfile
DEAD *.Dockerfile
All three are deliberate. This is a carried baseline, and those pins go live the moment a repo vendors it and adds a Dockerfile or a lockfile — .gitattributes says as much itself ("A repo with no lockfile is unaffected"). So the distinction the check has to encode is:
- in a carried baseline, a pattern matching nothing is a forward declaration for consumers, and correct
- in a leaf repo, a pattern matching nothing after vendoring is dead
Blog's two were unambiguously the second kind: they name host artifacts no consumer of Blog will ever carry. Some marker is needed to separate the cases — an explicit exception list, a comment convention, or simply scoping the dead direction to leaf repos and exempting the template. That call is the hub's, and it is the main thing worth deciding before lifting this.
The unpinned direction has no such caveat and could be adopted as-is: all 16 tracked shebang files in the hub already resolve to eol=lf, so it passes here today and would start protecting the instruction .gitattributes currently gives with no enforcement behind it — "Any repo whose tooling ships extensionless scripts adds the matching path pin." That sentence is the whole mechanism right now.
Two implementation notes
git ls-files -- <pattern> is pathspec matching, not gitattributes matching, and they differ: pathspec lets * cross a /, so capture/*.py also matches capture/sub/x.py. For the dead direction that errs toward not reporting, which is the safe bias, but a stricter implementation here should probably use gitignore-style matching to be exact. The unpinned direction is unaffected, since it delegates to git check-attr.
The symmetric question for .editorconfig — path-specific sections naming files that do not exist — I did not answer. My first pass at it produced false positives because my matcher did not expand braces, and repo_gate.py already implements that expansion, so the hub is better placed to answer it than I was. Recording it as an open question rather than a finding.
Suggested shape
Add the filesystem-facing direction to repo_gate.py --check eol (or a sibling --check eol-coverage), so it stays one script and one report. Sketch:
eol .editorconfig <-> .gitattributes disagreement (existing, unchanged)
eol-coverage tracked shebang file with no LF pin (new)
.gitattributes pattern matching no tracked file (new, needs the baseline exemption)
Happy to port check-eol-pins.py into scripts/ with tests matching the test_repo_gate.py style if that is the shape you want. Flagging rather than sending a PR first, since the baseline-exemption decision changes the design.
repo_gate.py --check eolcompares.gitattributesagainst.editorconfigand never compares either against the filesystem. Two defect shapes live in that gap, and both were sitting inptr727/Blogwhile the gate reported clean. Raising it here because the pin list is carried content and the check that guards it is hub-managed, so a fix here reaches every repo.What the gate does today
scripts/README.mdstates the scope plainly, and the implementation matches:That direction is correct and worth keeping. The limitation is a different axis: it is document-to-document. Both documents can agree perfectly and both be wrong about the repository they describe.
spec/files.jsonmarks.gitattributesas"fidelity": "intent", "whole": true, so what remains is an agent reading it for meaning during an audit. Nothing mechanical ever asks whether the pins match the tree.The two shapes, both found in Blog
Unpinned.
ops/vps-backup-pullis an extensionless shebang script, run unattended by systemd on the backup host.*.shdoes not reach it, the.pypins do not reach it, and* -textkeeps git passive, so a CRLF checkout would hand systemd a broken interpreter line. Copilot caught this one on the promotion PR; no gate did.Dead.
.gitattributespinned two paths that have never been tracked in that repository, in any commit:deploy/blog-deploy-shelldeploy/authorized_keysBoth live on the server, not in the repo. This is the worse half, and not because a no-op pin costs anything. The comment above those two read "The deploy shell is an extensionless shebang script that matches no rule above" — so the file asserted the extensionless case was handled, and the one real instance sat unpinned twenty lines up. A dead pin does not merely fail to bind. It reads as coverage, and it is what hid the live defect from every human and agent who read the file.
The gate reports clean on exactly that tree
Not argued from the code, measured.
ptr727/Blogat392de22carried both defects:There is a second reason it cannot fail there. Blog's
.editorconfigsets[*] end_of_line = lf, so the "matching override" the check looks for is satisfied by the global default for any path, including a path that does not exist. In a repo shaped that way the eol check is vacuously true for every pin it will ever read, so its result carries no information about pin content at all. That is likely to be common across the fleet rather than peculiar to Blog.What Blog now runs
Fixed in ptr727/Blog#69 as
checks/check-eol-pins.py, wired intoLint sources job. Two directions:#!whose resolvedeolis notlf. The shebang is the test rather than the mode bit, because the two move independently and it is the interpreter line a CRLF breaks.It asks
git check-attrfor the resolved attribute rather than re-implementing gitattributes matching, so it cannot disagree with what git actually applies on checkout, and it fails rather than passing if it finds no shebang files at all.Each direction was verified by reintroducing the real defect and watching the gate fail on it, rather than by trusting a green run. It also caught its own file before that was staged.
The design caveat, which matters for adopting this here
The dead-pattern direction cannot be adopted by the hub unmodified. Run against
ProjectTemplate@c40cd0a, three patterns match no tracked file:All three are deliberate. This is a carried baseline, and those pins go live the moment a repo vendors it and adds a Dockerfile or a lockfile —
.gitattributessays as much itself ("A repo with no lockfile is unaffected"). So the distinction the check has to encode is:Blog's two were unambiguously the second kind: they name host artifacts no consumer of Blog will ever carry. Some marker is needed to separate the cases — an explicit exception list, a comment convention, or simply scoping the dead direction to leaf repos and exempting the template. That call is the hub's, and it is the main thing worth deciding before lifting this.
The unpinned direction has no such caveat and could be adopted as-is: all 16 tracked shebang files in the hub already resolve to
eol=lf, so it passes here today and would start protecting the instruction.gitattributescurrently gives with no enforcement behind it — "Any repo whose tooling ships extensionless scripts adds the matching path pin." That sentence is the whole mechanism right now.Two implementation notes
git ls-files -- <pattern>is pathspec matching, not gitattributes matching, and they differ: pathspec lets*cross a/, socapture/*.pyalso matchescapture/sub/x.py. For the dead direction that errs toward not reporting, which is the safe bias, but a stricter implementation here should probably use gitignore-style matching to be exact. The unpinned direction is unaffected, since it delegates togit check-attr.The symmetric question for
.editorconfig— path-specific sections naming files that do not exist — I did not answer. My first pass at it produced false positives because my matcher did not expand braces, andrepo_gate.pyalready implements that expansion, so the hub is better placed to answer it than I was. Recording it as an open question rather than a finding.Suggested shape
Add the filesystem-facing direction to
repo_gate.py --check eol(or a sibling--check eol-coverage), so it stays one script and one report. Sketch:Happy to port
check-eol-pins.pyintoscripts/with tests matching thetest_repo_gate.pystyle if that is the shape you want. Flagging rather than sending a PR first, since the baseline-exemption decision changes the design.