/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: scripts/check-headers.ts * Purpose: CI lint — fail any source file missing or with a malformed mandatory author header */ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative, extname } from "node:path"; const ROOT = join(import.meta.dirname, ".."); // Extensions where comments are allowed and the header is mandatory. const CHECKED_EXTENSIONS = new Set([ ".ts", ".tsx", ".js", ".mjs", ".cjs", ".py", ".sql", ".sh", ".yaml", ".yml", ]); const ALWAYS_SKIPPED_DIRS = new Set([ "node_modules", "dist", ".next", ".turbo", ".git", "coverage", ]); function loadIgnorePatterns(): string[] { const raw = readFileSync(join(ROOT, "scripts", "check-headers.ignore"), "utf8"); return raw .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0 && !l.startsWith("#")); } function isIgnored(relPath: string, patterns: string[]): boolean { return patterns.some((p) => { if (p.endsWith("/")) return relPath.startsWith(p); if (p.includes("*")) { const rx = new RegExp( "^" + p.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", ); return rx.test(relPath); } return relPath === p; }); } function* walk(dir: string): Generator { for (const entry of readdirSync(dir)) { const full = join(dir, entry); const st = statSync(full); if (st.isDirectory()) { if (ALWAYS_SKIPPED_DIRS.has(entry)) continue; yield* walk(full); } else { yield full; } } } interface HeaderProblem { file: string; reason: string; } // The header must contain these lines (comment syntax varies by language). function checkHeader(relPath: string, content: string): string | null { const head = content.slice(0, 600); if (!head.includes("earth-now.co")) return "missing 'earth-now.co' banner line"; if (!head.includes("Author:") || !head.includes("Simon-Pierre Boucher")) return "missing 'Author: Simon-Pierre Boucher' line"; if (!head.includes("Contact:") || !head.includes("contact@spboucher.ai")) return "missing 'Contact: contact@spboucher.ai' line"; const fileLine = head.match(/File:\s+(\S+)/); if (!fileLine) return "missing 'File:' line"; if (fileLine[1] !== relPath) return `'File:' line says '${fileLine[1]}' but actual path is '${relPath}'`; if (!/Purpose:\s+\S/.test(head)) return "missing or empty 'Purpose:' line"; // Shebang scripts may put the header right after the shebang; otherwise it must open the file. const firstMeaningful = content.startsWith("#!") ? content.slice(content.indexOf("\n") + 1) : content; if (!/^\s*(\/\*\*|#|--)/.test(firstMeaningful)) return "header must be the first thing in the file (after an optional shebang)"; return null; } const patterns = loadIgnorePatterns(); const problems: HeaderProblem[] = []; let checked = 0; for (const file of walk(ROOT)) { const rel = relative(ROOT, file); if (!CHECKED_EXTENSIONS.has(extname(file))) continue; if (isIgnored(rel, patterns)) continue; checked++; const reason = checkHeader(rel, readFileSync(file, "utf8")); if (reason) problems.push({ file: rel, reason }); } if (problems.length > 0) { console.error(`✗ ${problems.length} file(s) with missing/malformed author header:\n`); for (const p of problems) console.error(` ${p.file} — ${p.reason}`); process.exit(1); } console.log(`✓ Author header OK on ${checked} source files.`);