SPB Git

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%
3.5 KB · 117 lines typescript
Raw Blame History
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