Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions backend/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions scripts/validate-generated-artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
78 changes: 78 additions & 0 deletions scripts/validate-generated-artifacts.ts
Original file line number Diff line number Diff line change
@@ -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.');
}