Skip to content

fix(ci): enumerate the EOL blob check by rule, not by shebang - #1805

Merged
bradygaster merged 1 commit into
devfrom
squad/1801-eol-blob-gate
Aug 21, 2026
Merged

fix(ci): enumerate the EOL blob check by rule, not by shebang#1805
bradygaster merged 1 commit into
devfrom
squad/1801-eol-blob-gate

Conversation

@bradygaster

Copy link
Copy Markdown
Owner

Closes #1804. Follow-up to #1788 / #1790.

Root cause

scripts/check-shebang-eol.mjs enumerated candidate files by #! at byte 0, then asked two questions about that one set: is it pinned to LF, and does its blob store CRLF.

The second question has the wrong enumeration. The churn class is defined by eol=lf rule + CRLF blob -- not by shebangs. The two sets overlap and neither contains the other, so the gate was blind to every pinned file without a #!, which is most of them.

The blindness was live on dev

Measured at dbb4c33b:

tracked files              : 1811
files with eol=lf attribute:  173
  of those, CRLF blob      :    2
     - docs/src/pages/rss.xml.js               (no shebang: gate BLIND)
     - packages/squad-cli/src/remote-ui/app.js (no shebang: gate BLIND)

Both are .js, both covered by the *.js text eol=lf rule added in #1790, and neither was renormalized when that rule landed. So the rule created the condition while the gate it shipped alongside reported:

Shebang EOL check passed: all 45 shebanged files are pinned to LF.
EXIT=0

A/B against the same tree, old gate vs new:

result
gate as merged on dev passed: all 45 shebanged files are pinned to LF - EXIT=0
widened gate FAILED: 2 problem(s), both CRLF-BLOB - EXIT=1

Why this half is the dangerous half

A rule over a CRLF blob means git normalizes the working tree to LF while the blob never moves, so the diff can never close. Modified in every worktree forever, git restore does not stick, and a broad staging command sweeps it up along with whatever real change happens to share the tree. Only --renormalize clears it.

Nothing about it is fatal, which is exactly why it survives for years. rss.xml.js has one CRLF line -- the failure mode in miniature, and proof it is scale-independent.

Fix -- a union, not a replacement

Each invariant now walks its own set:

Invariant Enumerated over Why that set
UNPINNED files starting with #! only the #! walk finds a shebanged file that no rule pins
CRLF-BLOB files where git check-attr eol = lf the churn class is defined by the rule

Also: eolAttributes now feeds paths over stdin rather than argv. It is called with every tracked file now (~1800 paths, >70KB), which overflows the 32767-character Windows command-line limit.

Both offending files renormalized. Pure EOL -- git diff --cached --ignore-cr-at-eol --stat across the pair is empty, zero content lines.

Proof the tests fail pre-fix

Swapped in dev''s pre-widening check-shebang-eol.mjs, kept the new tests:

 FAIL  > flags a NON-shebang file pinned to LF whose blob stores CRLF
   - [ ObjectContaining{"file": "web/app.js", "kind": "crlf-blob"} ]
   + []

 FAIL  > scans far more LF-pinned files than shebanged ones (guards the widened enumeration)
 TypeError: Cannot read properties of undefined (reading 'length')
 > test/scripts/check-shebang-eol.test.ts:191:26
     191|     expect(result.pinned.length).toBeGreaterThan(100);

 Tests  2 failed | 9 passed (11)

Restored the fix -- 11 passed. The non-shebang test asserts its fixture is absent from the shebang enumeration before asserting the violation, so it cannot pass by accident if the scan is ever narrowed back down.

Scope boundary -- written into the file, not left to be rediscovered

Still not covered: a file that ought to be pinned, has no shebang, and has no rule yet. Vitest snapshots were exactly this until *.snap text eol=lf was added. There is no cheap static signature for "some tool writes this file with LF" -- it cannot be determined by reading the file -- so guessing would trade a sound check for an unsound one. New tool-written types get pinned by hand; from the moment a rule exists, CRLF-BLOB enforces it.

Verification

$ git diff --cached --stat
 docs/src/pages/rss.xml.js               |   2 +-
 packages/squad-cli/src/remote-ui/app.js | 668 ++++++++++++++++----------------
 scripts/check-shebang-eol.mjs           | 124 ++++--
 test/scripts/check-shebang-eol.test.ts  |  41 ++
 4 files changed, 470 insertions(+), 365 deletions(-)

$ git diff --cached --diff-filter=D --name-only
(empty)

$ git diff --cached --ignore-cr-at-eol --stat -- docs/src/pages/rss.xml.js packages/squad-cli/src/remote-ui/app.js
(empty)
  • npm run build -- passes. prebuild version/template churn restored, not committed.
  • npx vitest run test/scripts/ -- 7 files, 75 tests, all passing.
  • node scripts/check-shebang-eol.mjs -- EOL check passed: 45 shebanged file(s) all pinned to LF, 173 LF-pinned file(s) all storing LF blobs. EXIT=0.
  • node scripts/security-review.mjs and architectural-review.mjs -- both clean locally, pre-checked because dev does not yet carry fix(ci): stop splicing repo-health report content into github-script bodies #1786 and any emitted finding would crash the base-branch reporter.

Changeset

packages/squad-cli/src/remote-ui/app.js is touched, so changelog-gate fires -- but the change is zero content lines, purely CR removal in the stored blob. There is no behavior to announce and a release note would be manufactured signal. Requesting skip-changelog on the same reasoning accepted for cli-entry.ts in #1790.

Measurement note

CR counts here come from reading blob bytes directly into a Buffer via git cat-file. Piping git cat-file through PowerShell re-encodes line endings and returns a confidently wrong number.

check-shebang-eol.mjs enumerated candidates by `#!` at byte 0 and then asked two
questions about that set: is it pinned to LF, and does its blob store CRLF.

The second question has the wrong enumeration. The churn class is defined by
`eol=lf rule + CRLF blob`, not by shebangs. The two sets overlap and neither
contains the other, so the gate was blind to every pinned file without a `#!` --
which is most of them. On dev @ dbb4c33 that blindness was live:

  tracked files              : 1811
  files with eol=lf attribute:  173
    of those, CRLF blob      :    2
       - docs/src/pages/rss.xml.js               (no shebang: gate blind)
       - packages/squad-cli/src/remote-ui/app.js (no shebang: gate blind)

Both are .js, both covered by the `*.js text eol=lf` rule added in #1790, and
neither was renormalized when that rule landed -- so the rule itself created the
condition while the gate reported "all 45 shebanged files are pinned to LF",
exit 0.

That half is the dangerous half. A rule over a CRLF blob means git normalizes
the working tree to LF while the blob never moves, so the diff can never close:
modified in every worktree forever, `git restore` does not stick, and a broad
`git add` sweeps it along with whatever real change shares the tree. Only
`git add --renormalize` clears it. Nothing about it is fatal, which is why it
survives for years -- rss.xml.js has exactly ONE CRLF line and has presumably
churned unnoticed for as long as the rule has existed.

The fix is a union, not a replacement. Each invariant now walks its own set:

  UNPINNED   over files starting with `#!`  -- only this walk finds a shebanged
                                               file that no rule pins
  CRLF-BLOB  over files where check-attr eol=lf -- the churn class

eolAttributes now feeds paths over stdin rather than argv, because it is called
with every tracked file (~1800 paths, >70KB) and that overflows the 32767-char
Windows command line.

Both offending files renormalized. Pure EOL: `git diff --ignore-cr-at-eol` is
empty across the pair, zero content lines.

Proven red before green. Against dev's pre-widening gate the two new tests fail:

  FAIL > flags a NON-shebang file pinned to LF whose blob stores CRLF
    - [ ObjectContaining{file: "web/app.js", kind: "crlf-blob"} ]
    + []
  FAIL > scans far more LF-pinned files than shebanged ones
    TypeError: Cannot read properties of undefined (reading 'length')

  Tests  2 failed | 9 passed (11)

Restored: 11 passed. The non-shebang test asserts the fixture is absent from the
shebang enumeration first, so it cannot pass by accident if the scan is ever
narrowed back down.

The scope boundary is documented in the file rather than left to be
rediscovered: still not covered is a file that ought to be pinned, has no
shebang, and has no rule yet -- vitest snapshots until `*.snap` was added. There
is no cheap static signature for "some tool writes this with LF", and guessing
would trade a sound check for an unsound one.

Closes #1804

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6
Copilot AI lite review requested due to automatic review settings August 21, 2026 09:10
@github-actions

Copy link
Copy Markdown
Contributor

🟡 Impact Analysis — PR #1805

Risk tier: 🟡 MEDIUM

📊 Summary

Metric Count
Files changed 4
Files added 0
Files modified 4
Files deleted 0
Modules touched 4

🎯 Risk Factors

  • 4 files changed (≤5 → LOW)
  • 4 modules touched (2-4 → MEDIUM)

📦 Modules Affected

docs (1 file)
  • docs/src/pages/rss.xml.js
scripts (1 file)
  • scripts/check-shebang-eol.mjs
squad-cli (1 file)
  • packages/squad-cli/src/remote-ui/app.js
tests (1 file)
  • test/scripts/check-shebang-eol.test.ts

This report is generated automatically for every PR. See #733 for details.

@bradygaster bradygaster added the skip-changelog Skip changelog enforcement for this PR label Aug 21, 2026
@bradygaster

Copy link
Copy Markdown
Owner Author

skip-changelog rationale.

This PR touches packages/squad-cli/src/remote-ui/app.js, so changelog-gate fires. The change to that file is zero content lines -- purely CR removal from the stored blob:

$ git diff --cached --ignore-cr-at-eol --stat -- docs/src/pages/rss.xml.js packages/squad-cli/src/remote-ui/app.js
(empty)

No shipped behavior changes, so a changeset would announce a fix that no consumer experienced. That is manufactured release-note signal, which is the same species of false coverage this workstream has spent the night removing.

Same reasoning accepted for cli-entry.ts in #1790.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

🛫 PR Readiness Check

ℹ️ This comment updates on each push. Last checked: commit 30794e5

PR Scope: 📦🔧 Mixed (product + infrastructure)

⚠️ 2 item(s) to address before review

Status Check Details
Single commit 1 commit — clean history
Not in draft Ready for review
Branch up to date Up to date with dev
Copilot review No Copilot review yet — it may still be processing
Changeset present Missing .changeset/*.md or CHANGELOG.md edit — run npx changeset add (or add skip-changelog label)
Scope clean No .squad/ or docs/proposals/ files
No merge conflicts No merge conflicts
Copilot threads resolved No Copilot review threads
CI passing All checks passing

Files Changed (4 files, +470 −365)

File +/−
docs/src/pages/rss.xml.js +1 −1
packages/squad-cli/src/remote-ui/app.js +334 −334
scripts/check-shebang-eol.mjs +94 −30
test/scripts/check-shebang-eol.test.ts +41 −0

Total: +470 −365


This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.

Copilot AI 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

Updates CI EOL validation to detect CRLF blobs across all LF-pinned files and normalizes two affected files.

Changes:

  • Separates shebang and LF-pinned file enumeration.
  • Uses stdin-safe attribute checks.
  • Adds regression coverage and removes CRLF-only churn.
Show a summary per file
File Summary
test/scripts/check-shebang-eol.test.ts Adds regression tests for widened enumeration.
scripts/check-shebang-eol.mjs Expands EOL validation to all LF-pinned files.
packages/squad-cli/src/remote-ui/app.js Removes CRLF line endings only.
docs/src/pages/rss.xml.js Removes CRLF line endings only.

Review details

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

  • Files reviewed: 2/4 changed files
  • Comments generated: 0
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changelog Skip changelog enforcement for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check-shebang-eol gate is blind to the churn class it exists to catch (2 CRLF blobs live on dev)

2 participants