Files
hanzo.ai/scripts/audit-dead-classes.mjs

141 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Fail if an element wears an `hz-` name that nothing declares.
*
* A class with no rule is the quietest defect this site can ship. There is no
* such thing as an undefined class: the browser resolves it to nothing, the
* export is valid, the typechecker never looks inside a string, and the element
* renders at whatever it inherits. So the failure is invisible everywhere the
* failure would normally be caught.
*
* It has happened once, at full size. `61ed9e9c` took the prefix off this site's
* role classes — `.hz-display` -> `.display`, `.hz-title` -> `.title`,
* `.hz-glass` -> `.chrome`, `hz-scrollbar-none` -> `bare`, and the rest — and
* about forty call sites kept the old spelling. **182 elements shipped
* unstyled**, the apex `<h1>` among them, at 14px where the rule it had lost
* says 52. Every gate was green. Nothing in a build can notice this, which is
* why it takes a gate of its own.
*
* WHAT IT ASKS. Every `hz-` token worn in class position under `app/` and
* `components/` must be one of the names below. The prefix is the whole handle:
* this site's own vocabulary is now plain (`.leaf`, `.title`, `.action`, and
* `consumers.mjs` holds those to having a rule), so a surviving `hz-` is either
* a package's name — which is real and stays — or a rename this repo already
* made and a call site did not follow. There is no third kind.
*
* WHY A LIST AND NOT A LOOKUP. Reading `app/globals.css` for the answer would
* bless whatever is in it, and three of these names have their rule somewhere
* this repo cannot read — `@hanzo/ui` stamps `hz-mono` / `hz-tnum` / `hz-row`
* onto its own components and ships their rules with the package. A written list
* makes a new legitimate name a deliberate line in a diff, and a dead one a
* failure. That is the same explicit-record shape `audit-price-literals.mjs`
* uses next door.
*
* IT IS NOT A RATCHET, and that is the one way it differs from its siblings.
* `ALLOWED` there may only shrink, and an entry that stops describing anything
* fails the run. Here an unworn name is not stale — `hz-tnum` and `hz-row` are
* worn by @hanzo/ui inside node_modules and by nothing in this repo, and they
* are no less real for it. This list is a vocabulary; it changes when the
* packages do.
*
* COMMENTS ARE NOT WEARERS, and this gate is unusable without that. The
* docblocks on this site say what an element wears — "The tile, on `.leaf`",
* "`.hz-action` is declared nowhere, so these links were rendering as bare
* text" — so a raw grep for `hz-action` finds `components/products/Aside.tsx`
* explaining the very bug this gate exists to prevent, and fails on it forever.
* The source is therefore read through `code()`, which blanks comment spans and
* keeps line numbers, and through `tokens()`, which reads class position only
* and steps through the braces so `cn(...)`, a ternary and an array all count.
* Both are the readers `audit-price-literals.mjs` and `consumers.mjs` already
* use; a second copy of either is how two gates come to disagree about what a
* class is.
*
* Usage: node scripts/audit-dead-classes.mjs
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { tokens } from "./names.mjs";
import { code } from "./source.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const ROOTS = ["app", "components"];
const SOURCE = /\.tsx?$/;
const PREFIX = /^hz-/;
// Every `hz-` name with a rule behind it. Name -> where the rule lives.
const ALLOWED = {
"hz-composer": "@hanzo/composer paints the pill's ring and halo at `.hz-composer::before`; app/globals.css states its ink.",
"hz-round": "@hanzo/composer sizes the 30px control on it with `!important`, so the name is the package's.",
"hz-mono": "@hanzo/ui stamps it from its own components and ships the rule; declared nowhere in this repo.",
"hz-tnum": "@hanzo/ui's, same as hz-mono — tabular figures on a Metric. Worn by the package, not by us.",
"hz-row": "@hanzo/ui stamps it on a DataTable row; app/globals.css adds only the hover the package does not state.",
"hz-globe": "app/globals.css:676 — the stage a WebGL canvas measures itself against, so it must be a rule.",
"hz-field": "app/globals.css:690 — the backdrop half of the same pair, filling whatever it is placed behind.",
"hz-pulse": "app/globals.css:853 — co-named with `.pulse` because @hanzo/ui's skeleton.js emits the prefixed half.",
"hz-caret": "app/globals.css:839 — co-named with `.caret` for the same reason, from input-otp.js.",
};
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) {
if (name === "node_modules" || name === ".next") continue;
walk(p, out);
} else if (SOURCE.test(name)) out.push(p);
}
return out;
}
/* Where a name sits, for the MESSAGE only — `tokens` above has already given the
verdict. Comments are blanked by then, so what is left is code, and the
boundary keeps the custom property `--hz-composer-halo` out of the answer. */
function at(text, name) {
const re = new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`);
const out = [];
text.split("\n").forEach((line, i) => re.test(line) && out.push(i + 1));
return out;
}
const dead = [];
const worn = new Set();
let files = 0;
for (const root of ROOTS) {
for (const file of walk(join(ROOT, root))) {
const rel = relative(ROOT, file);
const text = code(readFileSync(file, "utf8"));
let counted = false;
for (const name of new Set(tokens(text))) {
if (!PREFIX.test(name)) continue;
worn.add(name);
if (!counted) {
files++;
counted = true;
}
if (ALLOWED[name]) continue;
for (const line of at(text, name)) dead.push(`${rel}:${line}: ${name}`);
}
}
}
if (dead.length) {
console.error(
`\n${dead.length} element(s) wear an hz- class that nothing declares:\n\n` +
dead.map((d) => ` ${d}`).join("\n") +
`\n\nA class with no rule renders as nothing at all — no warning, no error,\n` +
`a valid page and an unstyled element. This site's own vocabulary dropped\n` +
`the prefix (\`.hz-leaf\` -> \`.leaf\`, \`.hz-glass\` -> \`.chrome\`), so an\n` +
`\`hz-\` name here is almost always a call site that did not follow: use the\n` +
`plain name the rule is written under in app/globals.css.\n\n` +
`If the name is genuinely a package's and carries a real rule, add it to\n` +
`ALLOWED in this script with the place that rule lives.\n`
);
process.exit(1);
}
console.log(
`dead classes: ${worn.size} hz- name(s) worn across ${files} file(s), every one declared.`
);