From 8461f61ea0b2d18ceaaa3e6ea7d5b764fce90283 Mon Sep 17 00:00:00 2001 From: simonpeters298 Date: Tue, 29 Sep 2026 11:55:54 +0000 Subject: [PATCH] fix(backend): emit LF-only OpenAPI spec to avoid phantom Windows diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate:openapi preserved the EOL already present in backend/openapi.json, so on Windows a CRLF working copy was re-written as CRLF. Regeneration then left the file flagged as modified while `git diff` was empty — a phantom change caused purely by CRLF/LF normalization. Always write LF, pin generated artifacts to `text eol=lf` in .gitattributes, and add a validator + test that fails on CRLF in generated artifacts. Closes #1374 --- .gitattributes | 9 +++ backend/scripts/generate-openapi.ts | 9 ++- package.json | 2 + scripts/validate-generated-artifacts.test.ts | 62 ++++++++++++++++ scripts/validate-generated-artifacts.ts | 78 ++++++++++++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 scripts/validate-generated-artifacts.test.ts create mode 100644 scripts/validate-generated-artifacts.ts diff --git a/.gitattributes b/.gitattributes index 6313b56c5..0f004f57a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,10 @@ +# Normalize all text files to LF in the repository and on checkout. * text=auto eol=lf + +# Generated artifacts are rewritten by tooling, so pin them explicitly to LF. +# Without this, a CRLF working copy produced by a Windows editor/checkout is +# preserved by regeneration and shows up as a phantom modification even though +# the content is identical after normalization (see issue #1374). +backend/openapi.json text eol=lf +backend/schema-snapshots/*.json text eol=lf +docs/schemas/webhooks/*.json text eol=lf diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index ac14858c8..3d8901a93 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -1,15 +1,18 @@ import fs from 'fs'; import path from 'path'; -import os from 'os'; import { specs } from '../src/swagger'; const outputPath = path.resolve(__dirname, '../openapi.json'); try { console.log('Generating openapi.json...'); + // Always emit LF, matching the repository's `.gitattributes` (`* text=auto eol=lf`). + // Previously the generator preserved whatever EOL the existing file happened to use, + // which made output platform-dependent: on Windows a CRLF working-tree copy was kept + // as CRLF, so regenerating a byte-identical spec showed up as a phantom `M` change + // (see issue #1374). const content = JSON.stringify(specs, null, 2); - const eol = fs.existsSync(outputPath) && fs.readFileSync(outputPath, 'utf8').includes('\r\n') ? '\r\n' : '\n'; - fs.writeFileSync(outputPath, content.replace(/\n/g, eol), 'utf8'); + fs.writeFileSync(outputPath, content, 'utf8'); console.log(`✅ openapi.json generated successfully at ${outputPath}`); } catch (error) { console.error('❌ Failed to generate openapi.json:', error); diff --git a/package.json b/package.json index c48062a7c..be9b0cf86 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "compress:images": "bash scripts/compress-pngs.sh", "validate:images": "tsx scripts/validate-image-sizes.ts", "test:validate-images": "vitest run scripts/validate-image-sizes.test.ts", + "validate:line-endings": "tsx scripts/validate-generated-artifacts.ts", + "test:validate-line-endings": "vitest run scripts/validate-generated-artifacts.test.ts", "clean:git-history": "bash scripts/purge-git-large-blobs.sh", "validate:frontend-env": "tsx scripts/validate-frontend-env.ts", "test:validate-frontend-env": "vitest run --config scripts/vitest.config.ts", diff --git a/scripts/validate-generated-artifacts.test.ts b/scripts/validate-generated-artifacts.test.ts new file mode 100644 index 000000000..d37bc9305 --- /dev/null +++ b/scripts/validate-generated-artifacts.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + collectGeneratedArtifacts, + validateGeneratedArtifactLineEndings, +} from './validate-generated-artifacts'; + +describe('validate-generated-artifacts', () => { + const rootDir = path.resolve(__dirname, '..'); + + it('commits every generated artifact with LF-only line endings', () => { + const report = validateGeneratedArtifactLineEndings(rootDir); + expect(report.checkedFiles.length).toBeGreaterThan(0); + expect(report.violations).toEqual([]); + expect(report.valid).toBe(true); + }); + + it('discovers the OpenAPI document and the schema snapshots', () => { + const files = collectGeneratedArtifacts(rootDir).map((file) => path.relative(rootDir, file)); + expect(files).toContain(path.join('backend', 'openapi.json')); + expect(files.some((file) => file.startsWith(path.join('backend', 'schema-snapshots')))).toBe( + true, + ); + expect(files.some((file) => file.startsWith(path.join('docs', 'schemas', 'webhooks')))).toBe( + true, + ); + }); + + it('flags generated artifacts that contain CRLF line endings', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'generated-eol-')); + try { + fs.mkdirSync(path.join(tmpRoot, 'backend', 'schema-snapshots'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'backend', 'openapi.json'), '{\r\n "openapi": "3.1.0"\r\n}\r\n'); + fs.writeFileSync( + path.join(tmpRoot, 'backend', 'schema-snapshots', 'get-_health.json'), + '{\n "ok": true\n}\n', + ); + + const report = validateGeneratedArtifactLineEndings(tmpRoot); + + expect(report.valid).toBe(false); + expect(report.violations).toEqual([ + { filePath: path.join('backend', 'openapi.json'), crlfCount: 3 }, + ]); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('ignores non-generated files', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'generated-eol-')); + try { + fs.mkdirSync(path.join(tmpRoot, 'backend'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'backend', 'README.md'), '# CRLF\r\n'); + expect(collectGeneratedArtifacts(tmpRoot)).toEqual([]); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/validate-generated-artifacts.ts b/scripts/validate-generated-artifacts.ts new file mode 100644 index 000000000..c285600e9 --- /dev/null +++ b/scripts/validate-generated-artifacts.ts @@ -0,0 +1,78 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface LineEndingViolation { + filePath: string; + crlfCount: number; +} + +export interface GeneratedArtifactsReport { + valid: boolean; + checkedFiles: string[]; + violations: LineEndingViolation[]; +} + +/** + * Committed artifacts that are rewritten by generators. They must be stored + * with LF-only line endings: on Windows a CRLF working copy produced by an + * editor or a legacy checkout was previously preserved by regeneration and + * showed up as a phantom `M` change with an empty diff. See issue #1374. + */ +const GENERATED_ARTIFACT_FILES = ['backend/openapi.json']; + +const GENERATED_ARTIFACT_DIRS = ['backend/schema-snapshots', 'docs/schemas/webhooks']; + +export function collectGeneratedArtifacts(rootDir = process.cwd()): string[] { + const files = GENERATED_ARTIFACT_FILES.map((rel) => path.join(rootDir, rel)); + + for (const relDir of GENERATED_ARTIFACT_DIRS) { + const dir = path.join(rootDir, relDir); + if (!fs.existsSync(dir)) continue; + for (const entry of fs.readdirSync(dir)) { + if (entry.endsWith('.json')) files.push(path.join(dir, entry)); + } + } + + return files.filter((file) => fs.existsSync(file)); +} + +export function validateGeneratedArtifactLineEndings( + rootDir = process.cwd(), +): GeneratedArtifactsReport { + const files = collectGeneratedArtifacts(rootDir); + const violations: LineEndingViolation[] = []; + + for (const file of files) { + const crlfCount = (fs.readFileSync(file, 'utf8').match(/\r\n/g) ?? []).length; + if (crlfCount > 0) { + violations.push({ filePath: path.relative(rootDir, file), crlfCount }); + } + } + + return { + valid: violations.length === 0, + checkedFiles: files.map((file) => path.relative(rootDir, file)), + violations, + }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + console.log('Validating line endings of generated artifacts...'); + const report = validateGeneratedArtifactLineEndings(); + + console.log(`Checked ${report.checkedFiles.length} generated artifact(s):`); + for (const file of report.checkedFiles) { + console.log(` - ${file}`); + } + + if (!report.valid) { + console.error('❌ Generated artifacts contain CRLF line endings:'); + for (const violation of report.violations) { + console.error(` - ${violation.filePath} (${violation.crlfCount} CRLF)`); + } + console.error("Re-run the generator (e.g. 'npm run generate:openapi') to normalize to LF."); + process.exit(1); + } + + console.log('✅ All generated artifacts use LF-only line endings.'); +}