#!/usr/bin/env node // Zero-Coupling Law: engine dirs must not name catalog cards or noun words. import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { loadCatalog, fail, isMain, readCourseConfig } from "./lib.mjs"; const META_NOUNS = new Set(["TEXT", "ALL", "EMPTY"]); /** Catalog entries the engine must not name: every card, and every noun word except the meta-nouns. */ export function auditEntries(catalog) { return catalog .filter((e) => e.kind === "card" || (e.kind === "word" && e.role === "noun" && !META_NOUNS.has(e.name))) .filter((e) => e.name.length >= 2); } export function auditNames(catalog) { return auditEntries(catalog).map((e) => e.name); } /** Spellings of a card name: exact, snake_case, camelCase, PascalCase, SCREAMING_SNAKE. */ export function variants(name) { const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean); const lower = words.map((w) => w.toLowerCase()); const cap = (w) => w[0].toUpperCase() + w.slice(1); return new Set([ name, lower.join("_"), lower[0] + lower.slice(1).map(cap).join(""), lower.map(cap).join(""), lower.join("_").toUpperCase(), ]); } const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const B = "(? new RegExp(`${B}${esc(v)}${E}`)); } const word = entry.name; const pascal = word[0].toUpperCase() + word.slice(1).toLowerCase(); return [ new RegExp(`${B}${esc(word)}${E}`), new RegExp(`${B}${esc(pascal)}${E}`), new RegExp(`(["'\`])${esc(word)}\\1`, "i"), ]; } function walk(dir, out = []) { for (const e of readdirSync(dir)) { if (e === "node_modules" || e === "target" || e.startsWith(".")) continue; const p = join(dir, e); statSync(p).isDirectory() ? walk(p, out) : out.push(p); } return out; } export function audit(ws, catalog) { const cfg = readCourseConfig(ws); const all = auditEntries(catalog).flatMap((e) => matchers(e).map((re) => ({ name: e.name, re }))); const hits = []; for (const d of cfg.engineDirs || []) { const dir = join(ws, d); if (!existsSync(dir)) continue; for (const file of walk(dir)) { readFileSync(file, "utf8").split("\n").forEach((line, i) => { const seen = new Set(); for (const m of all) { if (!seen.has(m.name) && m.re.test(line)) { seen.add(m.name); hits.push(`${relative(ws, file)}:${i + 1}: names "${m.name}"`); } } }); } } return hits; } if (isMain(import.meta.url)) { const ws = process.argv[2]; if (!ws) fail("usage: coupling-audit.mjs "); const hits = audit(ws, loadCatalog()); if (hits.length) fail("coupling-audit: engine names specific content\n" + hits.map((h) => " " + h).join("\n")); console.log("coupling-audit: ok"); }