Files
1d35e90880 fix(windows): preserve a file's existing line endings on rewrite (#1958)
* fix(windows): preserve a file's existing line endings on rewrite

The parsers normalize CRLF to LF on read, but nothing restored it on
write. On a Windows checkout (core.autocrlf=true) that turned every
rewrite into a whole-file change: applying a delta that added one
requirement produced a diff of 21 insertions and 14 deletions, burying
the real change. Archiving the same spec now writes 7 insertions and 0
deletions.

- specs-apply: write an updated spec back with the convention the file
  already used; a spec that does not exist yet stays LF.
- file-system: same fix for updateFileWithMarkers, so installing shell
  completions into a CRLF .bashrc/.zshrc no longer leaves mixed endings,
  which bash reports as "$'\r': command not found".
- pack-version-check: spawn npm through cross-spawn, since execFile
  cannot resolve npm.cmd on Windows.

Adds src/utils/line-endings.ts for the detect/restore pair, plus tests
pinning the CRLF round trip through the real write paths. Also adds
regression tests for path containment under Windows case variance:
path.win32.relative already folds case, and those tests pin both halves
of the contract so a future "case-insensitive" change cannot quietly
loosen the traversal guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(windows): keep removeMarkerBlock on the file's own newline

Addresses the two review points and one more instance of the same bug.

`removeMarkerBlock` collapses a run of blank lines, and rebuilt the
separator as a bare '\n' regardless of the file it came from. Removing a
managed block from a CRLF CLAUDE.md or rc file therefore left a lone LF
behind - the mixed ending this PR exists to prevent. It now uses the
newline it already detects for the trailing ending.

Test fixes:

- `marker-updates.test.ts`: close `describe('line endings')` so
  `removeMarkerBlock` is no longer nested inside `updateFileWithMarkers`.
- `path-containment.test.ts`: exercise `FileSystemUtils.assertPathWithin`
  and `resolveProjectArtifactPath` instead of a private copy of the
  containment logic, which passed whatever the production guard did. The
  guard had no coverage at all; a prefix-comparison regression now fails
  the sibling case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): read the file's convention consistently, and only ENOENT as absent

Three follow-ups from CodeRabbit's pass on the superseding PR.

`writeUpdatedSpec` turned every read error into "no previous file", so an
existing but unreadable spec was treated as absent and rewritten as LF.
Only ENOENT means absent now; everything else propagates.

`removeMarkerBlock` chose CRLF whenever the content held one anywhere, so
a single stray CRLF in an otherwise-LF file pulled the whole rewrite to
CRLF. It now uses detectLineEnding, the same dominant-ending reading
matchLineEnding uses, so both write paths agree.

Added the alias-path case the containment suite was missing: a directory
link inside the root that resolves outside it. That exercises the
canonicalization half of the guard, which a lexical check cannot do - the
link's own path looks contained. Skipped where creating a directory link
needs a privilege the runner lacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Travis James <travis@tribehealthsolutions.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 14:30:24 +00:00
..