/** * Gate: tracked text files must end with a final newline and no trailing * blank lines. * * Run via `bun run check` from the package root (after `tsc --noEmit`). * Neither tsc nor the test suite has an opinion about the last byte of a * file, so worker-authored files kept landing without one — each a spurious * two-line hunk on the next edit. This is the cheap mechanical check that * closes that gap: it walks `git ls-files`, checks every tracked text file * by extension, and names every offender in a single run. */ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; /** Tracked text suffixes the gate applies to. */ export const INCLUDED_SUFFIXES: readonly string[] = [ ".ts", ".js", ".css", ".html", ".md", ".json", ".yml", ".yaml", ".toml", ".sh", ]; /** * Generated files the gate must not police, named explicitly rather than * guessed at by content. Paths are repo-root-relative, as `git ls-files` * reports them. */ export const EXCLUDED_PATHS: readonly string[] = [ "omp/bun.lock", // regenerated by `bun install` "omp/schema/config.schema.json", // regenerated by `bun run schema` ]; /** Reason this file's ending fails the gate, or null when it is correct. */ export function checkEnding(bytes: Uint8Array): string | null { let trailingNewlines = 0; for (let i = bytes.length - 1; i >= 0 && bytes[i] === 0x0a; i--) { trailingNewlines++; } if (trailingNewlines === 0) return "missing trailing newline"; if (trailingNewlines > 2) return `ends with ${trailingNewlines - 1} blank lines`; return null; } /** * Check every file, returning one violation line per offender so a single * run names every bad path instead of dying on the first. */ export function findViolations( paths: readonly string[], read: (path: string) => Uint8Array = readFileSync, ): string[] { const violations: string[] = []; for (const path of paths) { const bytes = read(path); if (bytes.length === 0) continue; // nothing to terminate const reason = checkEnding(bytes); if (reason !== null) violations.push(`${path}: ${reason}`); } return violations; } function repoRoot(): string { return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); } if (import.meta.main) { const root = repoRoot(); const tracked = execFileSync("git", ["-C", root, "ls-files"], { encoding: "utf8" }) .split("\n") .filter((path) => path.length > 0); const textFiles = tracked.filter( (path) => INCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix)) && !EXCLUDED_PATHS.includes(path), ); const violations = findViolations(textFiles, (path) => readFileSync(join(root, path))); if (violations.length > 0) { for (const violation of violations) console.error(violation); console.error(`trailing newline gate: ${violations.length} file(s) need fixing`); process.exit(1); } console.log("trailing newline gate: ok"); }