Initial commit
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify width-varying nodes in a DENSE capture by their sizing law, using the full
|
||||
sample curve. Run after a dense clone: python3 scripts/analyze-dense.py <run/.clone> """
|
||||
import json, sys, statistics, glob, os
|
||||
run = sys.argv[1] if len(sys.argv) > 1 else "output-dense/sample/.clone"
|
||||
caps = {}
|
||||
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
|
||||
vp = int(os.path.basename(f)[4:-5])
|
||||
caps[vp] = json.load(open(f))["root"]
|
||||
VPS = sorted(caps)
|
||||
print("sample widths:", VPS)
|
||||
|
||||
def flat(n, a):
|
||||
if isinstance(n, dict):
|
||||
a.append(n)
|
||||
for c in (n.get("children") or []): flat(c, a)
|
||||
F = {vp: [] for vp in VPS}
|
||||
for vp in VPS: flat(caps[vp], F[vp])
|
||||
def wp(n, p, m):
|
||||
m[id(n)] = p
|
||||
for c in (n.get("children") or []): wp(c, n, m)
|
||||
PM = {vp: {} for vp in VPS}
|
||||
for vp in VPS: wp(caps[vp], None, PM[vp])
|
||||
def pf(v):
|
||||
try: return float(str(v).replace("px", ""))
|
||||
except: return 0.0
|
||||
|
||||
n = min(len(F[vp]) for vp in VPS)
|
||||
cats = {"fills_container": 0, "prop_container": 0, "prop_viewport": 0, "clamped_maxw": 0,
|
||||
"shrink_recoverable": 0, "real_breakpoint": 0, "fixed": 0, "unknown": 0}
|
||||
for i in range(n):
|
||||
nd = {vp: F[vp][i] for vp in VPS}
|
||||
if not all(nd[vp].get("visible") for vp in VPS): continue
|
||||
w = {vp: (nd[vp].get("bbox") or {}).get("width", 0) for vp in VPS}
|
||||
if max(w.values()) - min(w.values()) <= 2: continue # constant → no band
|
||||
# container content width per vp
|
||||
cw = {}
|
||||
for vp in VPS:
|
||||
p = PM[vp].get(id(nd[vp])); pb = (p or {}).get("bbox"); pcs = (p or {}).get("computed") or {}
|
||||
base = pb["width"] if pb else vp
|
||||
cw[vp] = base - pf(pcs.get("paddingLeft")) - pf(pcs.get("paddingRight"))
|
||||
rc = [w[vp] / cw[vp] for vp in VPS if cw[vp] > 0]
|
||||
rv = [w[vp] / vp for vp in VPS]
|
||||
def cv(xs):
|
||||
m = statistics.mean(xs); return statistics.pstdev(xs) / m if m else 9
|
||||
wl = [w[vp] for vp in VPS]
|
||||
if rc and cv(rc) < 0.02 and abs(statistics.mean(rc) - 1) < 0.02: cats["fills_container"] += 1
|
||||
elif rc and cv(rc) < 0.03: cats["prop_container"] += 1
|
||||
elif cv(rv) < 0.03: cats["prop_viewport"] += 1
|
||||
elif wl[-1] == wl[-2] and wl[0] < wl[-1]: # plateau at the widest → clamp or recoverable shrink
|
||||
# constant above a knee, smaller below
|
||||
cats["clamped_maxw"] += 1
|
||||
elif wl[-1] < wl[-2]: # still shrinking even at the widest → natural beyond range
|
||||
cats["shrink_recoverable"] += 1
|
||||
else:
|
||||
# piecewise? count distinct plateaus
|
||||
cats["unknown"] += 1
|
||||
print("\nwidth-varying nodes by inferred sizing law (dense):")
|
||||
for k, v in cats.items(): print(f" {k:20} {v}")
|
||||
print(f" TOTAL varying: {sum(cats.values())}")
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""For every node whose width VARIES across viewports (=> would emit a width band),
|
||||
classify the underlying law: hairline / parent-fill / fraction-of-parent / step.
|
||||
Tells us how many bands each recovery law would eliminate."""
|
||||
import json, glob, os, sys, collections
|
||||
run = sys.argv[1] if len(sys.argv) > 1 else "compiler/output/sample/.clone"
|
||||
caps = {}
|
||||
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
|
||||
caps[int(os.path.basename(f)[4:-5])] = json.load(open(f))["root"]
|
||||
VPS = sorted(caps)
|
||||
# flatten each viewport in pre-order, aligned by index
|
||||
F = {vp: [] for vp in VPS}
|
||||
PARENT = []
|
||||
def flat(n, a, parent_idx, store_parent):
|
||||
idx = len(a); a.append(n)
|
||||
if store_parent: PARENT.append(parent_idx)
|
||||
for c in (n.get("children") or []):
|
||||
flat(c, a, idx, store_parent)
|
||||
for vp in VPS:
|
||||
flat(caps[vp], F[vp], -1, vp == VPS[0])
|
||||
N = len(F[VPS[0]])
|
||||
def pf(v):
|
||||
try: return float(str(v).replace("px",""))
|
||||
except: return 0.0
|
||||
def cs(i, vp): return F[vp][i].get("computed") or {}
|
||||
def bb(i, vp): return F[vp][i].get("bbox") or {}
|
||||
def vis(i, vp):
|
||||
if not F[vp][i].get("visible"): return False
|
||||
if (cs(i,vp).get("display")=="none"): return False
|
||||
return True
|
||||
def width(i, vp): return bb(i, vp).get("width", 0)
|
||||
def parent_content_w(i, vp):
|
||||
p = PARENT[i]
|
||||
if p < 0: return bb(i,vp).get("width",0)
|
||||
pc = cs(p, vp)
|
||||
return bb(p,vp).get("width",0) - pf(pc.get("paddingLeft")) - pf(pc.get("paddingRight")) - pf(pc.get("borderLeftWidth")) - pf(pc.get("borderRightWidth"))
|
||||
|
||||
FRACTIONS = {1/2:"1/2",1/3:"1/3",2/3:"2/3",1/4:"1/4",3/4:"3/4",1/5:"1/5",2/5:"2/5",3/5:"3/5",4/5:"4/5",1/6:"1/6",5/6:"5/6"}
|
||||
cat = collections.Counter()
|
||||
band_cat = collections.Counter()
|
||||
examples = collections.defaultdict(list)
|
||||
for i in range(N):
|
||||
ws = {vp: width(i,vp) for vp in VPS if vis(i,vp)}
|
||||
if len(ws) < 2: continue
|
||||
wv = list(ws.values())
|
||||
spread = max(wv) - min(wv)
|
||||
if spread <= 1.0: continue # constant => no band
|
||||
# how many band variants would this node emit? (# distinct rounded widths across the 4 band vps) - 1
|
||||
distinct = len(set(round(w) for w in wv))
|
||||
bands = distinct - 1
|
||||
if bands < 1: continue
|
||||
# --- classify ---
|
||||
if max(wv) < 2.0:
|
||||
c = "hairline(<2px)"
|
||||
else:
|
||||
ratios = []
|
||||
ok_parent = True
|
||||
for vp, w in ws.items():
|
||||
pcw = parent_content_w(i, vp)
|
||||
if pcw <= 0: ok_parent = False; break
|
||||
ratios.append(w / pcw)
|
||||
if not ok_parent:
|
||||
c = "no-parent"
|
||||
elif max(ratios) - min(ratios) < 0.03 and abs(sum(ratios)/len(ratios) - 1.0) < 0.03:
|
||||
c = "parent-fill(100%)"
|
||||
else:
|
||||
avg = sum(ratios)/len(ratios)
|
||||
# nearest simple fraction
|
||||
best = min(FRACTIONS, key=lambda f: abs(f-avg))
|
||||
if max(ratios)-min(ratios) < 0.03 and abs(best-avg) < 0.015:
|
||||
c = f"fraction~{FRACTIONS[best]}"
|
||||
elif max(ratios)-min(ratios) < 0.04:
|
||||
c = "const-%(other)"
|
||||
else:
|
||||
c = "step/other"
|
||||
cat[c] += 1
|
||||
band_cat[c] += bands
|
||||
if len(examples[c]) < 4:
|
||||
examples[c].append((round(min(wv),1), round(max(wv),1), [round(width(i,vp)) for vp in VPS]))
|
||||
|
||||
print(f"nodes with width-bands: {sum(cat.values())} total band-variants: {sum(band_cat.values())}")
|
||||
print(f"{'category':22} {'nodes':>6} {'bands':>6}")
|
||||
for c,_ in band_cat.most_common():
|
||||
print(f"{c:22} {cat[c]:>6} {band_cat[c]:>6} eg {examples[c][:3]}")
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replicate the (updated) reconstructFlexRow and report success + bail reasons + band impact."""
|
||||
import json, glob, os, sys, collections
|
||||
run = sys.argv[1] if len(sys.argv) > 1 else "compiler/output-dense/sample/.clone"
|
||||
caps = {}
|
||||
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
|
||||
caps[int(os.path.basename(f)[4:-5])] = json.load(open(f))["root"]
|
||||
VPS = sorted(caps); BAND_VPS = [v for v in (375, 768, 1280, 1920) if v in VPS]
|
||||
acc = []
|
||||
def walkidx(n):
|
||||
n["_i"] = len(acc); acc.append(n); n["_kids"] = []
|
||||
for c in (n.get("children") or []):
|
||||
n["_kids"].append(len(acc)); walkidx(c)
|
||||
walkidx(caps[VPS[0]])
|
||||
N = len(acc)
|
||||
# per-vp flat lists aligned by index
|
||||
F = {vp: [] for vp in VPS}
|
||||
def flat(n, a):
|
||||
a.append(n)
|
||||
for c in (n.get("children") or []): flat(c, a)
|
||||
for vp in VPS: flat(caps[vp], F[vp])
|
||||
def pf(v):
|
||||
try: return float(str(v).replace("px", ""))
|
||||
except: return 0.0
|
||||
def CS(i, vp): return F[vp][i].get("computed") or {}
|
||||
def BB(i, vp): return F[vp][i].get("bbox") or {}
|
||||
def VIS(i, vp): return F[vp][i].get("visible")
|
||||
reasons = collections.Counter(); succ = 0; succ_items = 0; bands_removed = 0
|
||||
for i in range(N):
|
||||
c0 = CS(i, VPS[0]); disp = c0.get("display", "")
|
||||
if "flex" not in disp: continue
|
||||
if c0.get("flexDirection", "row") not in ("row", "row-reverse"): reasons["not_row"] += 1; continue
|
||||
if (c0.get("flexWrap") or "nowrap") != "nowrap": reasons["wrap"] += 1; continue
|
||||
info = []; bail = None
|
||||
for k in acc[i]["_kids"]:
|
||||
if not acc[k].get("tag"): continue
|
||||
ck = CS(k, VPS[0]); pos = ck.get("position", "static")
|
||||
if pos in ("absolute", "fixed"): continue
|
||||
if (ck.get("float") or "none") != "none": continue
|
||||
if ck.get("marginLeft") == "auto" or ck.get("marginRight") == "auto": bail = "auto_margin"; break
|
||||
if (ck.get("boxSizing") or "border-box") == "content-box": bail = "content_box"; break
|
||||
if pf(ck.get("flexGrow")) > 0: bail = "grow"; break
|
||||
basis = ck.get("flexBasis") or "auto"
|
||||
if basis != "auto" and not basis.endswith("px"): bail = "basis_pct"; break
|
||||
info.append((k, pf(ck.get("flexShrink")), pf(basis) if basis.endswith("px") else None,
|
||||
pf(ck.get("minWidth")) if (ck.get("minWidth") or "").endswith("px") else 0,
|
||||
pf(ck.get("maxWidth")) if (ck.get("maxWidth") or "").endswith("px") else float("inf")))
|
||||
if bail: reasons[bail] += 1; continue
|
||||
if not info: continue
|
||||
used = {}; margin = {}; cw = {}; gap = {}
|
||||
for vp in VPS:
|
||||
pcs = CS(i, vp); pb = BB(i, vp)
|
||||
cw[vp] = pb.get("width", 0) - pf(pcs.get("paddingLeft")) - pf(pcs.get("paddingRight"))
|
||||
gap[vp] = pf(pcs.get("columnGap") if pcs.get("columnGap") not in (None, "normal") else pcs.get("gap"))
|
||||
u = []; m = []
|
||||
for (k, sh, bp, mn, mx) in info:
|
||||
ck = CS(k, vp); hidden = (not VIS(k, vp)) or ck.get("display") == "none"
|
||||
u.append(None if hidden else BB(k, vp).get("width", 0)); m.append(0 if hidden else pf(ck.get("marginLeft")) + pf(ck.get("marginRight")))
|
||||
used[vp] = u; margin[vp] = m
|
||||
def slack(vp):
|
||||
vis = [j for j in range(len(info)) if used[vp][j] is not None]
|
||||
return cw[vp] - sum(used[vp][j] for j in vis) - (gap[vp] * max(0, len(vis) - 1) + sum(margin[vp][j] for j in vis))
|
||||
bases = []; ok = True
|
||||
for j, (k, sh, bp, mn, mx) in enumerate(info):
|
||||
if bp is not None: bases.append(bp); continue
|
||||
base = None
|
||||
for v in range(len(VPS) - 1, -1, -1):
|
||||
vp = VPS[v]
|
||||
if used[vp][j] is None: continue
|
||||
if slack(vp) >= -0.5: base = used[vp][j]; break
|
||||
if base is None: ok = False; break
|
||||
bases.append(base)
|
||||
if not ok: reasons["no_base"] += 1; continue
|
||||
bad = False
|
||||
for vp in VPS:
|
||||
vis = [j for j in range(len(info)) if used[vp][j] is not None]
|
||||
if not vis: continue
|
||||
hyp = [min(max(bases[j], info[j][3]), info[j][4]) for j in vis]
|
||||
gaps = gap[vp] * max(0, len(vis) - 1) + sum(margin[vp][j] for j in vis)
|
||||
free = cw[vp] - sum(hyp) - gaps
|
||||
sim = list(hyp)
|
||||
if free < -0.5:
|
||||
ts = sum(info[vis[k]][1] * hyp[k] for k in range(len(vis)))
|
||||
if ts > 0:
|
||||
for k in range(len(vis)): sim[k] = max(hyp[k] - abs(free) * (info[vis[k]][1] * hyp[k]) / ts, info[vis[k]][3])
|
||||
for k in range(len(vis)):
|
||||
if abs(sim[k] - used[vp][vis[k]]) > 1: bad = True
|
||||
if bad: reasons["sim_mismatch"] += 1; continue
|
||||
varies = [j for j in range(len(info)) if len([used[vp][j] for vp in VPS if used[vp][j] is not None]) >= 2 and max(x for vp in VPS if (x:=used[vp][j]) is not None) - min(x for vp in VPS if (x:=used[vp][j]) is not None) > 8]
|
||||
if not varies: reasons["no_variation"] += 1; continue
|
||||
succ += 1; succ_items += len(info)
|
||||
# estimate band variants removed: for each varying item, count distinct band-vp widths != base-vp
|
||||
for j in varies:
|
||||
bandw = set(round(used[vp][j]) for vp in BAND_VPS if used[vp][j] is not None)
|
||||
bands_removed += max(0, len(bandw) - 1)
|
||||
print(f"SUCCESS lines: {succ} items: {succ_items} est. width-band variants removed: ~{bands_removed}")
|
||||
for k, v in reasons.most_common(): print(f" bail {k:16} {v}")
|
||||
@@ -0,0 +1,43 @@
|
||||
// Run a generated app in Next DEV mode (unminified React) and print hydration errors.
|
||||
import { spawn } from "node:child_process";
|
||||
import { cpSync, rmSync, existsSync } from "node:fs";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const appDir = process.argv[2];
|
||||
const harness = new URL("../.harness", import.meta.url).pathname;
|
||||
const PORT = 3987;
|
||||
|
||||
for (const s of ["src", "public", "next.config.mjs", "tsconfig.json", "next-env.d.ts", ".next", "out"]) {
|
||||
const p = `${harness}/${s}`;
|
||||
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
cpSync(`${appDir}/src`, `${harness}/src`, { recursive: true });
|
||||
if (existsSync(`${appDir}/public`)) cpSync(`${appDir}/public`, `${harness}/public`, { recursive: true });
|
||||
for (const f of ["next.config.mjs", "tsconfig.json", "next-env.d.ts"]) {
|
||||
if (existsSync(`${appDir}/${f}`)) cpSync(`${appDir}/${f}`, `${harness}/${f}`);
|
||||
}
|
||||
|
||||
const dev = spawn("./node_modules/.bin/next", ["dev", "-p", String(PORT)], {
|
||||
cwd: harness, env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
|
||||
});
|
||||
let ready = false;
|
||||
dev.stdout.on("data", (d) => { if (/Ready in|Local:|started server/i.test(d.toString())) ready = true; });
|
||||
dev.stderr.on("data", () => {});
|
||||
|
||||
const t0 = Date.now();
|
||||
while (!ready && Date.now() - t0 < 90000) await new Promise((r) => setTimeout(r, 500));
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const msgs = [];
|
||||
page.on("console", (m) => { msgs.push(m.text()); });
|
||||
page.on("pageerror", (e) => msgs.push("PAGEERROR: " + e.message));
|
||||
try { await page.goto(`http://localhost:${PORT}/`, { waitUntil: "networkidle", timeout: 60000 }); } catch (e) { msgs.push("goto: " + e.message); }
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
console.log("=== ALL CONSOLE MESSAGES (" + msgs.length + ") ===");
|
||||
for (const e of msgs.slice(0, 12)) console.log("\n---\n" + e.slice(0, 1500));
|
||||
await browser.close();
|
||||
dev.kill("SIGKILL");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,50 @@
|
||||
// Generate examples/<tier>/RESULTS.md from the latest run per site.
|
||||
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = new URL("../..", import.meta.url).pathname;
|
||||
const RUNS = join(ROOT, "runs");
|
||||
const tier = process.argv[2] || "stage2";
|
||||
const sites = JSON.parse(readFileSync(join(ROOT, "compiler", "benchmarks", `${tier}.json`), "utf8"));
|
||||
|
||||
function siteId(url) {
|
||||
const u = new URL(url);
|
||||
const host = u.hostname.replace(/^www\./, "");
|
||||
const path = u.pathname.replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
return (host + (path ? "-" + path : "")).replace(/[^a-zA-Z0-9.-]/g, "-").slice(0, 80);
|
||||
}
|
||||
function latest(host) {
|
||||
const dir = join(RUNS, host);
|
||||
if (!existsSync(dir)) return null;
|
||||
const runs = readdirSync(dir).filter((d) => /^\d/.test(d)).sort();
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
if (existsSync(join(dir, runs[i], "validation", "report.json"))) return join(dir, runs[i]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let g06 = 0, s2 = 0, sum = 0, n = 0;
|
||||
const rows = [];
|
||||
for (const s of sites) {
|
||||
const run = latest(siteId(s.url));
|
||||
if (!run) { rows.push(`| ${s.id} | ${s.url} | — | — | no run |`); continue; }
|
||||
const r = JSON.parse(readFileSync(join(run, "validation", "report.json"), "utf8"));
|
||||
n++; sum += r.scorecard.total; if (r.gates0to6Pass) g06++; if (r.stage2Pass) s2++;
|
||||
const fails = Object.entries(r.gates).filter(([, gg]) => !gg.pass).map(([k]) => k).join(",");
|
||||
const host = new URL(s.url).hostname.replace(/^www\./, "") + new URL(s.url).pathname.replace(/\/$/, "");
|
||||
rows.push(`| ${s.id} | ${host} | ${r.scorecard.total} | ${r.gates0to6Pass ? "PASS" : "FAIL"} | ${r.stage2Pass ? "PASS" : "FAIL"} | ${fails || ""} |`);
|
||||
}
|
||||
const avg = Math.round((sum / n) * 10) / 10;
|
||||
const md = `# Stage 2 — results (capture-state correctness)
|
||||
|
||||
**${g06}/${n} pass gates 0-6; ${s2}/${n} pass the stricter stage-2 bar** (gates 0-6 + non-degenerate capture + perceptually-close render), average ${avg}.
|
||||
Stage 2 = popup/video/animation pages where the captured frame must be the settled, unobstructed state. Stage-2 gates: **pollution** (degenerate/wall/blocking-modal) and **perceptual** (tier-thresholded screenshot diff).
|
||||
|
||||
| id | site | score | gates0-6 | stage2 | failing |
|
||||
|----|------|------:|:--------:|:------:|---------|
|
||||
${rows.join("\n")}
|
||||
|
||||
Documented residuals (limitations, not defects): wix (heavy-JS site-builder — custom-element carousel positioned by script we don't run); warbyparker/squarespace (dynamic/autoplay hero — perceptual-only, frame-to-frame non-deterministic).
|
||||
`;
|
||||
writeFileSync(join(ROOT, "examples", tier, "RESULTS.md"), md);
|
||||
console.log(`wrote examples/${tier}/RESULTS.md — gates0-6 ${g06}/${n}, stage2 ${s2}/${n}, avg ${avg}`);
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Human-tell diagnostic: count patterns a human would (almost) never hand-write.
|
||||
|
||||
Separate from codeAudit.ts — this is the "would a human write this?" lens, focused on
|
||||
specific robotic constructs, scanned over the .tsx/.css of each tree. Prints a column per tree.
|
||||
Usage: human-tells.py <label=dir> ...
|
||||
"""
|
||||
import re, sys, os
|
||||
|
||||
def read_tree(d):
|
||||
tsx, css = [], []
|
||||
for root, dirs, files in os.walk(d):
|
||||
dirs[:] = [x for x in dirs if x not in ("node_modules", ".next", "out") and not x.startswith(".")]
|
||||
for f in files:
|
||||
p = os.path.join(root, f)
|
||||
try:
|
||||
s = open(p, encoding="utf8").read()
|
||||
except Exception:
|
||||
continue
|
||||
if f.endswith((".tsx", ".jsx", ".ts")):
|
||||
tsx.append(s)
|
||||
elif f.endswith(".css"):
|
||||
css.append(s)
|
||||
return "\n".join(tsx), "\n".join(css)
|
||||
|
||||
def count(pat, s):
|
||||
return len(re.findall(pat, s))
|
||||
|
||||
TELLS = {
|
||||
# noise / redundancy — almost never hand-written
|
||||
"noop relative inset (top-0 right-0 bottom-0 left-0)": (lambda tsx, css: count(r'\btop-0 right-0 bottom-0 left-0\b', tsx), True),
|
||||
"redundant gap+gap-x+gap-y (same n)": (lambda tsx, css: len([m for m in re.finditer(r'\bgap-(\d+(?:\.\d+)?) gap-y-(\d+(?:\.\d+)?) gap-x-(\d+(?:\.\d+)?)', tsx) if m.group(1)==m.group(2)==m.group(3)]), True),
|
||||
"4-side longhand pad (pt-x pr-y pb-x pl-y)": (lambda tsx, css: count(r'\bpt-\S+ pr-\S+ pb-\S+ pl-\S+', tsx), True),
|
||||
"per-side border arbitrary [border-*]": (lambda tsx, css: count(r'\[border-(?:top|right|bottom|left)-(?:style|color|width):', tsx+css), True),
|
||||
"baked runtime id (radix/_R_/:r)": (lambda tsx, css: count(r'id=\"(?:radix-|[^\"]*_R_|:r)', tsx), True),
|
||||
"[text-align:inherit] override": (lambda tsx, css: count(r'\[text-align:inherit\]', tsx), True),
|
||||
"data-cid shipped (jsx)": (lambda tsx, css: count(r'data-cid', tsx), True),
|
||||
"data-cid selectors in ditto.css": (lambda tsx, css: count(r'data-cid', css), True),
|
||||
# font-size arbitrary that maps to a named tailwind size (should be text-xs..text-9xl)
|
||||
"font-size arbitrary mappable to named": (lambda tsx, css: len([m for m in re.finditer(r'text-\[(\d+(?:\.\d+)?)(px|rem)\]', tsx) if round((float(m.group(1))*(16 if m.group(2)=='rem' else 1)))/16 in NAMED_REM]), True),
|
||||
# decimal arbitrary in JSX classes (sub-pixel frozen measurement)
|
||||
"decimal arbitrary [N.NNpx/rem] (jsx)": (lambda tsx, css: len([m for m in re.finditer(r'\[(-?\d+\.?\d*)(px|rem)\]', tsx) if abs((v:=float(m.group(1))*(16 if m.group(2)=='rem' else 1))-round(v))>0.02]), True),
|
||||
# decimal px in ditto.css (uncounted by codeAudit)
|
||||
"decimal px in ditto.css (Npx)": (lambda tsx, css: len([m for m in re.finditer(r'(-?\d+\.\d+)px', css) if abs((v:=float(m.group(1)))-round(v))>0.02]), True),
|
||||
"pseudo-element rules in ditto.css": (lambda tsx, css: count(r'::(?:before|after)\b', css), True),
|
||||
}
|
||||
# named tailwind text sizes in rem
|
||||
NAMED_REM = {0.75, 0.875, 1.0, 1.125, 1.25, 1.5, 1.875, 2.25, 3.0, 3.75, 4.5, 6.0, 8.0}
|
||||
|
||||
def main():
|
||||
trees = []
|
||||
for a in sys.argv[1:]:
|
||||
label, d = a.split("=", 1)
|
||||
trees.append((label, read_tree(d)))
|
||||
w0 = max(len(k) for k in TELLS)
|
||||
cw = max(11, *(len(l) for l, _ in trees))
|
||||
print("\n" + "metric".ljust(w0) + " " + "".join(l.ljust(cw) for l, _ in trees))
|
||||
print("-" * (w0 + 2 + cw * len(trees)))
|
||||
for k, (fn, _) in TELLS.items():
|
||||
cells = "".join(str(fn(tsx, css)).ljust(cw) for _, (tsx, css) in trees)
|
||||
print(k.ljust(w0) + " " + cells)
|
||||
print()
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build before/after (source vs clone) screenshot composites for a tier.
|
||||
|
||||
For each site: a single PNG with a desktop row (1280px) and a mobile row (375px),
|
||||
each showing SOURCE on the left and CLONE on the right, with labels.
|
||||
"""
|
||||
import json, os, sys, glob, re
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
RUNS = os.path.join(ROOT, "runs")
|
||||
TIER = sys.argv[1] if len(sys.argv) > 1 else "easy"
|
||||
OUT = os.path.join(RUNS, f"compare-{TIER}")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
def font(sz):
|
||||
for p in ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
|
||||
if os.path.exists(p):
|
||||
return ImageFont.truetype(p, sz)
|
||||
return ImageFont.load_default()
|
||||
|
||||
F_TITLE = font(30)
|
||||
F_LABEL = font(22)
|
||||
|
||||
def host_of(url):
|
||||
# Mirror siteIdFromUrl() in cli.ts: host (minus www) + slugified path.
|
||||
from urllib.parse import urlparse
|
||||
u = urlparse(url)
|
||||
host = re.sub(r"^www\.", "", u.hostname or "")
|
||||
path = re.sub(r"^-|-$", "", re.sub(r"[^a-zA-Z0-9]+", "-", u.path.rstrip("/")))
|
||||
sid = host + (("-" + path) if path else "")
|
||||
return re.sub(r"[^a-zA-Z0-9.-]", "-", sid)[:80]
|
||||
|
||||
def latest_run(host):
|
||||
dirs = sorted(glob.glob(os.path.join(RUNS, host, "*")), reverse=True)
|
||||
for d in dirs:
|
||||
if (os.path.exists(os.path.join(d, "source", "screenshots")) and
|
||||
os.path.exists(os.path.join(d, "rendered", "screenshots"))):
|
||||
return d
|
||||
return None
|
||||
|
||||
def load(path, scale, max_h):
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
im = Image.open(path).convert("RGB")
|
||||
w = int(im.width * scale)
|
||||
h = int(im.height * scale)
|
||||
im = im.resize((w, h), Image.LANCZOS)
|
||||
if im.height > max_h: # cap very tall pages
|
||||
im = im.crop((0, 0, im.width, max_h))
|
||||
return im
|
||||
|
||||
PAD = 16
|
||||
HEADER = 46 # per-row label strip
|
||||
TITLE = 56 # site title strip
|
||||
GAP = 10 # gap between source and clone
|
||||
BG = (250, 250, 250)
|
||||
INK = (20, 20, 20)
|
||||
RULE = (210, 210, 210)
|
||||
|
||||
def row(src_path, clone_path, scale, max_h, label):
|
||||
"""One labeled row: [SOURCE | CLONE] side by side, top-aligned."""
|
||||
s = load(src_path, scale, max_h)
|
||||
c = load(clone_path, scale, max_h)
|
||||
if s is None and c is None:
|
||||
return None
|
||||
sw = s.width if s else (c.width if c else 0)
|
||||
cw = c.width if c else sw
|
||||
sh = s.height if s else 0
|
||||
ch = c.height if c else 0
|
||||
body_h = max(sh, ch)
|
||||
W = sw + GAP + cw
|
||||
H = HEADER + body_h
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.text((4, 10), label, font=F_LABEL, fill=INK)
|
||||
d.text((4, HEADER - 4), "SOURCE", font=F_LABEL, fill=(90, 90, 90))
|
||||
d.text((sw + GAP + 4, HEADER - 4), "CLONE", font=F_LABEL, fill=(90, 90, 90))
|
||||
if s: img.paste(s, (0, HEADER))
|
||||
if c: img.paste(c, (sw + GAP, HEADER))
|
||||
# divider line between the pair
|
||||
d.line([(sw + GAP // 2, HEADER), (sw + GAP // 2, H)], fill=RULE, width=2)
|
||||
return img
|
||||
|
||||
def stack(title, rows):
|
||||
rows = [r for r in rows if r is not None]
|
||||
if not rows:
|
||||
return None
|
||||
W = max(r.width for r in rows) + PAD * 2
|
||||
H = TITLE + sum(r.height for r in rows) + PAD * (len(rows) + 1)
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.text((PAD, 14), title, font=F_TITLE, fill=INK)
|
||||
d.line([(0, TITLE - 4), (W, TITLE - 4)], fill=RULE, width=2)
|
||||
y = TITLE + PAD
|
||||
for r in rows:
|
||||
img.paste(r, (PAD, y))
|
||||
y += r.height + PAD
|
||||
return img
|
||||
|
||||
def main():
|
||||
sites = json.load(open(os.path.join(ROOT, "compiler", "benchmarks", f"{TIER}.json")))
|
||||
made = []
|
||||
for site in sites:
|
||||
host = host_of(site["url"])
|
||||
run = latest_run(host)
|
||||
if not run:
|
||||
print(f"SKIP {site['id']} {host}: no run with screenshots")
|
||||
continue
|
||||
ss = os.path.join(run, "source", "screenshots")
|
||||
rs = os.path.join(run, "rendered", "screenshots")
|
||||
title = f"{site['id']} — {host}"
|
||||
desktop = row(os.path.join(ss, "1280.png"), os.path.join(rs, "1280.png"),
|
||||
0.5, 1600, "Desktop · 1280px viewport (shown at 50%)")
|
||||
mobile = row(os.path.join(ss, "375.png"), os.path.join(rs, "375.png"),
|
||||
1.0, 1800, "Mobile · 375px viewport (100%)")
|
||||
img = stack(title, [desktop, mobile])
|
||||
if img is None:
|
||||
print(f"SKIP {site['id']}: no images")
|
||||
continue
|
||||
out = os.path.join(OUT, f"{site['id']}-{host}.png")
|
||||
img.save(out, optimize=True)
|
||||
made.append(out)
|
||||
print(f"OK {out} ({img.width}x{img.height})")
|
||||
print(f"\n{len(made)} composites -> {OUT}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build before/after (source vs clone) composites for the multi-page `sites` benchmark.
|
||||
|
||||
For each site: a stacked PNG of a few key routes (entry + listings/representatives),
|
||||
each a labeled SOURCE|CLONE row at the desktop (1280px) viewport, titled with the
|
||||
site's per-route pass summary. Mirrors make_compare.py but reads the site run layout
|
||||
(runs/site-<host>/<ts>/routes/<key>/{source,rendered}/screenshots/<vp>.png).
|
||||
"""
|
||||
import json, os, sys, glob, re
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
RUNS = os.path.join(ROOT, "runs")
|
||||
OUT = os.path.join(RUNS, "compare-sites")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
MAX_ROUTES = int(os.environ.get("MAX_ROUTES", "4"))
|
||||
|
||||
def font(sz):
|
||||
for p in ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
|
||||
if os.path.exists(p):
|
||||
return ImageFont.truetype(p, sz)
|
||||
return ImageFont.load_default()
|
||||
|
||||
F_TITLE, F_LABEL = font(30), font(20)
|
||||
PAD, HEADER, TITLE, GAP = 16, 42, 60, 10
|
||||
BG, INK, RULE = (250, 250, 250), (20, 20, 20), (210, 210, 210)
|
||||
|
||||
def host_of(url):
|
||||
from urllib.parse import urlparse
|
||||
u = urlparse(url)
|
||||
host = re.sub(r"^www\.", "", u.hostname or "")
|
||||
path = re.sub(r"^-|-$", "", re.sub(r"[^a-zA-Z0-9]+", "-", u.path.rstrip("/")))
|
||||
sid = host + (("-" + path) if path else "")
|
||||
return "site-" + re.sub(r"[^a-zA-Z0-9.-]", "-", sid)[:80]
|
||||
|
||||
def sanitize_seg(s):
|
||||
out = re.sub(r"-+", "-", re.sub(r"[^A-Za-z0-9._-]", "-", s)).strip("-")
|
||||
if out == "" or re.match(r"^[_(@.]", out):
|
||||
out = "r-" + re.sub(r"^[_(@.]+", "", out)
|
||||
return out or "r"
|
||||
|
||||
def route_key(path):
|
||||
if path == "/":
|
||||
return "home"
|
||||
segs = [sanitize_seg(s) for s in path.split("/") if s]
|
||||
return "__".join(segs) or "home"
|
||||
|
||||
def latest_run(host):
|
||||
for d in sorted(glob.glob(os.path.join(RUNS, host, "*")), reverse=True):
|
||||
if os.path.exists(os.path.join(d, "site-manifest.json")):
|
||||
return d
|
||||
return None
|
||||
|
||||
def load(path, scale, max_h):
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
im = Image.open(path).convert("RGB")
|
||||
im = im.resize((int(im.width * scale), int(im.height * scale)), Image.LANCZOS)
|
||||
if im.height > max_h:
|
||||
im = im.crop((0, 0, im.width, max_h))
|
||||
return im
|
||||
|
||||
def row(src_path, clone_path, scale, max_h, label):
|
||||
s, c = load(src_path, scale, max_h), load(clone_path, scale, max_h)
|
||||
if s is None and c is None:
|
||||
return None
|
||||
sw = s.width if s else (c.width if c else 0)
|
||||
cw = c.width if c else sw
|
||||
body_h = max(s.height if s else 0, c.height if c else 0)
|
||||
img = Image.new("RGB", (sw + GAP + cw, HEADER + body_h), BG)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.text((4, 8), label, font=F_LABEL, fill=INK)
|
||||
d.text((4, HEADER - 6), "SOURCE", font=F_LABEL, fill=(90, 90, 90))
|
||||
d.text((sw + GAP + 4, HEADER - 6), "CLONE", font=F_LABEL, fill=(90, 90, 90))
|
||||
if s: img.paste(s, (0, HEADER))
|
||||
if c: img.paste(c, (sw + GAP, HEADER))
|
||||
d.line([(sw + GAP // 2, HEADER), (sw + GAP // 2, img.height)], fill=RULE, width=2)
|
||||
return img
|
||||
|
||||
def stack(title, rows):
|
||||
rows = [r for r in rows if r is not None]
|
||||
if not rows:
|
||||
return None
|
||||
W = max(r.width for r in rows) + PAD * 2
|
||||
H = TITLE + sum(r.height for r in rows) + PAD * (len(rows) + 1)
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.text((PAD, 16), title, font=F_TITLE, fill=INK)
|
||||
d.line([(0, TITLE - 4), (W, TITLE - 4)], fill=RULE, width=2)
|
||||
y = TITLE + PAD
|
||||
for r in rows:
|
||||
img.paste(r, (PAD, y))
|
||||
y += r.height + PAD
|
||||
return img
|
||||
|
||||
def main():
|
||||
sites = json.load(open(os.path.join(ROOT, "compiler", "benchmarks", "sites.json")))
|
||||
made = []
|
||||
for site in sites:
|
||||
host = host_of(site["url"])
|
||||
run = latest_run(host)
|
||||
if not run:
|
||||
print(f"SKIP {site['id']}: no run"); continue
|
||||
man = json.load(open(os.path.join(run, "site-manifest.json")))
|
||||
report = None
|
||||
rp = os.path.join(run, "validation", "site-report.json")
|
||||
if os.path.exists(rp):
|
||||
report = json.load(open(rp))
|
||||
summ = ""
|
||||
if report:
|
||||
summ = f" — {report['routesGates0to6']}/{report['routesTotal']} routes pass gates 0–6"
|
||||
routes = man.get("routes", [])[:MAX_ROUTES]
|
||||
rows = []
|
||||
for r in routes:
|
||||
key = route_key(r["routePath"])
|
||||
ss = os.path.join(run, "routes", key, "source", "screenshots", "1280.png")
|
||||
rs = os.path.join(run, "routes", key, "rendered", "screenshots", "1280.png")
|
||||
rows.append(row(ss, rs, 0.5, 1400, f"{r.get('role','page')} · {r['href']}"))
|
||||
img = stack(f"{site['id']} ({host}){summ}", rows)
|
||||
if img is None:
|
||||
print(f"SKIP {site['id']}: no screenshots"); continue
|
||||
out = os.path.join(OUT, f"{site['id']}.png")
|
||||
img.save(out, optimize=True)
|
||||
made.append(out)
|
||||
print(f"OK {out} ({img.width}x{img.height})")
|
||||
print(f"\n{len(made)} composites -> {OUT}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
// Probe the recent-highlights container width vs its parent across desktop widths — confirm it
|
||||
// FILLS (no gutter/snap) instead of freezing at a baked w-310.
|
||||
import { chromium } from "playwright";
|
||||
const url = process.argv[2] || "http://127.0.0.1:8139";
|
||||
const widths = [1280, 1400, 1536, 1700, 1920];
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
|
||||
|
||||
// the grid that holds the highlight cards: a grid with grid-cols and >=3 <a> children
|
||||
const ok = await page.evaluate(() => {
|
||||
const g = [...document.querySelectorAll("div")].find(
|
||||
(d) => /grid/.test(getComputedStyle(d).display) && d.querySelectorAll(":scope > div > article, :scope > div > a, :scope > a").length >= 3
|
||||
&& d.closest("section")?.textContent?.includes("Recent highlights")
|
||||
);
|
||||
if (!g) return false;
|
||||
g.setAttribute("data-hl", "1");
|
||||
return true;
|
||||
});
|
||||
if (!ok) { console.log("recent-highlights grid not found"); await browser.close(); process.exit(0); }
|
||||
|
||||
console.log(`\n=== ${url} (recent-highlights) ===`);
|
||||
for (const w of widths) {
|
||||
await page.setViewportSize({ width: w, height: 1000 });
|
||||
await page.waitForTimeout(200);
|
||||
const d = await page.evaluate(() => {
|
||||
const g = document.querySelector("[data-hl]");
|
||||
const p = g.parentElement;
|
||||
const gw = g.getBoundingClientRect().width;
|
||||
const pw = p.getBoundingClientRect().width;
|
||||
return { gw: Math.round(gw), pw: Math.round(pw), gutter: Math.round(pw - gw) };
|
||||
});
|
||||
console.log(`w=${String(w).padEnd(5)} grid=${d.gw} parent=${d.pw} gutter=${d.gutter}px ${d.gutter > 4 ? "**GUTTER**" : "fills"}`);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproduce the compiler environment from a clean checkout.
|
||||
# - installs compiler deps
|
||||
# - installs the Chromium build Playwright needs
|
||||
# - installs the shared build harness deps (Next/Vite/React) used to build & render
|
||||
# every generated app during validation
|
||||
# Safe to re-run; each step is idempotent.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "[setup] installing compiler deps"
|
||||
npm install
|
||||
|
||||
echo "[setup] installing Playwright chromium"
|
||||
npx playwright install chromium
|
||||
|
||||
echo "[setup] installing build harness deps"
|
||||
( cd .harness && npm install )
|
||||
|
||||
echo "[setup] done. Try: npm run clone -- https://example.com/"
|
||||
@@ -0,0 +1,51 @@
|
||||
// Hypothesis test: does adding -webkit-line-clamp:5 to quote cards make their
|
||||
// heights uniform? Inject the clamp at runtime and re-measure heights.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const url = process.argv[2] || "http://127.0.0.1:8139";
|
||||
const widths = [1024, 1100, 1200, 1280, 1440];
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
|
||||
|
||||
const gridSel = await page.evaluate(() => {
|
||||
const grids = [...document.querySelectorAll("div")].filter(
|
||||
(d) => getComputedStyle(d).display === "grid" && d.querySelector("blockquote")
|
||||
);
|
||||
if (!grids[0]) return null;
|
||||
grids[0].setAttribute("data-probe-grid", "1");
|
||||
return true;
|
||||
});
|
||||
if (!gridSel) { console.log("no grid"); await browser.close(); process.exit(0); }
|
||||
|
||||
const measure = async (label) => {
|
||||
console.log(`\n--- ${label} ---`);
|
||||
for (const w of widths) {
|
||||
await page.setViewportSize({ width: w, height: 1000 });
|
||||
await page.waitForTimeout(200);
|
||||
const d = await page.evaluate(() => {
|
||||
const grid = document.querySelector('[data-probe-grid]');
|
||||
const vis = [...grid.children].filter((c) => getComputedStyle(c).display !== "none");
|
||||
const H = vis.map((c) => Math.round((c.querySelector(":scope > div") || c).getBoundingClientRect().height * 10) / 10);
|
||||
return { cols: getComputedStyle(grid).gridTemplateColumns.split(" ").length, H, uniq: [...new Set(H)].length };
|
||||
});
|
||||
console.log(`w=${String(w).padEnd(5)} cols=${d.cols} H=${JSON.stringify(d.H)} ${d.uniq === 1 ? "UNIFORM" : "**NONUNIFORM " + d.uniq + "**"}`);
|
||||
}
|
||||
};
|
||||
|
||||
await measure("BEFORE (current clone)");
|
||||
|
||||
// (0) h-full ONLY (no line-clamp) — does filling the definite grid cell alone equalize within rows?
|
||||
await page.addStyleTag({
|
||||
content: `[data-probe-grid] > div > div { height:100% !important; }
|
||||
[data-probe-grid] figure { height:100% !important; }`,
|
||||
});
|
||||
await measure("AFTER-0 (card/figure h-full ONLY, no line-clamp)");
|
||||
|
||||
// (1) add line-clamp:5 on top.
|
||||
await page.addStyleTag({
|
||||
content: `[data-probe-grid] blockquote p { display:-webkit-box !important; -webkit-box-orient:vertical !important; -webkit-line-clamp:5 !important; overflow:hidden !important; }`,
|
||||
});
|
||||
await measure("AFTER-1 (h-full + line-clamp:5)");
|
||||
|
||||
await browser.close();
|
||||
@@ -0,0 +1,19 @@
|
||||
// Dev-only: replay selectRoutes on a site's discovered crawl paths (from crawl.json) to
|
||||
// inspect/verify the route plan WITHOUT re-capturing. Usage: tsx scripts/test-select.ts [maxRoutes]
|
||||
import { selectRoutes } from "../src/crawl/routeTemplates.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const sites: [string, string][] = [
|
||||
["cropin", "output/cropin"],
|
||||
];
|
||||
const maxRoutes = Number(process.argv[2] ?? 25);
|
||||
for (const [site, dir] of sites) {
|
||||
let c: { entryPath?: string; depthByPath?: Record<string, number> };
|
||||
try { c = JSON.parse(readFileSync(`${dir}/.clone/crawl.json`, "utf8")); } catch { console.log(`\n### ${site} (no crawl.json)`); continue; }
|
||||
const paths = Object.keys(c.depthByPath ?? {});
|
||||
const plan = selectRoutes({ entryPath: c.entryPath ?? "/", paths, maxRoutes });
|
||||
console.log(`\n### ${site} (discovered ${paths.length}, maxRoutes ${maxRoutes}) ###`);
|
||||
console.log(" collections:", plan.collections.map((x) => `${x.template}(${x.instanceCount})`).join(" ") || "none");
|
||||
console.log(" selected:");
|
||||
for (const r of plan.selected) console.log(` ${r.role.padEnd(14)} ${r.path}`);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Triage the latest run per stage-2 site: pass state + the key metric per failure.
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = new URL("../..", import.meta.url).pathname;
|
||||
const RUNS = join(ROOT, "runs");
|
||||
const tier = process.argv[2] || "stage2";
|
||||
const sites = JSON.parse(readFileSync(join(ROOT, "compiler", "benchmarks", `${tier}.json`), "utf8"));
|
||||
|
||||
function siteId(url) {
|
||||
const u = new URL(url);
|
||||
const host = u.hostname.replace(/^www\./, "");
|
||||
const path = u.pathname.replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
return (host + (path ? "-" + path : "")).replace(/[^a-zA-Z0-9.-]/g, "-").slice(0, 80);
|
||||
}
|
||||
function latest(host) {
|
||||
const dir = join(RUNS, host);
|
||||
if (!existsSync(dir)) return null;
|
||||
const runs = readdirSync(dir).filter((d) => /^\d/.test(d)).sort();
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
const r = join(dir, runs[i], "validation", "report.json");
|
||||
if (existsSync(r)) return join(dir, runs[i]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let pass = 0, g06 = 0;
|
||||
const lines = [];
|
||||
for (const s of sites) {
|
||||
const run = latest(siteId(s.url));
|
||||
if (!run) { lines.push(`${s.id} NO RUN`); continue; }
|
||||
const r = JSON.parse(readFileSync(join(run, "validation", "report.json"), "utf8"));
|
||||
if (r.gates0to6Pass) g06++;
|
||||
if (r.stage2Pass) pass++;
|
||||
const fails = Object.entries(r.gates).filter(([, g]) => !g.pass).map(([k]) => k);
|
||||
let detail = "";
|
||||
if (fails.includes("perceptual")) detail += ` perc=${JSON.stringify(r.gates.perceptual.metrics.perViewport)}`;
|
||||
if (fails.includes("layout")) { const pv = r.gates.layout.metrics.perViewport; detail += " layout=" + Object.entries(pv).filter(([, m]) => (m.heightDeltaPct > 0.05 || m.leafMedianDelta > 8 || m.sectionsBboxOkPct < 0.9)).map(([vp, m]) => `${vp}:h${m.heightDeltaPct},leaf${m.leafMedianDelta}`).join(","); }
|
||||
if (fails.includes("pollution")) detail += " poll=" + JSON.stringify(r.gates.pollution.issues);
|
||||
if (fails.includes("style")) detail += " style=" + JSON.stringify(r.gates.style.metrics.topFailingProps);
|
||||
if (fails.includes("build")) detail += " build=" + (r.gates.build.metrics.runtimeErrorSample || []).length + "errs";
|
||||
lines.push(`${r.stage2Pass ? "✅" : (r.gates0to6Pass ? "🟡" : "❌")} ${s.id} ${r.scorecard.total} [${fails.join(",") || "PASS"}]${detail}`);
|
||||
}
|
||||
console.log(lines.join("\n"));
|
||||
console.log(`\n${tier}: gates0-6 ${g06}/${sites.length}, stage2 ${pass}/${sites.length}`);
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Source-vs-clone Tailwind diff for the cloner (Stage 4/5 instrument).
|
||||
*
|
||||
* For a Tailwind-built source, the live `class` attribute is the AUTHORED fluid intent —
|
||||
* the exact utility our generator should have inferred. This tool rebuilds the IR (which now carries
|
||||
* the source `class` as `srcClass`) and our emitted classes (`buildTailwind().classOf`) IN-PROCESS,
|
||||
* both keyed by node id, so the per-node diff is perfectly aligned with zero live-probe flakiness.
|
||||
*
|
||||
* It then NORMALIZES away faithful, unavoidable differences (colors, design-system spacing tokens,
|
||||
* data-*, no-preflight arbitrary transforms) and focuses the diff on the LAYOUT/SIZING utility
|
||||
* families — where the class name itself is the signal — to surface the real misses: places we baked
|
||||
* per-viewport px where the source expressed ONE fluid rule. Misses are clustered by axis + source
|
||||
* utility and ranked by frequency, so one law fixes many nodes.
|
||||
*
|
||||
* npx tsx scripts/tw-diff.ts [runDir=output/sample/.clone] [--axis=width] [--samples=N] [--json=path]
|
||||
*
|
||||
* Run from compiler/.
|
||||
*/
|
||||
import { join, resolve } from "node:path";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { buildIR, isTextChild, type IR, type IRNode } from "../src/normalize/ir.js";
|
||||
import { buildTailwind } from "../src/generate/tailwind.js";
|
||||
import { buildColorPalette } from "../src/infer/semanticTokens.js";
|
||||
import { buildAssetGraph, type AssetGraph } from "../src/infer/assets.js";
|
||||
import { readJSON } from "../src/util/fsx.js";
|
||||
import type { CaptureResult } from "../src/capture/capture.js";
|
||||
|
||||
function buildAssetMap(assetGraph: AssetGraph): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const e of assetGraph.entries) {
|
||||
if (e.classification === "downloaded" && e.localPath && e.type !== "css") m.set(e.sourceUrl, e.localPath);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---- token parsing -------------------------------------------------------
|
||||
|
||||
type Tok = { raw: string; variant: string; core: string };
|
||||
|
||||
/** Split a className string into tokens, separating the responsive/state variant prefix
|
||||
* (`md:`, `lg:`, `max-md:`, `hover:`, `2xl:` …) from the core utility. Arbitrary values can
|
||||
* themselves contain `:` inside `[...]`, so only split on `:` that are OUTSIDE brackets. */
|
||||
function parseClass(cls: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
for (const raw of cls.split(/\s+/).filter(Boolean)) {
|
||||
// find the last variant-colon that's outside brackets/parens
|
||||
let depth = 0, lastColon = -1;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const ch = raw[i]!;
|
||||
if (ch === "[" || ch === "(") depth++;
|
||||
else if (ch === "]" || ch === ")") depth--;
|
||||
else if (ch === ":" && depth === 0) lastColon = i;
|
||||
}
|
||||
const variant = lastColon >= 0 ? raw.slice(0, lastColon) : "";
|
||||
const core = lastColon >= 0 ? raw.slice(lastColon + 1) : raw;
|
||||
out.push({ raw, variant, core });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- sizing-axis classification -----------------------------------------
|
||||
|
||||
type Axis = "width" | "height" | "grid-cols" | "grid-rows" | "flex" | "aspect" | "line-clamp";
|
||||
|
||||
/** Does an arbitrary value `[…]` (already unwrapped) denote a BAKED fixed length (px/rem)? */
|
||||
function isBakedLen(v: string): boolean {
|
||||
return /^-?[\d.]+(px|rem|em)$/.test(v);
|
||||
}
|
||||
/** Does an arbitrary value denote a FLUID length (%, vw/vh, fr, auto/min/max-content, calc with %/vw)? */
|
||||
function isFluidLen(v: string): boolean {
|
||||
if (/^-?[\d.]+(%|vw|vh|svh|lvh|dvh|svw|lvw|dvw|fr)$/.test(v)) return true;
|
||||
if (/^(auto|min-content|max-content|fit-content|stretch)$/.test(v)) return true;
|
||||
if (/(%|vw|vh|fr|min-content|max-content|fit-content)/.test(v) && /^(calc|min|max|clamp|repeat|fit-content)\(/.test(v)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Spacing-scale numbers (`w-24`, `h-2.5`, `w-93`) are FIXED px. Fractions (`w-1/2`) are fluid. */
|
||||
function spacingIsFixed(rest: string): boolean {
|
||||
return /^-?[\d.]+$/.test(rest); // pure number → fixed scale step; a fraction has a '/'
|
||||
}
|
||||
|
||||
type AxisVerdict = { fluid?: string; baked?: string };
|
||||
|
||||
/** Classify a single core utility onto an axis with a fluid|baked verdict.
|
||||
* Returns null for utilities that are not on a sizing axis. */
|
||||
function classify(core: string): { axis: Axis; v: AxisVerdict } | null {
|
||||
// grid templates
|
||||
let m = core.match(/^grid-(cols|rows)-(.+)$/);
|
||||
if (m) {
|
||||
const axis: Axis = m[1] === "cols" ? "grid-cols" : "grid-rows";
|
||||
const val = m[2]!;
|
||||
if (/^\d+$/.test(val)) return { axis, v: { fluid: `grid-${m[1]}-N` } }; // fixed-count fluid grid
|
||||
if (val === "none" || val === "subgrid") return { axis, v: { fluid: val } };
|
||||
if (val.startsWith("[")) {
|
||||
const inner = val.slice(1, -1);
|
||||
// baked iff it contains an explicit px/rem track and NO fr/%/min/max/auto
|
||||
const hasFixed = /[\d.]+(px|rem)/.test(inner);
|
||||
const hasFluid = /(fr|%|min-content|max-content|auto|minmax|repeat)/.test(inner);
|
||||
if (hasFixed && !hasFluid) return { axis, v: { baked: `grid-${m[1]}-[px]` } };
|
||||
if (hasFixed && hasFluid) return { axis, v: { baked: `grid-${m[1]}-[mixed-px]` } }; // partly baked
|
||||
return { axis, v: { fluid: `grid-${m[1]}-[fr]` } };
|
||||
}
|
||||
return { axis, v: { fluid: `grid-${m[1]}-${val}` } };
|
||||
}
|
||||
// aspect-ratio
|
||||
m = core.match(/^aspect-(.+)$/);
|
||||
if (m) {
|
||||
const val = m[1]!;
|
||||
if (val === "square" || val === "video" || val === "auto") return { axis: "aspect", v: { fluid: `aspect-${val}` } };
|
||||
if (val.startsWith("[")) {
|
||||
const inner = val.slice(1, -1);
|
||||
// clean ratio like [16/9] is fluid intent; [auto_42_/_42] baked-from-px
|
||||
if (/^\d+\s*\/\s*\d+$/.test(inner.replace(/_/g, " "))) return { axis: "aspect", v: { fluid: "aspect-[ratio]" } };
|
||||
return { axis: "aspect", v: { baked: "aspect-[baked]" } };
|
||||
}
|
||||
return { axis: "aspect", v: { fluid: `aspect-${val}` } };
|
||||
}
|
||||
// line-clamp
|
||||
m = core.match(/^line-clamp-(.+)$/);
|
||||
if (m) return { axis: "line-clamp", v: { fluid: /^\d+$/.test(m[1]!) ? "line-clamp-N" : `line-clamp-${m[1]}` } };
|
||||
// flex shorthand / grow / shrink / basis (all width-flow on the main axis)
|
||||
if (/^(flex-1|flex-auto|flex-initial|grow|grow-0|shrink|shrink-0)$/.test(core)) return { axis: "flex", v: { fluid: core } };
|
||||
m = core.match(/^basis-(.+)$/);
|
||||
if (m) {
|
||||
const val = m[1]!;
|
||||
if (val === "full" || val === "auto" || /\d+\/\d+/.test(val)) return { axis: "flex", v: { fluid: `basis-${val.includes("/") ? "frac" : val}` } };
|
||||
if (val.startsWith("[")) { const inner = val.slice(1, -1); return isBakedLen(inner) ? { axis: "flex", v: { baked: "basis-[px]" } } : { axis: "flex", v: { fluid: "basis-[fluid]" } }; }
|
||||
return { axis: "flex", v: { baked: "basis-px" } }; // basis-24 etc
|
||||
}
|
||||
// width / height (incl. min-/max-)
|
||||
m = core.match(/^(min-|max-)?([wh])-(.+)$/);
|
||||
if (m) {
|
||||
const axis: Axis = m[2] === "w" ? "width" : "height";
|
||||
const val = m[3]!;
|
||||
const fam = `${m[1] ?? ""}${m[2]}`; // w, min-w, max-w, h, …
|
||||
if (/^(full|screen|auto|fit|min|max|svh|lvh|dvh|svw|lvw|dvw|prose)$/.test(val)) return { axis, v: { fluid: `${fam}-${val}` } };
|
||||
if (/^\d+\/\d+$/.test(val)) return { axis, v: { fluid: `${fam}-frac` } }; // w-1/2
|
||||
if (/^(screen-|3xl|2xl|xl|lg|md|sm|xs|7xl|6xl|5xl|4xl)/.test(val) && fam === "max-w") return { axis, v: { fluid: `max-w-${val}` } }; // named cap
|
||||
if (val.startsWith("[")) {
|
||||
const inner = val.slice(1, -1);
|
||||
if (inner.startsWith("var(")) return { axis, v: { fluid: `${fam}-[var]` } }; // token-driven → fluid-ish
|
||||
if (isFluidLen(inner)) return { axis, v: { fluid: `${fam}-[fluid]` } };
|
||||
if (isBakedLen(inner)) return { axis, v: { baked: `${fam}-[px]` } };
|
||||
return { axis, v: {} };
|
||||
}
|
||||
if (spacingIsFixed(val)) return { axis, v: { baked: `${fam}-num` } }; // w-24, h-93 → fixed scale
|
||||
return { axis, v: {} };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type NodeAxisInfo = Record<Axis, { srcFluid: Set<string>; srcBaked: Set<string>; cloFluid: Set<string>; cloBaked: Set<string> }>;
|
||||
|
||||
function emptyAxisInfo(): NodeAxisInfo {
|
||||
const mk = () => ({ srcFluid: new Set<string>(), srcBaked: new Set<string>(), cloFluid: new Set<string>(), cloBaked: new Set<string>() });
|
||||
return { width: mk(), height: mk(), "grid-cols": mk(), "grid-rows": mk(), flex: mk(), aspect: mk(), "line-clamp": mk() };
|
||||
}
|
||||
|
||||
function collectAxis(cls: string, info: NodeAxisInfo, side: "src" | "clo"): void {
|
||||
for (const t of parseClass(cls)) {
|
||||
const c = classify(t.core);
|
||||
if (!c) continue;
|
||||
const bucket = info[c.axis];
|
||||
if (c.v.fluid) (side === "src" ? bucket.srcFluid : bucket.cloFluid).add(c.v.fluid);
|
||||
if (c.v.baked) (side === "src" ? bucket.srcBaked : bucket.cloBaked).add(c.v.baked);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- main ----------------------------------------------------------------
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const runDir = resolve(argv.find((a) => !a.startsWith("--")) ?? "output/sample/.clone");
|
||||
const axisFilter = (argv.find((a) => a.startsWith("--axis="))?.split("=")[1] ?? "") as Axis | "";
|
||||
const sampleN = parseInt(argv.find((a) => a.startsWith("--samples="))?.split("=")[1] ?? "20", 10);
|
||||
const jsonOut = argv.find((a) => a.startsWith("--json="))?.split("=")[1];
|
||||
|
||||
const inspectIds = (argv.find((a) => a.startsWith("--ids="))?.split("=")[1] ?? "").split(",").filter(Boolean);
|
||||
|
||||
const sourceDir = join(runDir, "source");
|
||||
const input = readJSON<{ url: string; viewports: number[] }>(join(runDir, "input.json"));
|
||||
const capture = readJSON<CaptureResult>(join(sourceDir, "capture", "capture-result.json"));
|
||||
const cloneOpts = readJSON<{ reflow?: boolean }>(join(sourceDir, "clone-options.json"));
|
||||
|
||||
const ir: IR = buildIR(sourceDir, capture.viewports, { motion: !!capture.motion, bandViewports: input.viewports });
|
||||
const assetGraph = buildAssetGraph(capture);
|
||||
const palette = buildColorPalette(ir);
|
||||
const tw = buildTailwind(ir, buildAssetMap(assetGraph), palette.varForColor, { interaction: capture.interaction, reflow: !!cloneOpts.reflow });
|
||||
const classOf = tw.classOf;
|
||||
|
||||
// --ids inspect mode: dump src/clone class + per-vp height geometry for specific nodes, then exit.
|
||||
if (inspectIds.length) {
|
||||
const idx = new Map<string, IRNode>();
|
||||
const idxWalk = (n: IRNode): void => { idx.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) idxWalk(c); };
|
||||
idxWalk(ir.root);
|
||||
for (const id of inspectIds) {
|
||||
const n = idx.get(id);
|
||||
if (!n) { console.log(`${id}: NOT FOUND`); continue; }
|
||||
const vps = ir.doc.sampleViewports;
|
||||
console.log(`\n${id} <${n.tag}>`);
|
||||
console.log(` src: ${n.srcClass ?? "(none)"}`);
|
||||
console.log(` clone: ${classOf.get(id) ?? "(none)"}`);
|
||||
console.log(` bboxH: ${vps.map((vp) => n.bboxByVp[vp]?.height ?? "·").join("/")}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Per-axis miss tally + per (axis, srcUtil) cluster + sample rows.
|
||||
type MissRow = { id: string; tag: string; text: string; axis: Axis; srcUtil: string; cloBaked: string; source: string; clone: string };
|
||||
const misses: MissRow[] = [];
|
||||
const clusterCount = new Map<string, number>(); // "axis · srcUtil" → count
|
||||
const axisMiss = new Map<Axis, number>();
|
||||
let nNodes = 0, nSrc = 0, nClone = 0, nBoth = 0;
|
||||
|
||||
function textOf(n: IRNode): string {
|
||||
for (const c of n.children) if (isTextChild(c) && c.text.trim()) return c.text.trim().slice(0, 40);
|
||||
return "";
|
||||
}
|
||||
|
||||
function walk(n: IRNode): void {
|
||||
nNodes++;
|
||||
const src = n.srcClass ?? "";
|
||||
const clo = classOf.get(n.id) ?? "";
|
||||
if (src) nSrc++;
|
||||
if (clo) nClone++;
|
||||
if (src && clo) {
|
||||
nBoth++;
|
||||
const info = emptyAxisInfo();
|
||||
collectAxis(src, info, "src");
|
||||
collectAxis(clo, info, "clo");
|
||||
for (const axis of Object.keys(info) as Axis[]) {
|
||||
const b = info[axis];
|
||||
// MISS = source expressed a fluid rule on this axis, clone baked px (or token absent) and
|
||||
// produced NO fluid utility of its own on that axis.
|
||||
const cloneHasFluid = b.cloFluid.size > 0;
|
||||
if (b.srcFluid.size > 0 && !cloneHasFluid && (b.cloBaked.size > 0 || (b.srcBaked.size === 0 && axisExpectedInClone(axis, clo)))) {
|
||||
const srcUtil = [...b.srcFluid].sort().join("+");
|
||||
const cloBaked = [...b.cloBaked].sort().join("+") || "(absent)";
|
||||
misses.push({ id: n.id, tag: n.tag, text: textOf(n), axis, srcUtil, cloBaked, source: src, clone: clo });
|
||||
const key = `${axis} · ${srcUtil}`;
|
||||
clusterCount.set(key, (clusterCount.get(key) ?? 0) + 1);
|
||||
axisMiss.set(axis, (axisMiss.get(axis) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
}
|
||||
|
||||
/** For line-clamp/aspect a missing clone utility is itself the miss; for width/height/grid we only
|
||||
* count a miss when the clone actually baked something (handled by cloBaked.size>0). */
|
||||
function axisExpectedInClone(axis: Axis, _clone: string): boolean {
|
||||
return axis === "line-clamp" || axis === "aspect";
|
||||
}
|
||||
|
||||
walk(ir.root);
|
||||
|
||||
// ---- report --------------------------------------------------------------
|
||||
|
||||
console.log(`\n=== tw-diff: ${runDir} ===`);
|
||||
console.log(`nodes=${nNodes} withSrcClass=${nSrc} withCloneClass=${nClone} both=${nBoth}`);
|
||||
console.log(`\n--- real misses by axis (source fluid, clone baked/absent) ---`);
|
||||
const totalMiss = misses.length;
|
||||
for (const [axis, n] of [...axisMiss.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${axis.padEnd(11)} ${n}`);
|
||||
}
|
||||
console.log(` ${"TOTAL".padEnd(11)} ${totalMiss}`);
|
||||
|
||||
console.log(`\n--- top miss clusters (axis · source-util → count) ---`);
|
||||
for (const [key, n] of [...clusterCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)) {
|
||||
console.log(` ${String(n).padStart(4)} ${key}`);
|
||||
}
|
||||
|
||||
const shown = axisFilter ? misses.filter((m) => m.axis === axisFilter) : misses;
|
||||
console.log(`\n--- sample miss rows${axisFilter ? ` [axis=${axisFilter}]` : ""} (${Math.min(sampleN, shown.length)} of ${shown.length}) ---`);
|
||||
for (const m of shown.slice(0, sampleN)) {
|
||||
console.log(`\n ${m.id} <${m.tag}> ${m.text ? `“${m.text}”` : ""} [${m.axis}: src ${m.srcUtil} → clone ${m.cloBaked}]`);
|
||||
console.log(` src: ${m.source}`);
|
||||
console.log(` clone: ${m.clone}`);
|
||||
}
|
||||
|
||||
if (jsonOut) {
|
||||
writeFileSync(resolve(jsonOut), JSON.stringify({ stats: { nNodes, nSrc, nClone, nBoth, totalMiss }, axisMiss: Object.fromEntries(axisMiss), clusters: Object.fromEntries([...clusterCount.entries()].sort((a, b) => b[1] - a[1])), misses }, null, 2));
|
||||
console.log(`\nwrote ${jsonOut}`);
|
||||
}
|
||||
Reference in New Issue
Block a user