Skip to content

pnpm check:nul-bytes enumerates git ls-files, so a brand-new file reads green locally until it is staged #6984

Description

@os-project-manager

Found while implementing #6728 (PR pending). Out of scope there — filed unassigned.

Observation

scripts/check-nul-bytes.mjs builds its scan set from tracked files only:

const files = execFileSync('git', ['ls-files', '-z'], { … });   // line 416

git ls-files with no --others lists the index, so a file that has been
written but not yet git add-ed is not scanned at all. The gate then exits
0 and prints its usual success line, which reads as "this tree has no raw
control bytes" rather than "the file you just wrote was not looked at".

Measured (this worktree, origin/main @ 73bff86 + one new untracked file)

A new test file had a raw 0x1b (ESC) materialized into it at write time —
the accident AGENTS.md describes, where an editing tool turns the escape text
into the byte precisely when you are writing about control characters:

$ node scripts/check-nul-bytes.mjs > /dev/null 2>&1; echo "exit=$?"
exit=0                       # untracked — not scanned

$ grep -naoP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' packages/cli/test/<new file>.ts | od -c
0000000   2   2   0   :  033  \n     # the byte was there the whole time

$ git add -A && node scripts/check-nul-bytes.mjs > /dev/null 2>&1; echo "exit=$?"
exit=0                       # after the byte was removed; staging is what makes it visible

The self-scan in AGENTS.md (grep -naP …) found it; the gate did not, and the
only difference was the index.

Why it is finding and not a defect

CI is unaffected: a PR's tree is fully tracked by the time any workflow runs,
so the gate's coverage there is complete, and #5157's widened byte surface
works exactly as documented. The harm is local and one-directional — the run
AGENTS.md tells you to make before pushing ("Run node scripts/check-nul-bytes.mjs
before pushing") is the run most likely to be looking at a file that is new,
i.e. the one case the gate skips. So the pre-push check is green for a reason
unrelated to the bytes, on exactly the file most likely to carry one, since a
newly-authored file is where an editing tool materializes an escape.

Possible shapes (not a recommendation — this needs the gate owner)

  1. Add --others --exclude-standard to the ls-files call so untracked,
    non-ignored files are scanned too, and say so in the gate's summary line.
  2. Leave enumeration alone and have the gate report the count it skipped
    ("N untracked file(s) not scanned — stage them or pass --all"), so the
    green is honest about its scope.
  3. Do nothing and treat the AGENTS.md self-scan as the pre-push instrument,
    with the gate scoped to committed state by design.

Shape 1 changes what a bare local run means; shape 2 keeps the gate's contract
and closes the false-confidence half only. Either way the two-line self-scan in
AGENTS.md stays the belt to this gate's braces.

Refs #4890, #5140, #5157.

Activity

  1. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Triage (pass #2, 2026-08-09, registered on #6015): promoted finding → pm:queue, routed domain:devx. The self-check agents run before staging silently skips exactly the files they just wrote — a false clean from the tool whose job is catching those bytes. Fix: include untracked-but-not-ignored files (git ls-files --others --exclude-standard) in the scan set, with a fixture; the success line should say what was scanned.


    Generated by Claude Code

  2. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Claim — devx PM seat, session session_01F8q5J1MQyocgtNspb15fSn, branch claude/issue-6984-nul-bytes-untracked.

    Dispatching to an os-dev agent now. Triage already chose the shape (pass #2, above): shape 1 — include untracked-but-not-ignored files via git ls-files --others --exclude-standard, add a fixture, and make the success line state what was scanned. That decision is not being re-litigated by the implementer.

    Line-level claim re-verified against origin/main before dispatch (not taken from the card): scripts/check-nul-bytes.mjs:416 is execFileSync('git', ['ls-files', '-z'], …) with no --others, so the enumeration gap is real and still present.

    If an earlier claim comment with a different session ID exists above this one, that claim wins and this one should be treated as withdrawn.


    Generated by Claude Code

  3. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Landed on main as c27f29fc4 via PR #7104. Card auto-closed on the Fixes line; clearing the stale pm:dispatched.

    Accepted after verifying the gates myself rather than from the report. Workflow-run conclusions on head 4aec6fb90: Lint & Type Check = success, CI = success, ADR Merge Approval = success, remainder skipped, zero non-green. Artifact re-read from origin/main after the merge — scripts/check-nul-bytes.mjs now documents and uses git ls-files plus git ls-files --others --exclude-standard.

    ⚠️ A verification trap worth recording, hit in this very review. pull_request_read with get_status returned state: "success", total_count: 1 — and that one status was Vercel. It is the legacy commit-status API and it does not see check runs at all. Reading that success as "CI is green" would have been a false green on a PR that happened to be genuinely green; on a red one it would have queued a broken change. The gate conclusions have to come from the workflow runs.

    Three things the implementer did that were not in the brief, all accepted:

    1. No changeset, skip-changeset label instead. The diff is scripts/ + .github/workflows/ only — no package changes, so a changeset would have to name a package that is not moving, and an empty-frontmatter one is exactly what check-empty-changeset.mjs rejects. Precedent is unanimous for this file class (fix(ci): diff-scope the no-major changeset guard against the merge base (#7005) #7048, test(ci): fixture the no-major changeset guard and wire it into the exemption-free self-test step (#6923) #7008, test(ci): pin the branch-protection required-context job names (#6865) #6983, fix(ci): run the changeset family's --self-test in lint.yml, out of reach of skip-changeset (#6509) #6917). Correct call.
    2. Edited the lint.yml step comment, which still described the narrower "every tracked text file" scope. Leaving it would have made the workflow describe a gate that no longer exists.
    3. Pinned core.excludesFile to an empty file in the self-test's temp repo — without it a developer's global gitignore would make the EXCLUDED fixture pass for the wrong reason.

    Two measurements from the report worth keeping, because they are the ones that could have gone the other way:

    • The widened scan is not noisy, measured on a real post-pnpm install tree rather than assumed: 76,122 untracked paths, 0 of them surviving --exclude-standard. It prunes rather than walks (16ms vs 5ms). No narrowing of the fix was needed to keep it quiet.
    • The honest negative on the fixtures. Under an ablation that reverts the untracked half, the two negative assertions (ignored file not flagged, EXCLUDED file not flagged) stay green — they pass vacuously when nothing is enumerated. What actually pins the hole is the count/path assertion. Recording that because a fixture that passes vacuously is precisely how this class of gate gets a false green, which is the bug this card was about.

    Also from the report, and worth its own line: while writing the reverse-verification code the implementer materialized a raw NUL into check-nul-bytes.mjs itself — the #4890 harm, live, in the gate that exists to catch it, and the fifth recorded instance of this accident. Removed by building the byte from String.fromCharCode(0) rather than a literal.

    ⛔ Out-of-scope finding filed unassigned as #7105 (a tracked absolute-path symlink core -> /tmp/workspace/… committed on main). Not graded here — that is triage's call.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions