fix(tools): stage course cards from M5, audit noun words without flagging identifiers, Node<24.2 main guard, rename-proof expansion-check, safe check-links
- cardDirs: course/cards only from Module 5 (spec 6.2a); fixture modules 04/05 and tests - coupling-audit: noun words match as UPPERCASE, PascalCase or a whole string literal; lowercase identifiers like key/flag pass; card names keep every variant - isMain() argv guard replaces import.meta.main in coupling-audit and verify-references; engines.node >=22 - expansion-check: git diff --no-renames so moving engine code into content fails - start-module and other tools: clear error when .course.json is missing - check-links: malformed URLs reported as invalid URL instead of throwing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0700cac492
commit
ad0701395d
26 files changed
+213
-47
No files matched your search
+37
-13
@@ -2,17 +2,22 @@
|
||||
// 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 } from "./lib.mjs";
|
||||
import { loadCatalog, fail, isMain, readCourseConfig } from "./lib.mjs";
|
||||
|
||||
const META_NOUNS = new Set(["TEXT", "ALL", "EMPTY"]);
|
||||
|
||||
export function auditNames(catalog) {
|
||||
/** 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)))
|
||||
.map((e) => e.name)
|
||||
.filter((n) => n.length >= 2);
|
||||
.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());
|
||||
@@ -27,6 +32,30 @@ export function variants(name) {
|
||||
}
|
||||
|
||||
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const B = "(?<![A-Za-z0-9_])";
|
||||
const E = "(?![A-Za-z0-9_])";
|
||||
|
||||
/**
|
||||
* Regexes that find an entry in a line of engine code.
|
||||
* A card name (multi-word, distinctive) is caught in every spelling.
|
||||
* A noun word (KEY, FLAG, WALL, ...) is also an everyday English word, so its lowercase
|
||||
* form is an ordinary identifier (`for (const [key, v] of m)`, `let flag = false`).
|
||||
* Nouns are caught as: the exact uppercase word (`BABA`, `"text:BABA"`), the PascalCase
|
||||
* name of a type or variant (`Kind::Baba`), or a whole string literal in any case
|
||||
* (`kind == "baba"`). Lowercase bare identifiers pass.
|
||||
*/
|
||||
export function matchers(entry) {
|
||||
if (entry.kind === "card") {
|
||||
return [...variants(entry.name)].map((v) => 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)) {
|
||||
@@ -38,13 +67,8 @@ function walk(dir, out = []) {
|
||||
}
|
||||
|
||||
export function audit(ws, catalog) {
|
||||
const cfg = JSON.parse(readFileSync(join(ws, ".course.json"), "utf8"));
|
||||
const matchers = [];
|
||||
for (const name of auditNames(catalog)) {
|
||||
for (const v of variants(name)) {
|
||||
matchers.push({ name, re: new RegExp(`(?<![A-Za-z0-9_])${esc(v)}(?![A-Za-z0-9_])`) });
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -52,7 +76,7 @@ export function audit(ws, catalog) {
|
||||
for (const file of walk(dir)) {
|
||||
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
||||
const seen = new Set();
|
||||
for (const m of matchers) {
|
||||
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}"`);
|
||||
@@ -64,7 +88,7 @@ export function audit(ws, catalog) {
|
||||
return hits;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
if (isMain(import.meta.url)) {
|
||||
const ws = process.argv[2];
|
||||
if (!ws) fail("usage: coupling-audit.mjs <workspaceDir>");
|
||||
const hits = audit(ws, loadCatalog());
|
||||
|
||||
Reference in new issue
Block a user