From db043cff02400aa8a0e5db4b63a5d5397cd17e98 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 2 Oct 2026 17:26:40 -0400 Subject: [PATCH] fix(next): keep line endings in files paramour init edits In a CRLF checkout, init compared its LF agents snippet with the CRLF section byte for byte, so it reported "updated" for an unchanged section and wrote LF lines into a CRLF file. addPackageScript and the next.config wrap had the same problem, rewriting CRLF files as LF, and recast also dropped next.config's final newline. Init now compares its section with line endings normalized (sameContent) and writes every edit using the file's own line ending (lineEnding), and the next.config wrap restores the source's final newline. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/init-keeps-line-endings.md | 5 ++ packages/next/src/emit.ts | 10 ++++ packages/next/src/init/agents-md.ts | 13 +++-- packages/next/src/init/scaffold.ts | 15 ++++-- packages/next/src/init/wrap-next-config.ts | 9 +++- packages/next/test/init.test.ts | 53 +++++++++++++++++++++ packages/next/test/wrap-next-config.test.ts | 23 +++++++++ 7 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 .changeset/init-keeps-line-endings.md diff --git a/.changeset/init-keeps-line-endings.md b/.changeset/init-keeps-line-endings.md new file mode 100644 index 0000000..d9d89a4 --- /dev/null +++ b/.changeset/init-keeps-line-endings.md @@ -0,0 +1,5 @@ +--- +"@paramour-js/next": patch +--- + +`paramour init` keeps the line endings of the files it edits. In a CRLF checkout (git `core.autocrlf=true`), it used to report "updated paramour section" for an unchanged `AGENTS.md`/`CLAUDE.md` section and leave the file with mixed CRLF and LF line endings. It also rewrote a CRLF `package.json` or `next.config` entirely in LF. It now recognizes its own section regardless of line endings, and writes each edit using the file's existing line ending. Wrapping `next.config` also no longer drops the file's final newline. diff --git a/packages/next/src/emit.ts b/packages/next/src/emit.ts index b78daac..ad85f66 100644 --- a/packages/next/src/emit.ts +++ b/packages/next/src/emit.ts @@ -15,6 +15,16 @@ export interface WriteIfChangedResult { written: boolean; } +/** + * The line ending a user-owned file already uses. Init edits files the user + * owns (package.json, next.config, AGENTS.md), and writing LF text into a CRLF file + * leaves mixed endings, so those edits keep the file's own ending. Generated + * files are always written LF instead. + */ +export function lineEnding(text: string): "\n" | "\r\n" { + return text.includes("\r\n") ? "\r\n" : "\n"; +} + /** * Content equality that ignores CRLF vs LF. A consumer repo with git * `core.autocrlf=true` checks the committed LF artifact out as CRLF; that diff --git a/packages/next/src/init/agents-md.ts b/packages/next/src/init/agents-md.ts index 77867dd..85d5ca3 100644 --- a/packages/next/src/init/agents-md.ts +++ b/packages/next/src/init/agents-md.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import { lineEnding, sameContent } from "../emit.js"; + export const AGENTS_MARKER_START = ""; export const AGENTS_MARKER_END = ""; @@ -59,20 +61,21 @@ export function upsertAgentsSection(text: string): { status: "added" | "unchanged" | "unterminated" | "updated"; text: string; } { + const eol = lineEnding(text); + const snippet = agentsSnippet().replaceAll("\n", eol); const start = text.indexOf(AGENTS_MARKER_START); if (start === -1) { - const base = text === "" || text.endsWith("\n") ? text : `${text}\n`; - const separator = base === "" ? "" : "\n"; + const base = text === "" || text.endsWith("\n") ? text : `${text}${eol}`; + const separator = base === "" ? "" : eol; return { status: "added", - text: `${base}${separator}${agentsSnippet()}\n`, + text: `${base}${separator}${snippet}${eol}`, }; } const end = text.indexOf(AGENTS_MARKER_END, start); if (end === -1) return { status: "unterminated", text }; const current = text.slice(start, end + AGENTS_MARKER_END.length); - const snippet = agentsSnippet(); - if (current === snippet) return { status: "unchanged", text }; + if (sameContent(current, snippet)) return { status: "unchanged", text }; return { status: "updated", text: diff --git a/packages/next/src/init/scaffold.ts b/packages/next/src/init/scaffold.ts index 885104d..fda9340 100644 --- a/packages/next/src/init/scaffold.ts +++ b/packages/next/src/init/scaffold.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; +import { lineEnding } from "../emit.js"; import { resolveRouteDirs } from "../scan.js"; import { loadPackagedSkill } from "../skills/packaged.js"; import { auditTarget, isOutdated } from "../skills/sync.js"; @@ -15,9 +16,9 @@ export interface SetupCheck { /** * Insert `"paramour": "paramour generate"` into a package.json's scripts, - * preserving the file's own indentation and trailing-newline choice. - * Throws on malformed JSON — a broken package.json is init's one hard - * prerequisite failure. + * preserving the file's own indentation, line ending, and trailing-newline + * choice. Throws on malformed JSON — a broken package.json is init's one + * hard prerequisite failure. */ export function addPackageScript(text: string): { changed: boolean; @@ -41,8 +42,12 @@ export function addPackageScript(text: string): { if (scripts?.paramour !== undefined) return { changed: false, text }; pkg.scripts = { ...scripts, paramour: "paramour generate" }; const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; - const trailing = text.endsWith("\n") ? "\n" : ""; - return { changed: true, text: JSON.stringify(pkg, null, indent) + trailing }; + const eol = lineEnding(text); + const trailing = text.endsWith("\n") ? eol : ""; + return { + changed: true, + text: JSON.stringify(pkg, null, indent).replaceAll("\n", eol) + trailing, + }; } /** diff --git a/packages/next/src/init/wrap-next-config.ts b/packages/next/src/init/wrap-next-config.ts index 72cb0de..648008d 100644 --- a/packages/next/src/init/wrap-next-config.ts +++ b/packages/next/src/init/wrap-next-config.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import { lineEnding } from "../emit.js"; + /** * The `paramour init` next.config codemod (magicast: recast printing + * babel-ts parsing, so TS/ESM configs transform format-preservingly). @@ -179,7 +181,12 @@ export async function wrapNextConfigSource( local ?? "withTypedRoutes", current, ); - return { code: generateCode(mod).code, status: "wrapped" }; + // recast prints new nodes with LF and drops the final newline; keep the + // user's file as it was on both counts. + const eol = lineEnding(source); + let code = generateCode(mod).code.replaceAll("\r\n", "\n"); + if (source.endsWith("\n") && !code.endsWith("\n")) code += "\n"; + return { code: code.replaceAll("\n", eol), status: "wrapped" }; } catch { return { snippet: manualSnippet(), status: "manual" }; } diff --git a/packages/next/test/init.test.ts b/packages/next/test/init.test.ts index 301fdf1..a50d339 100644 --- a/packages/next/test/init.test.ts +++ b/packages/next/test/init.test.ts @@ -573,6 +573,59 @@ describe("paramour init", () => { expect(snapshotTree(root)).toEqual(before); }); + it("treats a CRLF checkout of its own section as up to date", async () => { + // git core.autocrlf=true checks the committed LF file out as CRLF. + const checkedOut = `${AGENTS_MD}\n${agentsSnippet()}\n`.replaceAll( + "\n", + "\r\n", + ); + const root = makeProject(["app/page.tsx"], { + "CLAUDE.md": checkedOut, + "package.json": PACKAGE_JSON, + }); + const run = await init(["--no-wrap"]); + expect(run.out.join("\n")).toContain( + "• CLAUDE.md paramour section already up to date — skipped", + ); + expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).toBe(checkedOut); + }); + + it("keeps a CRLF instructions file CRLF when appending and updating", async () => { + const root = makeProject(["app/page.tsx"], { + "AGENTS.md": AGENTS_MD.replaceAll("\n", "\r\n"), + "package.json": PACKAGE_JSON, + }); + await init(["--no-wrap"]); + const appended = readFileSync(join(root, "AGENTS.md"), "utf8"); + expect(appended).toContain(AGENTS_MARKER_START); + expect(appended).not.toMatch(/(? { + const root = makeProject(["app/page.tsx"], { + "package.json": PACKAGE_JSON.replaceAll("\n", "\r\n"), + }); + const run = await init(["--no-wrap"]); + expect(run.out.join("\n")).toContain( + `✔ added "paramour" script to package.json`, + ); + const text = readFileSync(join(root, "package.json"), "utf8"); + expect(text).toContain('"paramour": "paramour generate"'); + expect(text).not.toMatch(/(? { const root = makeProject(["app/page.tsx"], { "AGENTS.md": "# Notes without newline", diff --git a/packages/next/test/wrap-next-config.test.ts b/packages/next/test/wrap-next-config.test.ts index 56172d5..e6f2cd6 100644 --- a/packages/next/test/wrap-next-config.test.ts +++ b/packages/next/test/wrap-next-config.test.ts @@ -30,6 +30,29 @@ describe("wrapNextConfigSource", () => { ); // The untouched parts keep their original formatting (recast). expect(result.code).toContain(" reactStrictMode: true,"); + expect( + result.code.endsWith("export default withTypedRoutes(nextConfig);\n"), + ).toBe(true); + }); + + it("keeps a CRLF config CRLF, including the added import", async () => { + const result = await wrapNextConfigSource( + IDENTIFIER_TS.replaceAll("\n", "\r\n"), + ); + expect(result.status).toBe("wrapped"); + if (result.status !== "wrapped") return; + expect(result.code).toContain( + "export default withTypedRoutes(nextConfig);", + ); + expect(result.code).not.toMatch(/(? { + const result = await wrapNextConfigSource(IDENTIFIER_TS.trimEnd()); + expect(result.status).toBe("wrapped"); + if (result.status !== "wrapped") return; + expect(result.code.endsWith("withTypedRoutes(nextConfig);")).toBe(true); }); it("wraps an object-literal default export", async () => {