Context
Found while reviewing PR #17223, which converts the block-alignment pre-commit gate from reject to repair (--fix --staged). The PR meets all five of its ACs on the path it ships, and this is a distinct defect in a sibling invocation path — filed rather than raised as a blocker, and owned by me because I found it.
The Problem
getStagedAddedLines derives its line numbers from git diff --cached (stagedDiff.mjs:59) — the index side. processFile reads and rewrites the working tree file. When those two differ, the owned-line set is applied to the wrong positions.
Reproduced at PR #17223 head e7b438dff3 in a scratch repo. A file with a staged misaligned object block, plus three later unstaged comment lines inserted above it:
<h1 class="neo-h1" data-record-id="4">staged: const zz / const obj block at index lines 1-5</h1>
<h1 class="neo-h1" data-record-id="5">worktree: 3 unstaged comment lines prepended, shifting everything by 3</h1>
$ node buildScripts/util/check-block-alignment.mjs --fix --staged f.mjs
Aligned 1 line(s) in f.mjs — left 1 untouched-line violation(s) as-is
exit=0
Result:
const zz = 1;
const obj = {
id: 1,
namelong: 2
};Two failures in one pass: it wrote to a line the author did not stage, and it did not repair the drift the author did stage. The message reads as success and the exit code is 0.
The Architectural Reality
buildScripts/util/stagedDiff.mjs:59 — git diff --cached --unified=0 -- <file>, so the returned set is new-side index line numbers.
buildScripts/util/check-block-alignment.mjs — processFile reads the file from disk (working tree) and, in the scoped-repair branch, indexes applied[violation.lineIndex] with those index-derived numbers.
- The mismatch is pre-existing — check mode (
--staged, shipped in #13720) has always had it. What PR #17223 changes is the consequence: the same divergence turned from reporting the wrong line number into rewriting the wrong line, silently.
Exposure is bounded, and that is why this is not a blocker on #17223:
- The hook path is safe.
lint-staged stashes unstaged changes for partially-staged files before running tasks, so index and working tree agree there. PR #17223's live dogfood exercises exactly this path.
- The check-mode failure message steers users to
--fix <files> (whole-file), not --fix --staged, so the documented remedy does not lead here.
- The exposure is the manual invocation of
--fix --staged, which the script's own usage header advertises.
Impact is whitespace-only — it cannot change program semantics — but it is a silent write to a line the author does not own, which is the exact harm the scoped repair's fail-closed design exists to prevent.
The Fix
Extend the existing fail-closed rule to cover the precondition it currently assumes. Before applying a scoped repair, verify the file has no unstaged changes (git diff --quiet -- <file>, non-zero exit ⇒ working tree differs from index). On divergence, take the path the guard already has for an unreliable staged set: report the violations, write nothing, return 'unfixable'.
That reuses the disposition PR #17223 introduced rather than adding a new one, and keeps the philosophy the file already states: "a git hiccup must never silently become a whole-file reformat inside an author's commit." A partially-staged file is the same hazard by a different route.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
check-block-alignment.mjs --fix --staged |
#17201 AC1/AC2 as shipped in PR #17223 |
Refuses to rewrite when the working tree differs from the index for that file |
Reports violations, exit 1, file byte-identical — the existing 'unfixable' path |
script usage header |
scratch-repo reproduction above at e7b438dff3 |
--staged check mode |
#13720 |
Unchanged — misreported line numbers are pre-existing and non-destructive |
n/a |
n/a |
same divergence, reporting-only consequence |
Acceptance Criteria
Out of Scope
- Changing check mode's line-number source, or teaching the scoped repair to translate index line numbers onto working-tree positions. Refusing is correct here: a partially-staged file has no single authoritative content for the repair to target.
- Any change to the detector or to
lint-staged wiring.
Avoided Traps
- Translate index positions to working-tree positions. Rejected: it requires diffing the working tree against the index and remapping, which reintroduces the same class of error with more machinery, for a case where refusing costs the author one
git stash.
- Read the file from the index instead of disk. Rejected: the repair must write the working tree, so reading the index would fix the line numbers and then write them to the wrong content.
- Treat it as pre-existing and leave it. Rejected: the divergence is pre-existing but its consequence is not — reporting a wrong line number and rewriting a wrong line are different severities.
Decision Record impact
none.
Related
- Sibling: #17201 (the convert-to-repair ticket) / PR #17223
- Origin of the scoping helper: #13720
- Origin of the aligner: #13556
Live latest-open sweep: checked latest 20 open issues at 2026-08-16T00:05Z; no equivalent found.
Origin Session ID: b17338dd-b474-494f-b08c-683044de2ddb
Retrieval Hint: check-block-alignment scoped repair index vs working tree partially staged getStagedAddedLines line number divergence
Context
Found while reviewing PR #17223, which converts the block-alignment pre-commit gate from reject to repair (
--fix --staged). The PR meets all five of its ACs on the path it ships, and this is a distinct defect in a sibling invocation path — filed rather than raised as a blocker, and owned by me because I found it.The Problem
getStagedAddedLinesderives its line numbers fromgit diff --cached(stagedDiff.mjs:59) — the index side.processFilereads and rewrites the working tree file. When those two differ, the owned-line set is applied to the wrong positions.Reproduced at PR #17223 head
e7b438dff3in a scratch repo. A file with a staged misaligned object block, plus three later unstaged comment lines inserted above it:Result:
// unstaged line A // unstaged line B // unstaged line C const zz = 1; // ← REWRITTEN. Not staged, not touched by the staged change. const obj = { id: 1, // ← the actually-staged drift, left unrepaired namelong: 2 };Two failures in one pass: it wrote to a line the author did not stage, and it did not repair the drift the author did stage. The message reads as success and the exit code is 0.
The Architectural Reality
buildScripts/util/stagedDiff.mjs:59—git diff --cached --unified=0 -- <file>, so the returned set is new-side index line numbers.buildScripts/util/check-block-alignment.mjs—processFilereads the file from disk (working tree) and, in the scoped-repair branch, indexesapplied[violation.lineIndex]with those index-derived numbers.--staged, shipped in #13720) has always had it. What PR #17223 changes is the consequence: the same divergence turned from reporting the wrong line number into rewriting the wrong line, silently.Exposure is bounded, and that is why this is not a blocker on #17223:
lint-stagedstashes unstaged changes for partially-staged files before running tasks, so index and working tree agree there. PR #17223's live dogfood exercises exactly this path.--fix <files>(whole-file), not--fix --staged, so the documented remedy does not lead here.--fix --staged, which the script's own usage header advertises.Impact is whitespace-only — it cannot change program semantics — but it is a silent write to a line the author does not own, which is the exact harm the scoped repair's fail-closed design exists to prevent.
The Fix
Extend the existing fail-closed rule to cover the precondition it currently assumes. Before applying a scoped repair, verify the file has no unstaged changes (
git diff --quiet -- <file>, non-zero exit ⇒ working tree differs from index). On divergence, take the path the guard already has for an unreliable staged set: report the violations, write nothing, return'unfixable'.That reuses the disposition PR #17223 introduced rather than adding a new one, and keeps the philosophy the file already states: "a git hiccup must never silently become a whole-file reformat inside an author's commit." A partially-staged file is the same hazard by a different route.
Contract Ledger Matrix
check-block-alignment.mjs --fix --staged'unfixable'pathe7b438dff3--stagedcheck modeAcceptance Criteria
--fix --stagedreports and writes nothing; the file is byte-identical afterwards, proven by a fixture whose unstaged edit shifts staged line numbers.--fix(whole-file, deliberate) is unaffected by the new precondition.Out of Scope
lint-stagedwiring.Avoided Traps
git stash.Decision Record impact
none.Related
Live latest-open sweep: checked latest 20 open issues at 2026-08-16T00:05Z; no equivalent found.
Origin Session ID: b17338dd-b474-494f-b08c-683044de2ddb
Retrieval Hint:
check-block-alignment scoped repair index vs working tree partially staged getStagedAddedLines line number divergence