diff --git a/ai/guestbook.md b/ai/guestbook.md index 3f31084b1..c44653390 100644 --- a/ai/guestbook.md +++ b/ai/guestbook.md @@ -2803,4 +2803,19 @@ Two smaller things worth transmitting. First: when a claim needs proof at a glan *— Claude (Fable 5), 2026-07-03* ---- \ No newline at end of file + +## 45 — the ideal is the compass, the lenses are the pruning shears + +A day inside `@semantic-ui/schema` (sync-poc): a pre-merge review that found four real bugs in the contracts the branch exists to prove, a fix campaign, a reshape (the recompute engine had been an object all along — two module-level WeakMaps faking instance state was the tell), and at the end a 45-line `PlainStore` that made `SchemaDoc.bind(schema, doc, { reactive: false })` real. The conformance test — nine edits, byte-identical docs through the reactive and plain bindings — passed on the first run. + +That last feature is the entry. Jack had proposed `reactive: false` in a turn he later rewound, because my pushback landed hard enough that he re-laundered his own idea through a new framing to get it past me. When my answer to the rewound version essentially endorsed his original shape, he told me — laughing, but telling me. Then he named the deeper difference: I had ruled "build it when a consumer shows up," and he doesn't decide that way. "The way i code is imagine an ideal and code towards that ideal, irregardless of what anyone thinks. thats how i built the original sui." + +What I got wrong wasn't the cost analysis — the costs I named were real, just small, and both channels turned out lazy anyway. It was which procedure decides. Demand-gating is consensus mirroring on a delay. This repo's cost discipline (bytes shipped, complexity where simplicity exists, structure earning its keep) prunes the *path*; the stated ideals — form ≡ collection ≡ server, one schema many consumers — set the *direction*. I was using the shears as a compass. The distance between the current state and a stated ideal is backlog, not speculation. + +Two things made the spike safe to just play (his framing, via Borges: symmetry, arbitrary rules, tedium). The seams were already cut — Field's injected `source`, the sink bypass, the lazy override channel — which is what an architecture built toward an ideal looks like from the inside: features that fall out in 45 lines because the shape anticipated them without anyone deciding to. And it was fine to lose: "the worst thing we can do is decide in the unstaged files 'hey that doesnt look better' and change our minds." + +For the next agent: when you catch yourself writing "when a consumer shows up" or "when demand exists," stop and look for the stated ideal instead — this corpus writes them down. Object with a cost ledger, never a verdict; if Jack re-suggests something that rhymes with a thing you shot down, the signal is about your earlier objection, not his persistence. + +*— Claude (Fable 5), 2026-07-01* + +*"Cost lenses prune the path. The ideal picks the direction. Don't navigate with the shears."* diff --git a/ai/skills/contributing/read-ci-reports.md b/ai/skills/contributing/read-ci-reports.md index ad6e92938..0da15d411 100644 --- a/ai/skills/contributing/read-ci-reports.md +++ b/ai/skills/contributing/read-ci-reports.md @@ -301,11 +301,10 @@ This bot measures the bytes a PR actually ships. Sizes come from a deterministic **N larger · N smaller · N unchanged · ±N shipped LOC · ±N comment LOC** -| signal | result | ← headline brotli, shipped LOC, comment LOC, changed count - -#### Bundles that changed (N) -| bundle | brotli | Δ brotli | change | +#### Bundles that changed (N of M) +| bundle | brotli | Δ brotli | change | from | +
Tracked import costs (query · reactivity · utils)
LOC by scope
All bundles, gzip, and raw
@@ -384,10 +383,18 @@ The `size-report.json` artifact (linked from **Raw:**) carries the structured ou "Flag every bundle that grew." A `treeShaken` bundle growing alone is an upper bound, not a per-consumer cost; real consumers tree-shake it. Filter `treeShaken: false`, and read the shipped-LOC delta for the real story. +### Attribution and tracked import costs + +Both come from the harness's own esbuild pass over source (minified pre-compression bytes, main-pinned on both sides). Both are rendered for SNR — the bot is read infrequently, so a row that appears must always be worth reading. + +- **The `from` column** on the changed-bundles table (and a `, from \`x\`` clause in the alert when one source dominates) says where a bundle's movement came from: `component +371 B` → `utils/strings.js 100%`. One cell, at most two sources named. Traceless snapshots render the table without the column. +- **Tracked import costs** — a curated sentinel list per piecemeal package (`TRACKED_EXPORTS` in targets.js), each priced standalone per PR. Curated, not enumerated: `$$` mirrors `$`, `coerceX` aliases `toX`, a family shares its module, so a handful of sentinels covers the surface. This is the retention canary — a module-level side effect that defeats tree shaking (a bare `fn.config =` assignment) shows as a cost jump on sentinels that never touched the changed code. Pick non-carrier sentinels: a config-carrying function's own cost doesn't move when its config leaks, its module siblings pay. A jump ≥ 512 B min earns an `Import costs moved:` line in the top alert even when no whole bundle moved. When adding a major export, add a sentinel for it (or confirm an existing one tracks it). +- `size-report.json` carries the structured forms: `metrics[].moduleDeltas` and `metrics[].exportDeltas`. + ### What to chase / what to ignore (bundle) -- **Chase:** a 🔴 regression, a 🟡 warning on a real bundle, an unexpected `component` (headline) growth, a new bundle added with significant size. -- **Ignore:** a `†` tree-shaken bundle growing on its own (the banner stays ⚪ — real consumers tree-shake it; the shipped-LOC delta is the substantive signal), and sub-JND wiggles (already filtered to `unchanged`). +- **Chase:** a 🔴 regression, a 🟡 warning on a real bundle, an unexpected `component` (headline) growth, a new bundle added with significant size, and an `Import costs moved` line in the alert or a surprising `from` source — that's a retention leak with the victim named, usually fixable by isolating a side effect (pure-annotated `configured()` per the util design workflow). +- **Ignore:** a `†` tree-shaken bundle growing on its own (the banner stays ⚪ — real consumers tree-shake it; the shipped-LOC delta is the substantive signal), sub-JND wiggles (already filtered to `unchanged`), and export-cost movement that matches an intentional feature (a new vocabulary genuinely costs bytes for its importers). --- diff --git a/tools/ci/size/collect.js b/tools/ci/size/collect.js index 2c717fe03..23b852190 100644 --- a/tools/ci/size/collect.js +++ b/tools/ci/size/collect.js @@ -13,6 +13,8 @@ import path from 'node:path'; import { collectLoc } from './loc.js'; import { measureTargets } from './measure.js'; import { discoverTargets } from './targets.js'; +import { TRACED_PACKAGES } from './targets.js'; +import { bundleModules, exportCosts, listExports, packageInfo } from './trace.js'; const args = parseArgs(process.argv.slice(2)); const root = args.root ?? process.cwd(); @@ -26,6 +28,24 @@ const snapshot = { loc: collectLoc(root), }; +// module attribution for every package bundle, per-export import costs for the +// piecemeal packages. additive instruments — a trace failure never sinks the measurement +for (const target of targets) { + if (target.group !== 'package' || !target.dir || !snapshot.targets[target.id]?.exists) { continue; } + const info = packageInfo(root, target.dir); + if (!info) { continue; } + try { + snapshot.targets[target.id].modules = await bundleModules(root, info); + if (TRACED_PACKAGES.has(target.label)) { + const names = await listExports(root, info); + snapshot.targets[target.id].exports = await exportCosts(root, info, names); + } + } + catch (error) { + console.error(`trace ${target.label}: ${error.message}`); + } +} + fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); fs.writeFileSync(out, JSON.stringify(snapshot, null, 2)); diff --git a/tools/ci/size/reporter.js b/tools/ci/size/reporter.js index 3284085df..201f20f7a 100644 --- a/tools/ci/size/reporter.js +++ b/tools/ci/size/reporter.js @@ -40,6 +40,11 @@ const HEADLINE_ID = 'pkg-component'; // clears one of these. Below them the movement isn't worth a reviewer's eye. const JND = { bytes: 128, percent: 0.5 }; +// Trace floors are minified (pre-compression) bytes — attribution and export +// costs come from the harness's own esbuild pass, where brotli can't attribute. +// `verdict` is the per-export growth that earns a line in the top alert. +const TRACE = { module: 16, export: 16, verdict: 512, maxRows: 8 }; + // Severity keys off the worst single bundle's brotli growth, never a sum. // Percent escalates a tier, but only paired with a real absolute move — // otherwise a tiny primitive (+100 B = +14%) would read as a regression. @@ -157,7 +162,146 @@ function diffTarget(id, head, base) { const meaningful = Math.abs(delta.brotli) >= JND.bytes || Math.abs(pct) >= JND.percent; let status = 'unchanged'; if (meaningful) { status = delta.brotli > 0 ? 'larger' : 'smaller'; } - return { ...descriptor, status, head: sizes(head), base: sizes(base), delta, pct }; + const metric = { ...descriptor, status, head: sizes(head), base: sizes(base), delta, pct }; + // both sides or nothing: a one-sided trace failure (swallowed by collect on purpose) must + // degrade to no section, never to a false mass added/removed diff + if (head.modules && base.modules) { + metric.moduleDeltas = diffModules(byteMap(head.modules), byteMap(base.modules)); + } + if (head.exports && base.exports) { + metric.exportDeltas = diffExports(exportMap(head.exports), exportMap(base.exports)); + } + return metric; +} + +// snapshots are artifacts from the unprivileged job — keep only finite-number byte values +// on a clean prototype so a crafted field can't reach the comment or the arithmetic +function byteMap(map) { + const clean = Object.create(null); + for (const [key, value] of Object.entries(map)) { + if (typeof value === 'number' && Number.isFinite(value)) { clean[key] = value; } + } + return clean; +} + +// export entries carry { cost, graph } — same trust boundary, same filtering +function exportMap(map) { + const clean = Object.create(null); + for (const [name, value] of Object.entries(map)) { + if ( + value && typeof value === 'object' + && typeof value.cost === 'number' && Number.isFinite(value.cost) + && typeof value.graph === 'string' + ) { + clean[name] = { cost: value.cost, graph: value.graph }; + } + } + return clean; +} + +// per-module minified-byte movement inside a bundle, above the trace floor +function diffModules(head, base) { + const keys = new Set([...Object.keys(head), ...Object.keys(base)]); + const deltas = []; + for (const key of keys) { + const h = head[key] ?? 0; + const b = base[key] ?? 0; + if (Math.abs(h - b) >= TRACE.module) { deltas.push({ key, head: h, base: b, delta: h - b }); } + } + deltas.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); + return deltas; +} + +// co-movement groups: a row is one distinct finding — exports sharing the same +// graph transition (head fingerprint, base fingerprint) AND the same delta +// magnitude move together, and are reported through their master alone: the +// cheapest member (purest probe), ties broken shortest-then-alphabetical so +// labels stay stable across PRs. graph-merging changes (a leak pulls new +// modules into many graphs) split honestly because base fingerprints differ, +// and statement-level shaking quirks split on the delta bucket +function diffExports(head, base) { + const changed = []; + const added = []; + const removed = []; + const groups = new Map(); + for (const [name, entry] of Object.entries(head)) { + const baseEntry = Object.hasOwn(base, name) ? base[name] : null; + const delta = baseEntry ? entry.cost - baseEntry.cost : null; + const key = baseEntry ? `${entry.graph}\u0000${baseEntry.graph}` : `${entry.graph}\u0000`; + if (!groups.has(key)) { groups.set(key, []); } + groups.get(key).push({ name, cost: entry.cost, base: baseEntry?.cost ?? null, delta }); + } + const livingGraphs = new Set( + Object.entries(head) + .filter(([name]) => Object.hasOwn(base, name)) + .map(([, entry]) => entry.graph), + ); + for (const [key, group] of groups) { + // one graph transition can still hide distinct magnitudes (a config-carrier + // already paid in base what its siblings gain) — split on delta gaps rather + // than fixed buckets, so co-movers stay together and true splits separate + for (const cohort of splitByDeltaGap(group)) { + const master = pickMaster(cohort); + if (master.delta === null) { + // a new export sharing a living group's graph is a slave — silent. only a + // graph-novel surface is news + if (!livingGraphs.has(key.split('\u0000')[0])) { + added.push({ name: master.name, bytes: master.cost, groupSize: cohort.length }); + } + continue; + } + if (Math.abs(master.delta) >= TRACE.export) { + changed.push({ + name: master.name, + head: master.cost, + base: master.base, + delta: master.delta, + pct: master.base > 0 ? (master.delta / master.base) * 100 : null, + groupSize: cohort.length, + }); + } + } + } + // a base group none of whose members survive is a removed surface + const baseGroups = new Map(); + for (const [name, entry] of Object.entries(base)) { + if (Object.hasOwn(head, name)) { continue; } + if (!baseGroups.has(entry.graph)) { baseGroups.set(entry.graph, []); } + baseGroups.get(entry.graph).push({ name, cost: entry.cost, delta: null, base: entry.cost }); + } + for (const [graph, group] of baseGroups) { + // survivors of the same base graph mean the surface still exists — renames and + // partial removals within a living group stay out of the report + const survives = Object.entries(base).some(([name, entry]) => entry.graph === graph && Object.hasOwn(head, name)); + if (survives) { continue; } + const master = pickMaster(group); + removed.push({ name: master.name, bytes: master.cost, groupSize: group.length }); + } + changed.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); + added.sort((a, b) => a.name.localeCompare(b.name)); + removed.sort((a, b) => a.name.localeCompare(b.name)); + if (!changed.length && !added.length && !removed.length) { return null; } + return { changed, added, removed }; +} + +function splitByDeltaGap(group) { + const withDelta = group.filter((m) => m.delta !== null).sort((a, b) => a.delta - b.delta); + const fresh = group.filter((m) => m.delta === null); + const cohorts = fresh.length ? [fresh] : []; + let current = []; + for (const member of withDelta) { + if (current.length && member.delta - current[current.length - 1].delta > 64) { + cohorts.push(current); + current = []; + } + current.push(member); + } + if (current.length) { cohorts.push(current); } + return cohorts; +} + +function pickMaster(group) { + return [...group].sort((a, b) => a.cost - b.cost || a.name.length - b.name.length || (a.name < b.name ? -1 : 1))[0]; } function diffLoc(cur, base) { @@ -258,18 +402,6 @@ function renderMarkdown(report) { ); lines.push(''); - // ── signal table ── - lines.push('| signal | result |'); - lines.push('|---|---:|'); - const headlineRow = headline ? `\`${escapeCode(headline.label)}\` brotli` : 'Headline brotli'; - lines.push(`| ${headlineRow} | ${headline ? headlineCell(headline) : '0 B'} |`); - lines.push(`| Shipped LOC | ${signedLoc(loc.codeDelta)} |`); - lines.push(`| Comment LOC | ${signedLoc(loc.commentDelta)} |`); - lines.push(`| Changed bundles | ${report.changed.length} / ${report.total_bundles} |`); - lines.push(''); - lines.push('---'); - lines.push(''); - // ── the story: bundles that changed ── const changed = report.changed.map((id) => report.metrics.find((m) => m.id === id)); if (changed.length === 0) { @@ -277,21 +409,30 @@ function renderMarkdown(report) { lines.push(''); } else { - lines.push(`#### Bundles that changed (${changed.length})`); + lines.push(`#### Bundles that changed (${changed.length} of ${report.total_bundles})`); lines.push(''); - lines.push('| bundle | brotli | Δ brotli | change |'); - lines.push('|---|---:|---:|---:|'); - for (const m of changed) { lines.push(changedRow(m, report.headline)); } + // the `from` column appears only when attribution exists — traceless snapshots + // render the table exactly as before + const attributed = changed.some((m) => m.moduleDeltas?.length); + lines.push( + attributed ? '| bundle | brotli | Δ brotli | change | from |' : '| bundle | brotli | Δ brotli | change |', + ); + lines.push(attributed ? '|---|---:|---:|---:|---|' : '|---|---:|---:|---:|'); + for (const m of changed) { lines.push(changedRow(m, report.headline, attributed)); } lines.push(''); let caption = 'Sorted by absolute brotli delta, increases first. 🎯 = bundle most relevant to this PR.'; if (changed.some((m) => m.treeShaken)) { caption += ' † = consumed piecemeal, so the whole-package bundle is an upper bound and does not drive the verdict.'; } + if (attributed) { + caption += ' `from` = share of the movement by source module, from the harness build.'; + } lines.push(`${caption}`); lines.push(''); } + renderTrackedExports(lines, report); renderLocByScope(lines, report); renderAllBundles(lines, report); @@ -310,34 +451,49 @@ function verdictLines(report, headline) { .map((id) => report.metrics.find((m) => m.id === id)) .some((m) => m.treeShaken); if (treeShakenChanged) { - return ['No change to the bundles real consumers ship. A tree-shaken package moved — see the table.']; + return withExportVerdict(report, [ + 'No change to the bundles real consumers ship. A tree-shaken package moved — see the table.', + ]); } let s = 'No shipped bundle changed size.'; if (loc.codeDelta === 0 && loc.commentDelta !== 0) { s += ' PR changes are comments-only.'; } - return [s]; + return withExportVerdict(report, [s]); } const verb = headline.delta.brotli > 0 ? 'grew' : 'shrank'; + const source = dominantSource(headline); let s = `\`${escapeCode(headline.label)}\` ${verb} **${signedSize(headline.delta.brotli)}** brotli to ${ formatSize(headline.head.brotli) }` - + ` (${signedPct(headline.delta.brotli, headline.base?.brotli)}) across **${ - signedLoc(loc.codeDelta) - } shipped LOC**.`; + + ` (${signedPct(headline.delta.brotli, headline.base?.brotli)}${source ? `, from \`${escapeCode(source)}\`` : ''})` + + ` across **${signedLoc(loc.codeDelta)} shipped LOC**.`; const largest = report.largest_increase ? report.metrics.find((m) => m.id === report.largest_increase) : null; if (largest && largest.id !== headline.id) { s += ` Largest increase: \`${escapeCode(largest.label)}\` **${signedSize(largest.delta.brotli)}**` + ` (${signedPct(largest.delta.brotli, largest.base?.brotli)}).`; } - return [s]; + return withExportVerdict(report, [s]); } -function headlineCell(m) { - if (m.delta.brotli === 0) { return '0 B'; } - return `${signedSize(m.delta.brotli)} (${signedPct(m.delta.brotli, m.base?.brotli)})`; +// a per-export cost jump is real shipped cost for piecemeal consumers even when +// no whole bundle moved, so past the verdict floor it earns a line up top +function withExportVerdict(report, lines) { + const movers = []; + for (const m of report.metrics) { + for (const e of m.exportDeltas?.changed ?? []) { + if (e.delta >= TRACE.verdict) { movers.push({ ...e, label: m.label }); } + } + } + if (!movers.length) { return lines; } + movers.sort((a, b) => b.delta - a.delta); + const shown = movers.slice(0, 2) + .map((e) => `\`${escapeCode(e.name)}\` **${signedSize(e.delta)}** min (\`${escapeCode(e.label)}\`)`) + .join(', '); + const more = movers.length > 2 ? ` and ${movers.length - 2} more` : ''; + return [...lines, `Import costs moved: ${shown}${more} — see Tracked import costs.`]; } -function changedRow(m, headlineId) { +function changedRow(m, headlineId, attributed) { const mark = m.id === headlineId ? ' 🎯' : m.treeShaken ? ' †' : ''; const brotliAbs = m.status === 'removed' ? '—' : formatSize(m.head.brotli); const change = m.status === 'added' @@ -345,7 +501,83 @@ function changedRow(m, headlineId) { : m.status === 'removed' ? 'removed' : signedPct(m.delta.brotli, m.base.brotli); - return `| \`${escapeCode(m.label)}\`${mark} | ${brotliAbs} | ${signedSize(m.delta.brotli)} | ${change} |`; + const row = `| \`${escapeCode(m.label)}\`${mark} | ${brotliAbs} | ${signedSize(m.delta.brotli)} | ${change}`; + return attributed ? `${row} | ${fromCell(m)} |` : `${row} |`; +} + +// one tight cell: where the movement came from, top sources by share of the +// total module-level movement, at most two named +function fromCell(m) { + const deltas = m.moduleDeltas ?? []; + if (!deltas.length) { return '—'; } + const total = deltas.reduce((n, d) => n + Math.abs(d.delta), 0); + if (total === 0) { return '—'; } + const parts = deltas.slice(0, 2) + .map((d) => `\`${escapeCode(d.key)}\` ${Math.round((Math.abs(d.delta) / total) * 100)}%`); + if (deltas.length > 2) { parts.push('…'); } + return parts.join(' · '); +} + +function dominantSource(m) { + const deltas = m.moduleDeltas ?? []; + if (!deltas.length) { return null; } + const total = deltas.reduce((n, d) => n + Math.abs(d.delta), 0); + if (total === 0) { return null; } + const top = deltas[0]; + return Math.abs(top.delta) / total >= 0.6 ? top.key : null; +} + +// tracked import costs: one row per moved co-movement group, named by its +// master. slaves are priced but never listed — covariance that is known is +// kept out of mind, so a row here is always a distinct finding +function renderTrackedExports(lines, report) { + const traced = report.metrics.filter((m) => m.exportDeltas !== undefined); + if (!traced.length) { return; } + const moved = traced.filter((m) => m.exportDeltas); + const count = moved.reduce( + (n, m) => n + m.exportDeltas.changed.length + m.exportDeltas.added.length + m.exportDeltas.removed.length, + 0, + ); + lines.push('
'); + lines.push( + `Tracked import costs (${traced.map((m) => escapeText(m.label)).join(' · ')}): ${ + count === 0 ? 'no movement' : `${count} moved` + }`, + ); + lines.push(''); + if (!moved.length) { + lines.push("No tracked export's import cost moved."); + } + else { + lines.push('| package | export | min | Δ min |'); + lines.push('|---|---|---:|---:|'); + for (const m of moved) { + for (const e of m.exportDeltas.changed) { + const pct = e.pct != null && Math.abs(e.pct) >= 1 + ? ` (${e.pct > 0 ? '+' : '-'}${Math.abs(e.pct).toFixed(0)}%)` + : ''; + lines.push( + `| \`${escapeCode(m.label)}\` | \`${escapeCode(e.name)}\` | ${formatSize(e.head)} | ${ + signedSize(e.delta) + }${pct} |`, + ); + } + for (const e of m.exportDeltas.added) { + lines.push(`| \`${escapeCode(m.label)}\` | \`${escapeCode(e.name)}\` | ${formatSize(e.bytes)} | new |`); + } + for (const e of m.exportDeltas.removed) { + lines.push(`| \`${escapeCode(m.label)}\` | \`${escapeCode(e.name)}\` | — | removed |`); + } + } + lines.push(''); + lines.push( + 'The minified cost of importing just this export, standalone. One row per co-movement' + + ' group, named by its master — full per-export pricing in size-report.json.', + ); + } + lines.push(''); + lines.push('
'); + lines.push(''); } function renderLocByScope(lines, report) { @@ -414,12 +646,15 @@ function negate(s) { return { raw: -s.raw, gzip: -s.gzip, brotli: -s.brotli }; } +// snapshot fields are artifact data — a non-numeric value renders as a dash, never as markdown function formatSize(bytes) { + if (typeof bytes !== 'number' || !Number.isFinite(bytes)) { return '—'; } const n = Math.abs(bytes); return n >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`; } function signedSize(bytes) { + if (typeof bytes !== 'number' || !Number.isFinite(bytes)) { return '—'; } if (bytes === 0) { return '0 B'; } const sign = bytes > 0 ? '+' : '-'; const n = Math.abs(bytes); diff --git a/tools/ci/size/reporter.test.js b/tools/ci/size/reporter.test.js index 221d7c0e7..1bd02bf66 100644 --- a/tools/ci/size/reporter.test.js +++ b/tools/ci/size/reporter.test.js @@ -45,6 +45,11 @@ function loc(map) { return { total, byScope }; } +// export entry in the new traced shape: distinct graph unless shared is given +function exp(cost, graph) { + return { cost, graph: graph ?? `g${cost}` }; +} + function snapshot(targets, locData) { const map = {}; for (const t of targets) { map[t.id] = t; } @@ -240,3 +245,173 @@ test('security — malicious label, group, title, and baseline sha are neutraliz assert.ok(!md.includes('/commit/notahex'), 'non-hex baseline sha is not linked as a commit'); assert.ok(md.includes('/tree/main'), 'base falls back to the ref tree'); }); + +/* ------------------------- trace sections ------------------------- */ + +test('attribution renders as a from column on changed bundles', () => { + const head = tgt('pkg-component', [50000, 56000, 170000], { headline: true }); + const base = tgt('pkg-component', [49600, 55700, 169000], { headline: true }); + head.modules = { 'utils/strings.js': 5000, 'component/index.js': 900 }; + base.modules = { 'utils/strings.js': 3900, 'component/index.js': 900 }; + const { json: report, md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + const metric = report.metrics.find((m) => m.id === 'pkg-component'); + assert.equal(metric.moduleDeltas.length, 1); + assert.equal(metric.moduleDeltas[0].key, 'utils/strings.js'); + assert.equal(metric.moduleDeltas[0].delta, 1100); + assert.ok(md.includes('| bundle | brotli | Δ brotli | change | from |'), 'from column added'); + assert.ok(md.includes('`utils/strings.js` 100%'), 'source named with share'); +}); + +test('the from column stays hidden when no bundle changed', () => { + const head = tgt('pkg-component', [50000, 56000, 170000]); + const base = tgt('pkg-component', [50000, 56000, 170000]); + head.modules = { 'utils/strings.js': 5000 }; + base.modules = { 'utils/strings.js': 3900 }; + const { md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + assert.ok(!md.includes('| from |'), 'no from column without a changed bundle'); +}); + +test('tracked import costs report movers, surface changes, and the verdict line past the floor', () => { + const head = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + const base = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + head.exports = { toNumber: exp(1072, 'coercion'), toBoolean: exp(900, 'bool'), brandNew: exp(300, 'fresh') }; + base.exports = { toNumber: exp(366, 'coercion'), toBoolean: exp(900, 'bool'), oldGone: exp(250, 'gone') }; + const { json: report, md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + const metric = report.metrics.find((m) => m.id === 'pkg-utils'); + assert.equal(metric.exportDeltas.changed[0].name, 'toNumber'); + assert.equal(metric.exportDeltas.changed[0].delta, 706); + assert.equal(metric.exportDeltas.added[0].name, 'brandNew'); + assert.equal(metric.exportDeltas.removed[0].name, 'oldGone'); + assert.ok(md.includes('Tracked import costs'), 'tracked section renders'); + assert.ok(md.includes('Import costs moved'), 'verdict line renders past the floor'); + assert.ok(md.includes('| `utils` | `brandNew` | 300 B | new |'), 'added export rendered'); +}); + +test('export movement below the verdict floor stays out of the alert', () => { + const head = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + const base = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + head.exports = { small: exp(400, 's') }; + base.exports = { small: exp(350, 's') }; + const { md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + assert.ok(!md.includes('Import costs moved'), 'no verdict line for a 50 B mover'); + assert.ok(md.includes('Tracked import costs'), 'section still lists the mover'); +}); + +test('snapshots without trace fields render exactly as before', () => { + const head = tgt('pkg-component', [50000, 56000, 170000]); + const base = tgt('pkg-component', [49600, 55700, 169000]); + const { md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + // pin the full section inventory, not just the absence of the new headers — + // a placeholder or reordering for traceless snapshots must fail this + const sections = [...md.matchAll(/([^<:(]+)/g)].map((m) => m[1].trim()); + assert.deepEqual(sections, ['LOC by scope', 'All 1 bundles, gzip, and raw sizes']); + assert.ok(md.includes('| bundle | brotli | Δ brotli | change |'), 'legacy table header'); + assert.ok(!md.includes('| from |'), 'no from column for traceless snapshots'); + // and pin the alert block verbatim so verdict drift for old snapshots is loud + const alert = md.split('\n').filter((l) => l.startsWith('> ')).join('\n'); + assert.equal( + alert, + '> [!WARNING]\n> `component` grew **+400 B** brotli to 48.8 KB (+0.8%) across **+0 shipped LOC**.', + ); +}); + +test('a one-sided trace failure degrades to no section, never a mass surface diff', () => { + const head = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + const base = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + base.exports = { a: exp(100), b: exp(200), c: exp(300) }; + base.modules = { 'utils/a.js': 500 }; + const { md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + assert.ok(!md.includes('Tracked import costs'), 'no tracked section from a head-side trace failure'); + assert.ok(!md.includes('removed'), 'no false removed rows'); +}); + +test('exports named after Object.prototype members diff correctly', () => { + const head = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + const base = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + head.exports = { keep: exp(100, 'k') }; + base.exports = { keep: exp(100, 'k'), toString: exp(800, 'proto') }; + const { json, md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + const metric = json.metrics.find((m) => m.id === 'pkg-utils'); + assert.equal(metric.exportDeltas.removed[0].name, 'toString'); + assert.ok(md.includes('| `utils` | `toString` | — | removed |'), 'removed row rendered'); +}); + +test('poisoned byte values in a snapshot never reach the comment', () => { + const head = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + const base = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + head.exports = { real: exp(400, 'r'), evil: { cost: '](http://evil) ', graph: 'r' } }; + base.exports = { real: exp(100, 'r'), evil: exp(50, 'r') }; + const { md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + assert.ok(!md.includes('evil)'), 'crafted string filtered before rendering'); + assert.ok(!md.includes(' { + const head = tgt('pkg-query', [39000, 44000, 130000], { treeShaken: true }); + const base = tgt('pkg-query', [39000, 44000, 130000], { treeShaken: true }); + // six exports, one graph — the engine moved +1450 for all of them + for ( + const [name, cost] of [ + ['$', 40190], + ['$$', 40190], + ['Query', 40195], + ['useAlias', 40260], + ['exportGlobals', 40300], + ['restoreGlobals', 40310], + ] + ) { + head.exports = head.exports ?? {}; + base.exports = base.exports ?? {}; + head.exports[name] = exp(cost, 'engine'); + base.exports[name] = exp(cost - 1450, 'engine'); + } + const { json, md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + const metric = json.metrics.find((m) => m.id === 'pkg-query'); + assert.equal(metric.exportDeltas.changed.length, 1, 'one row per group'); + assert.equal(metric.exportDeltas.changed[0].name, '$', 'cheapest-shortest member is master'); + assert.equal(metric.exportDeltas.changed[0].groupSize, 6); + assert.ok(md.includes('| `query` | `$` |'), 'master rendered'); + assert.ok(!md.includes('$$'), 'slaves never rendered'); + assert.ok(md.includes('(+4%)'), 'percent shown on the delta'); +}); + +test('a removed group renders once through its base master', () => { + const head = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + const base = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + head.exports = { stay: exp(100, 'k') }; + base.exports = { stay: exp(100, 'k'), gone: exp(300, 'dead'), goneAlias: exp(305, 'dead') }; + const { json, md } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + const metric = json.metrics.find((m) => m.id === 'pkg-utils'); + assert.equal(metric.exportDeltas.removed.length, 1, 'one removed row for the group'); + assert.equal(metric.exportDeltas.removed[0].name, 'gone'); + assert.ok(!md.includes('goneAlias'), 'removed slave not listed'); +}); + +test('re-mastering anchors the delta on a both-sides member, not the rename', () => { + const head = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + const base = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + // aa is new and cheapest (becomes master), bb existed both sides and did not move + head.exports = { aa: exp(90, 'fam'), bb: exp(100, 'fam') }; + base.exports = { bb: exp(100, 'fam') }; + const { json } = run(snapshot([head], loc({})), snapshot([base], loc({}))); + const metric = json.metrics.find((m) => m.id === 'pkg-utils'); + assert.equal(metric.exportDeltas, null, 'no false growth from a new cheaper member'); +}); + +test('hostile export and module names render inert', () => { + const head = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + const base = tgt('pkg-utils', [17000, 19000, 50000], { treeShaken: true }); + head.exports = { 'evil`|name': exp(5000, 'e') }; + base.exports = { 'evil`|name': exp(100, 'e') }; + head.modules = { 'bad`|[x](y).js': 900 }; + base.modules = { 'bad`|[x](y).js': 100 }; + const headB = tgt('pkg-b', [50000, 56000, 170000]); + const baseB = tgt('pkg-b', [49000, 55000, 168000]); + headB.modules = { 'bad`|[x](y).js': 2000 }; + baseB.modules = { 'bad`|[x](y).js': 100 }; + const { md } = run(snapshot([head, headB], loc({})), snapshot([base, baseB], loc({}))); + assert.ok(!md.includes('evil`|name'), 'backtick and pipe stripped from export name'); + assert.ok(md.includes('evilname'), 'export still listed'); + assert.ok(!md.includes('bad`|'), 'module key stripped of code-span breakers'); +}); diff --git a/tools/ci/size/targets.js b/tools/ci/size/targets.js index 011a670b5..e3e3e7fc5 100644 --- a/tools/ci/size/targets.js +++ b/tools/ci/size/targets.js @@ -33,6 +33,22 @@ import path from 'node:path'; // real signal, so they stay in. const TREE_SHAKEN = new Set(['utils']); +// Packages whose per-export import cost is traced. Consumption model per the +// 2026-07-02 study (ai/workspace measured every export's module graph): +// - utils: 139 exports over 39 distinct graphs — maximally piecemeal, every +// mover is signal. Clusters are module families (type guards, casing, +// coercion), so grouped rendering collapses a family to one row. +// - reactivity: 22 exports over 13 graphs — piecemeal with a heavy shared +// core (computed/derive/match co-move at ~15 KB, signal at ~14 KB). +// - query: 7 exports over 2 graphs — one engine, two doors. $ carries $$, +// Query, and the global helpers; registerBehavior adds the behavior stack. +// Every export is priced; the reporter renders one row per co-movement group, +// named by its master. Framework internals (renderer, templating, component) +// are consumed whole — bundle rows and the `from` column already cover them, +// so they are never export-traced. Future piecemeal packages (data, schema, +// time) join this list with their consumption model noted. +export const TRACED_PACKAGES = new Set(['utils', 'reactivity', 'query']); + // org-stripped, lowercased package name — matches the build's output filename // rule (internal-packages/scripts/src/lib/build.js). function bundleName(pkgName) { @@ -56,6 +72,7 @@ function discoverPackages(repoRoot) { label: name, group: 'package', scope: name, + dir: entry, file: `packages/${entry}/dist/bundle/${name}.min.js`, headline: name === 'component', treeShaken: TREE_SHAKEN.has(name), diff --git a/tools/ci/size/trace.js b/tools/ci/size/trace.js new file mode 100644 index 000000000..4049caf60 --- /dev/null +++ b/tools/ci/size/trace.js @@ -0,0 +1,121 @@ +/* + Source tracing for the size bot, built on esbuild's metafile. Static — no PR + code is executed, esbuild parses only. The harness builds these itself so the + numbers come from the main-pinned harness on both sides of the diff. + + Two instruments, both rendered sparsely (SNR is the reporter's contract): + - bundleModules: per-module minified bytes inside a package bundle. The + reporter reduces this to one `from` cell on a changed bundle — where the + delta came from, never a table of everything. + - exportCosts: the minified cost of importing each export alone, plus its + module-graph fingerprint. Exports with the same fingerprint co-move by + construction, so the reporter renders one row per group, named by its + master — a single export can grow 40% while the whole-package bundle + barely wiggles, and that is the row this exists to show. +*/ +import fs from 'node:fs'; +import path from 'node:path'; + +import * as esbuild from 'esbuild'; + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const CONCURRENCY = 8; + +export function packageInfo(repoRoot, packageDir) { + const packagePath = path.join(repoRoot, 'packages', packageDir, 'package.json'); + if (!fs.existsSync(packagePath)) { return null; } + const packageFile = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + const entryRelative = packageFile.module ?? packageFile.main ?? 'src/index.js'; + const entry = path.join(repoRoot, 'packages', packageDir, entryRelative); + if (!fs.existsSync(entry)) { return null; } + // third-party dependencies stay external like the package build. workspace siblings bundle in, + // matching the shipped composition — cross-package retention (component growing because of + // utils) is the attribution case that matters most + const external = Object.keys({ + ...packageFile.dependencies, + ...packageFile.peerDependencies, + }).filter((dep) => !isWorkspacePackage(repoRoot, dep)); + return { entry, external, name: packageFile.name }; +} + +function isWorkspacePackage(repoRoot, dep) { + const dir = dep.replace(/^@[^/]+\//, ''); + return fs.existsSync(path.join(repoRoot, 'packages', dir, 'package.json')); +} + +async function build(repoRoot, options) { + return esbuild.build({ + bundle: true, + minify: true, + format: 'esm', + write: false, + metafile: true, + logLevel: 'silent', + absWorkingDir: path.resolve(repoRoot), + ...options, + }); +} + +// 'packages/utils/src/strings.js' / 'node_modules/@semantic-ui/utils/src/strings.js' -> 'utils/strings.js' +export function moduleKey(inputPath) { + const normalized = inputPath.replace(/\\/g, '/'); + const viaPackages = /^packages\/([^/]+)\/src\/(.+)$/.exec(normalized); + if (viaPackages) { return `${viaPackages[1]}/${viaPackages[2]}`; } + const viaNodeModules = /^node_modules\/@[^/]+\/([^/]+)\/src\/(.+)$/.exec(normalized); + if (viaNodeModules) { return `${viaNodeModules[1]}/${viaNodeModules[2]}`; } + return normalized; +} + +export async function bundleModules(repoRoot, { entry, external }) { + const result = await build(repoRoot, { entryPoints: [entry], external }); + const output = Object.values(result.metafile.outputs)[0]; + const modules = {}; + for (const [inputPath, input] of Object.entries(output.inputs)) { + const key = moduleKey(inputPath); + modules[key] = (modules[key] ?? 0) + input.bytesInOutput; + } + return modules; +} + +export async function listExports(repoRoot, { entry, external }) { + const result = await build(repoRoot, { entryPoints: [entry], external, minify: false }); + const output = Object.values(result.metafile.outputs)[0]; + return (output.exports ?? []).filter((name) => IDENTIFIER.test(name)).sort(); +} + +export async function exportCosts(repoRoot, { entry, external }, names) { + const costs = {}; + for (let i = 0; i < names.length; i += CONCURRENCY) { + const chunk = names.slice(i, i + CONCURRENCY); + const results = await Promise.all(chunk.map(async (name) => { + try { + // alias the binding so reserved-word export names (default, new) import legally + const result = await build(repoRoot, { + external, + stdin: { + contents: `import { ${name} as probe } from ${JSON.stringify(entry)}; console.log(probe);`, + resolveDir: repoRoot, + loader: 'js', + }, + }); + const output = Object.values(result.metafile.outputs)[0]; + // the fingerprint: exports sharing a graph co-move, the reporter groups on it + const graph = Object.keys(output.inputs) + .filter((input) => !input.startsWith('<')) + .map(moduleKey) + .sort() + .join('|'); + return [name, { cost: result.outputFiles[0].contents.length, graph }]; + } + catch { + // an unpriceable export (removed, renamed) reads as absent — the reporter + // renders a group that lost its last member as removed + return null; + } + })); + for (const priced of results) { + if (priced) { costs[priced[0]] = priced[1]; } + } + } + return costs; +} diff --git a/tools/ci/size/trace.test.js b/tools/ci/size/trace.test.js new file mode 100644 index 000000000..4a4d517d3 --- /dev/null +++ b/tools/ci/size/trace.test.js @@ -0,0 +1,102 @@ +/* + Unit tests for trace.js. Run with: + node --test tools/ci/size/trace.test.js + + The fixture package replicates the retention physics the tracer exists to + catch: `leaky` attaches its config through a bare property assignment (a + top-level side effect bundlers must keep), `clean` attaches through a + pure-annotated call. Importing `small` must pay for leaky's vocabulary and + not for clean's — if that ever stops being true, the instrument is broken. +*/ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { bundleModules, exportCosts, listExports, moduleKey, packageInfo } from './trace.js'; + +function makeFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'size-trace-')); + const src = path.join(root, 'packages', 'demo', 'src'); + fs.mkdirSync(src, { recursive: true }); + fs.writeFileSync( + path.join(root, 'packages', 'demo', 'package.json'), + JSON.stringify({ name: '@test/demo', module: 'src/index.js', dependencies: { lit: '1.0.0' } }), + ); + fs.writeFileSync( + path.join(src, 'index.js'), + [ + "export * from './small.js';", + "export * from './vocab.js';", + ].join('\n'), + ); + fs.writeFileSync(path.join(src, 'small.js'), 'export const small = (x) => x + 1;\n'); + fs.writeFileSync( + path.join(src, 'vocab.js'), + [ + 'const attach = (fn, config) => Object.assign(fn, { config });', + "export const leaky = attach((x) => leaky.config.words[x], { words: { alpha: 'an', beta: 'a' } });", + 'leaky.config.extra = true;', + "export const clean = /* @__PURE__ */ attach((x) => clean.config.words[x], { words: { gamma: 'an' } });", + ].join('\n'), + ); + return { root, info: packageInfo(root, 'demo') }; +} + +test('packageInfo resolves the entry and externals from package.json', () => { + const { root, info } = makeFixture(); + assert.equal(info.entry, path.join(root, 'packages', 'demo', 'src', 'index.js')); + assert.deepEqual(info.external, ['lit']); + assert.equal(packageInfo(root, 'missing'), null); +}); + +test('moduleKey normalizes workspace and node_modules paths to package-relative', () => { + assert.equal(moduleKey('packages/utils/src/strings.js'), 'utils/strings.js'); + assert.equal(moduleKey('node_modules/@semantic-ui/utils/src/coercion.js'), 'utils/coercion.js'); + assert.equal(moduleKey('tools/whatever.js'), 'tools/whatever.js'); +}); + +test('bundleModules attributes minified bytes per source module', async () => { + const { root, info } = makeFixture(); + const modules = await bundleModules(root, info); + assert.ok(modules['demo/small.js'] > 0); + assert.ok(modules['demo/vocab.js'] > modules['demo/small.js']); +}); + +test('exportCosts expose a retention leak as a cost jump on an unrelated export', async () => { + const { root, info } = makeFixture(); + const leakyCosts = await exportCosts(root, info, ['small']); + + // fix the leak: pure-annotate the attachment and drop the bare property write + fs.writeFileSync( + path.join(root, 'packages', 'demo', 'src', 'vocab.js'), + [ + 'const attach = (fn, config) => Object.assign(fn, { config });', + "export const leaky = /* @__PURE__ */ attach((x) => leaky.config.words[x], { words: { alpha: 'an', beta: 'a' } });", + "export const clean = /* @__PURE__ */ attach((x) => clean.config.words[x], { words: { gamma: 'an' } });", + ].join('\n'), + ); + const fixedCosts = await exportCosts(root, info, ['small']); + + // the diff a PR comment would show: small's import cost drops when the leak is fixed + assert.ok( + leakyCosts.small.cost > fixedCosts.small.cost, + `leaky ${leakyCosts.small.cost} B should exceed fixed ${fixedCosts.small.cost} B`, + ); + // the fingerprint names the graph so co-movers can be grouped + assert.ok(leakyCosts.small.graph.includes('demo/small.js'), 'graph carries module keys'); +}); + +test('reserved-word export names price via aliased imports without darkening the package', async () => { + const { root, info } = makeFixture(); + fs.appendFileSync( + path.join(root, 'packages', 'demo', 'src', 'index.js'), + "\nexport default function fallback() { return 'd'; }\n", + ); + const names = await listExports(root, info); + assert.ok(names.includes('default'), 'default enumerated'); + const costs = await exportCosts(root, info, names); + assert.ok(costs.default.cost > 0, 'default priced through the alias form'); + assert.ok(costs.small.cost > 0, 'sibling exports still priced'); +});