spb/earth-now Public License
earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.
TypeScript 93%
Shell 2.3%
SQL 1.4%
JavaScript 1.3%
Dockerfile 1.2%
CSS 0.8%
1/**2 * earth-now.co3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: scripts/check-headers.ts6 * Purpose: CI lint — fail any source file missing or with a malformed mandatory author header7 */89import { readFileSync, readdirSync, statSync } from "node:fs";10import { join, relative, extname } from "node:path";1112const ROOT = join(import.meta.dirname, "..");1314// Extensions where comments are allowed and the header is mandatory.15const CHECKED_EXTENSIONS = new Set([16 ".ts",17 ".tsx",18 ".js",19 ".mjs",20 ".cjs",21 ".py",22 ".sql",23 ".sh",24 ".yaml",25 ".yml",26]);2728const ALWAYS_SKIPPED_DIRS = new Set([29 "node_modules",30 "dist",31 ".next",32 ".turbo",33 ".git",34 "coverage",35]);3637function loadIgnorePatterns(): string[] {38 const raw = readFileSync(join(ROOT, "scripts", "check-headers.ignore"), "utf8");39 return raw40 .split("\n")41 .map((l) => l.trim())42 .filter((l) => l.length > 0 && !l.startsWith("#"));43}4445function isIgnored(relPath: string, patterns: string[]): boolean {46 return patterns.some((p) => {47 if (p.endsWith("/")) return relPath.startsWith(p);48 if (p.includes("*")) {49 const rx = new RegExp(50 "^" + p.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$",51 );52 return rx.test(relPath);53 }54 return relPath === p;55 });56}5758function* walk(dir: string): Generator<string> {59 for (const entry of readdirSync(dir)) {60 const full = join(dir, entry);61 const st = statSync(full);62 if (st.isDirectory()) {63 if (ALWAYS_SKIPPED_DIRS.has(entry)) continue;64 yield* walk(full);65 } else {66 yield full;67 }68 }69}7071interface HeaderProblem {72 file: string;73 reason: string;74}7576// The header must contain these lines (comment syntax varies by language).77function checkHeader(relPath: string, content: string): string | null {78 const head = content.slice(0, 600);79 if (!head.includes("earth-now.co")) return "missing 'earth-now.co' banner line";80 if (!head.includes("Author:") || !head.includes("Simon-Pierre Boucher"))81 return "missing 'Author: Simon-Pierre Boucher' line";82 if (!head.includes("Contact:") || !head.includes("contact@spboucher.ai"))83 return "missing 'Contact: contact@spboucher.ai' line";84 const fileLine = head.match(/File:\s+(\S+)/);85 if (!fileLine) return "missing 'File:' line";86 if (fileLine[1] !== relPath)87 return `'File:' line says '${fileLine[1]}' but actual path is '${relPath}'`;88 if (!/Purpose:\s+\S/.test(head)) return "missing or empty 'Purpose:' line";89 // Shebang scripts may put the header right after the shebang; otherwise it must open the file.90 const firstMeaningful = content.startsWith("#!")91 ? content.slice(content.indexOf("\n") + 1)92 : content;93 if (!/^\s*(\/\*\*|#|--)/.test(firstMeaningful))94 return "header must be the first thing in the file (after an optional shebang)";95 return null;96}9798const patterns = loadIgnorePatterns();99const problems: HeaderProblem[] = [];100let checked = 0;101102for (const file of walk(ROOT)) {103 const rel = relative(ROOT, file);104 if (!CHECKED_EXTENSIONS.has(extname(file))) continue;105 if (isIgnored(rel, patterns)) continue;106 checked++;107 const reason = checkHeader(rel, readFileSync(file, "utf8"));108 if (reason) problems.push({ file: rel, reason });109}110111if (problems.length > 0) {112 console.error(`✗ ${problems.length} file(s) with missing/malformed author header:\n`);113 for (const p of problems) console.error(` ${p.file} — ${p.reason}`);114 process.exit(1);115}116console.log(`✓ Author header OK on ${checked} source files.`);117