/** * Output-quality rubric (deterministic, framework-agnostic). * * The fidelity gates (validate/gates.ts) measure whether the clone *looks and * behaves* like the source. They say nothing about whether the generated CODE is * good — componentized, semantically named, styled through a reusable token/class * system, editable, and well organized. That "developer-facing quality" is exactly * where a deterministic converter is judged once fidelity is a given. * * This module statically analyzes a generated app directory and scores it 0–100 on * six categories. It reads only source text (`.tsx/.jsx/.ts/.astro/.css`). The * metrics are chosen to be framework-agnostic: e.g. "style reuse" rewards shared * classes whether they're Tailwind utilities or semantic clone classes, and * penalizes per-node unique rules (`.c1{...} .c2{...}`) regardless of framework. * * Pure + deterministic: same directory ⇒ same score. */ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, extname, basename, relative } from "node:path"; // --------------------------------------------------------------------------- // File collection // --------------------------------------------------------------------------- const SKIP_DIRS = new Set(["node_modules", ".next", "out", "dist", ".git", ".wrangler", ".astro", "public", ".harness", ".harness2"]); const CODE_EXT = new Set([".tsx", ".jsx", ".ts", ".astro", ".css"]); export type SrcFile = { path: string; rel: string; ext: string; text: string; lines: number }; export function collectFiles(root: string): SrcFile[] { const out: SrcFile[] = []; const walk = (dir: string): void => { let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const name of entries.sort()) { if (name.startsWith("._")) continue; const p = join(dir, name); let st; try { st = statSync(p); } catch { continue; } if (st.isDirectory()) { if (!SKIP_DIRS.has(name)) walk(p); continue; } const ext = extname(name).toLowerCase(); if (!CODE_EXT.has(ext)) continue; if (name.endsWith(".d.ts")) continue; let text = ""; try { text = readFileSync(p, "utf8"); } catch { continue; } out.push({ path: p, rel: relative(root, p), ext, text, lines: text.split("\n").length }); } }; walk(root); return out; } // --------------------------------------------------------------------------- // Primitive extraction // --------------------------------------------------------------------------- const CONFIG_NAME_RE = /(config|tsconfig|next-env|site-config|content\.config|\.config\.)/i; const ROOT_NAME_RE = /(^|\/)(layout|_app|_document|root-layout)\.[jt]sx$/i; /** Files that compose the whole page (the "entry"): page.tsx (Next), index/page in a * pages/ dir, home.tsx, or an .astro route shell. Used for the dominance metric. */ function isEntryFile(rel: string): boolean { const b = basename(rel).toLowerCase(); if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true; if (rel.includes("/pages/") && b.endsWith(".astro")) return true; if (b === "index.astro" || b === "[...slug].astro" || b === "404.astro") return true; return false; } const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"]; /** Count JSX/HTML opening element tags — a framework-agnostic proxy for page size * (how many DOM nodes the output describes). Excludes React fragments + closing tags. */ function countTags(text: string): number { const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g); return m ? m.length : 0; } /** All className / class string-literal tokens used on elements (space split). * Catches JSX attribute form (className="…"), object/spread form (className: "…" — * what our generator emits), and hoisted className consts. A dynamic per-node class * (className: "c" + d._cid / className={"c"+…}) renders a UNIQUE opaque class per * instance, so each occurrence is recorded as its own opaque token (keeping the * reuse + semantic metrics honest for the per-node-CSS strategy). */ function classTokens(text: string): string[] { const out: string[] = []; // className="..." / className: "..." / "class": "..." (attr + object form) const re = /\b(?:className|class)\s*(?:=|:)\s*"([^"]*)"/g; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { for (const t of m[1]!.split(/\s+/)) if (t) out.push(t); } // hoisted className consts: const xClassName = "..." const re2 = /\b(?:const|let)\s+\w*[Cc]lassName\w*\s*=\s*"([^"]*)"/g; while ((m = re2.exec(text)) !== null) { for (const t of m[1]!.split(/\s+/)) if (t) out.push(t); } // dynamic per-node class: className: "c" + d._cid / className={"c"+ ...} const dyn = (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?"\s*\+/g) ?? []).length + (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?\d*"\s*\+/g) ?? []).length; for (let i = 0; i < dyn; i++) out.push(`c__dyn${out.length}`); // unique opaque per occurrence return out; } const CID_CLASS_RE = /^c[a-z]?\d+$/; // our legacy per-node class: c12 / cn12 /** A class token that carries no human meaning (pure id / hash). */ function isOpaqueClass(t: string): boolean { if (CID_CLASS_RE.test(t)) return true; if (/^[a-z]?\d+$/.test(t)) return true; // hashed CSS-module-ish tokens: _foo_ab12 or 6-hex tails if (/_[a-z0-9]{5,}$/i.test(t) && /\d/.test(t)) return true; return false; } const GENERIC_COMP_RE = /^(Item|Card|List|ListItem|Link|LinkItem|Wrapper|Box|Div|El|Node|Group|Block|Comp|Component|Row|Col|Cell|Thing|Unit|Part|Chunk)\d*$/; /** Exported / defined component names in a file. */ function componentNames(text: string): string[] { const names = new Set(); let m: RegExpExecArray | null; const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g; while ((m = re1.exec(text)) !== null) names.add(m[1]!); const re2 = /export\s+function\s+([A-Z]\w*)/g; while ((m = re2.exec(text)) !== null) names.add(m[1]!); const re3 = /(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*[=(]/g; while ((m = re3.exec(text)) !== null) names.add(m[1]!); return [...names]; } /** Data/content field names declared in a content or data module. */ function contentFieldNames(text: string): string[] { const out: string[] = []; // type Foo = { a: string; b?: number } — capture keys const typeBlocks = text.match(/(?:type|interface)\s+\w+\s*=?\s*\{([^}]*)\}/g) ?? []; for (const blk of typeBlocks) { const re = /(\w+)\s*\??\s*:/g; let m: RegExpExecArray | null; while ((m = re.exec(blk)) !== null) out.push(m[1]!); } return out; } const PLUMBING_FIELD_RE = /^(_cid\d*|cid\d*|value\d*|val\d*|f\d+|d\d+|field\d+|key\d*|prop\d+|arg\d+)$/; // --------------------------------------------------------------------------- // Scoring // --------------------------------------------------------------------------- export type CategoryScore = { score: number; max: number; metrics: Record }; export type QualityReport = { dir: string; total: number; categories: Record; raw: Record; }; const clamp01 = (x: number): number => Math.max(0, Math.min(1, x)); /** Linear ramp: value v mapped from [lo,hi] → [0,1]. */ const ramp = (v: number, lo: number, hi: number): number => clamp01((v - lo) / (hi - lo)); const r1 = (n: number): number => Math.round(n * 10) / 10; export function scoreApp(root: string): QualityReport { const files = collectFiles(root); const tsx = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro"); const css = files.filter((f) => f.ext === ".css"); // Component modules = JSX-bearing files that aren't config/root-layout. const componentModules = tsx.filter((f) => !CONFIG_NAME_RE.test(f.rel) && !ROOT_NAME_RE.test(f.rel) && countTags(f.text) > 0); const nonEntry = componentModules.filter((f) => !isEntryFile(f.rel)); const totalTags = componentModules.reduce((s, f) => s + countTags(f.text), 0) || 1; const entryFiles = componentModules.filter((f) => isEntryFile(f.rel)); const maxFileTags = Math.max(0, ...componentModules.map((f) => countTags(f.text))); const dominance = maxFileTags / totalTags; // 1.0 = one giant file // Section-named components (semantic top-level blocks). Tokenize the file path + // component names into words (splitting camelCase, kebab, snake, slashes) so both // `hero-section.tsx` and `HeroSection` are credited. const isSectionFile = (f: SrcFile): boolean => { const raw = f.rel + " " + componentNames(f.text).join(" "); const words = new Set(raw.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); return SECTION_WORDS.some((w) => words.has(w)); }; const sectionComponents = nonEntry.filter(isSectionFile).length; const svgFiles = componentModules.filter((f) => /(^|\/)svgs?\//i.test(f.rel) || /icon/i.test(basename(f.rel)) || (countTags(f.text) > 0 && /^[^<]*(); for (const f of componentModules) for (const n of componentNames(f.text)) allComponentNames.add(n); // exclude the page/Page entry symbol from the semantic judgement const judgedNames = [...allComponentNames].filter((n) => n !== "Page" && n !== "default" && n !== "RootLayout" && n !== "Layout"); const genericNames = judgedNames.filter((n) => GENERIC_COMP_RE.test(n)).length; const compNameSemanticRatio = judgedNames.length ? 1 - genericNames / judgedNames.length : 0; const allClassToks: string[] = []; for (const f of tsx) allClassToks.push(...classTokens(f.text)); const classTotal = allClassToks.length || 1; const opaqueClasses = allClassToks.filter(isOpaqueClass).length; const classSemanticRatio = 1 - opaqueClasses / classTotal; const distinctClasses = new Set(allClassToks).size || 1; const classReuse = 1 - distinctClasses / classTotal; // 0 = every class used once (per-node), →1 = heavy reuse // content field naming + editability const contentFiles = files.filter((f) => /(content|data)\.[jt]s$/i.test(basename(f.rel)) || /(^|\/)(content|lib)\//i.test(f.rel)); const fieldNames: string[] = []; for (const f of contentFiles) fieldNames.push(...contentFieldNames(f.text)); const plumbingFields = fieldNames.filter((n) => PLUMBING_FIELD_RE.test(n)).length; const fieldSemanticRatio = fieldNames.length ? 1 - plumbingFields / fieldNames.length : 0.5; // ---- styling system ---- const cssBytes = css.reduce((s, f) => s + f.text.length, 0); const cssBytesPerNode = cssBytes / totalTags; let tokenRefs = 0, tokenDefs = 0; for (const f of css) { tokenRefs += (f.text.match(/var\(--/g) ?? []).length; tokenDefs += (f.text.match(/^\s*--[\w-]+\s*:/gm) ?? []).length; } // Tailwind-style theme tokens count as token usage too (utility classes referencing roles) const tailwindTokenClasses = allClassToks.filter((t) => /(^|[-:])(bg|text|border|fill|stroke|ring|from|to|via)-/.test(t)).length; // per-node-unique-rule penalty: count `.cNNN{` selectors in CSS let perNodeRules = 0; for (const f of css) perNodeRules += (f.text.match(/\.c[a-z]?\d+\s*[\{,]/g) ?? []).length; const perNodeRuleRatio = perNodeRules / totalTags; // 1.0 = a unique rule per node // magic literals in component markup (raw hex/rgb/px in className/style strings) let magicLiterals = 0; for (const f of componentModules) { magicLiterals += (f.text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).length; magicLiterals += (f.text.match(/\brgba?\([^)]*\)/g) ?? []).length; } const magicPerNode = magicLiterals / totalTags; // ---- idiomatic styling (the axes the old rubric was blind to) ---- // A px ARBITRARY value (`w-[713.938px]`, `py-[64px]`) is a measurement, not a design choice — // the single biggest "machine-generated" tell. Count them across markup + hoisted class // consts, vs the standard scale / rem a human writes. let arbPx = 0, arbBands = 0, stdBreakpoints = 0, dataCidCount = 0; for (const f of [...componentModules, ...css]) { arbPx += (f.text.match(/\[[0-9]+(?:\.[0-9]+)?px\]/g) ?? []).length; arbBands += (f.text.match(/(?:min|max)-\[[0-9]+px\]:/g) ?? []).length; // arbitrary midpoint media variants stdBreakpoints += (f.text.match(/(?:^|[\s"'`])(?:max-)?(?:sm|md|lg|xl|2xl):/g) ?? []).length; dataCidCount += (f.text.match(/data-cid/g) ?? []).length; } const arbPxPerNode = arbPx / totalTags; // low is hand-authored; high is machine replay const bandShare = arbBands + stdBreakpoints > 0 ? arbBands / (arbBands + stdBreakpoints) : 0; // 1 = all arbitrary const dataCidPerNode = dataCidCount / totalTags; // Robotic, never-hand-written comment phrases. let roboticComments = 0; for (const f of [...componentModules, ...css, ...files.filter((x) => x.ext === ".ts")]) { roboticComments += (f.text.match(/Generated by clone-static|render-identical to the source|Do not edit by hand/g) ?? []).length; } // ---- editability ---- const hasContentModule = contentFiles.some((f) => /export\s+(const|type)/.test(f.text)) ? 1 : 0; // Destructured props with a default value, in any function/arrow param block — counts // string, array, and identifier defaults alike (a default is a default). let propsWithDefaults = 0; for (const f of componentModules) { for (const blk of f.text.match(/\(\s*\{[^{}]*\}/g) ?? []) { propsWithDefaults += (blk.match(/\b\w+\s*=\s*(?![=>])/g) ?? []).length; } } const defaultsSignal = clamp01(propsWithDefaults / Math.max(6, nonEntry.length)); // ---- file org ---- const dirSet = new Set(componentModules.map((f) => f.rel.split("/").slice(0, -1).join("/"))); const hasSectionsDir = [...dirSet].some((d) => /sections?$/i.test(d)) ? 1 : 0; const hasComponentsDir = [...dirSet].some((d) => /(components|ui)$/i.test(d)) ? 1 : 0; const hasLayoutDir = [...dirSet].some((d) => /(layout|svgs?)$/i.test(d)) ? 1 : 0; const orgDirs = hasSectionsDir + hasComponentsDir + hasLayoutDir; // ---- metadata ---- let docHeaders = 0, annotations = 0; for (const f of componentModules) { docHeaders += (f.text.match(/\/\*\*[\s\S]*?\*\//g) ?? []).length; // component-type annotations in attribute, object, or doc-tag form. annotations += (f.text.match(/@component|data-component/g) ?? []).length; } // ========================================================================= // Category scoring // ========================================================================= // 1. Componentization (25): many components, low single-file dominance, sections present const cCount = ramp(nonEntry.length, 0, 12); // 0 → 12+ components const cDom = 1 - ramp(dominance, 0.3, 0.95); // penalize one giant file const cSections = ramp(sectionComponents, 0, 6); // semantic sections const componentization = 25 * (0.4 * cCount + 0.35 * cDom + 0.25 * cSections); // 2. Semantic naming (25): component names, class names, content fields const naming = 25 * (0.4 * compNameSemanticRatio + 0.4 * classSemanticRatio + 0.2 * fieldSemanticRatio); // 3. Styling system (20): class reuse, tokens, low per-node verbosity, few magic literals AND // — the axes the old rubric missed — idiomatic values (standard scale/rem, not a wall of // px arbitraries) and standard breakpoints (not arbitrary midpoint bands). const sReuse = clamp01(classReuse / 0.6); // 0.6+ reuse → full marks const sTokens = ramp(tokenRefs + tailwindTokenClasses, 0, Math.max(40, totalTags * 0.5)); const sVerbosity = 1 - clamp01(perNodeRuleRatio); // per-node rules are bad const sMagic = 1 - clamp01(magicPerNode / 0.25); const sArb = 1 - clamp01(arbPxPerNode / 2); // ~0 arb-px/node → 1; 2+/node → 0 const sBp = 1 - bandShare; // 0 arbitrary bands → 1 const styling = 20 * (0.22 * sReuse + 0.13 * sTokens + 0.13 * sVerbosity + 0.07 * sMagic + 0.27 * sArb + 0.18 * sBp); // 4. Editability (15): content module, typed fields, semantic fields, prop defaults const editability = 15 * (0.4 * hasContentModule + 0.3 * fieldSemanticRatio + 0.3 * defaultsSignal); // 5. File org (10): folder structure + svgs extracted const fileOrg = 10 * (0.7 * (orgDirs / 3) + 0.3 * clamp01(svgFiles / 4)); // 6. Metadata (5): concise human doc headers + semantic component annotations — but a // "Generated by clone-static / render-identical" robotic header is a NEGATIVE signal (no // human writes it), and shipping a per-node data-cid on every element is markup noise. const cleanComments = roboticComments === 0 ? 1 : clamp01(1 - roboticComments / Math.max(4, nonEntry.length)); const lowCidNoise = 1 - clamp01(dataCidPerNode); // ~0 data-cid/node → 1; 1/node → 0 const metadata = 5 * (0.35 * clamp01(docHeaders / Math.max(4, nonEntry.length)) * cleanComments + 0.35 * clamp01(annotations / Math.max(8, totalTags * 0.1)) + 0.3 * lowCidNoise); const total = componentization + naming + styling + editability + fileOrg + metadata; return { dir: root, total: r1(total), categories: { componentization: { score: r1(componentization), max: 25, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), sectionComponents } }, naming: { score: r1(naming), max: 25, metrics: { compNameSemanticPct: r1(compNameSemanticRatio * 100), classSemanticPct: r1(classSemanticRatio * 100), fieldSemanticPct: r1(fieldSemanticRatio * 100), genericNames } }, styling: { score: r1(styling), max: 20, metrics: { classReusePct: r1(classReuse * 100), tokenRefs, perNodeRules, magicLiterals, arbPx, arbPxPerNode: r1(arbPxPerNode), arbBands, stdBreakpoints, bandSharePct: r1(bandShare * 100) } }, editability: { score: r1(editability), max: 15, metrics: { hasContentModule, fieldSemanticPct: r1(fieldSemanticRatio * 100), propsWithDefaults } }, fileOrg: { score: r1(fileOrg), max: 10, metrics: { orgDirs, svgFiles, hasSectionsDir, hasComponentsDir } }, metadata: { score: r1(metadata), max: 5, metrics: { docHeaders, annotations, roboticComments, dataCidPerNode: r1(dataCidPerNode) } }, }, raw: { files: files.length, componentModules: componentModules.length, totalTags, maxFileTags, classTokens: classTotal, distinctClasses, opaqueClasses, cssBytes, }, }; } function fmt(rep: QualityReport): string { const lines: string[] = []; lines.push(`\n ${rep.dir}`); lines.push(` TOTAL: ${rep.total}/100`); for (const [k, c] of Object.entries(rep.categories)) { const metrics = Object.entries(c.metrics).map(([mk, mv]) => `${mk}=${mv}`).join(" "); lines.push(` ${k.padEnd(16)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`); } return lines.join("\n"); } async function main(): Promise { const dirs = process.argv.slice(2).filter((a) => !a.startsWith("--")); const asJson = process.argv.includes("--json"); if (dirs.length === 0) { console.error("usage: qualityScore [ ...] [--json]"); process.exit(1); } const reports = dirs.map((d) => scoreApp(d)); if (asJson) { console.log(JSON.stringify(reports, null, 2)); return; } for (const rep of reports) console.log(fmt(rep)); if (reports.length > 1) { console.log("\n ── comparison ──"); console.log(" " + "category".padEnd(16) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join("")); for (const cat of Object.keys(reports[0]!.categories)) { console.log(" " + cat.padEnd(16) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join("")); } console.log(" " + "TOTAL".padEnd(16) + reports.map((r) => String(r.total).padStart(10)).join("")); } } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((e) => { console.error(e); process.exit(1); }); }