From 065f80d7a6678aed84c5babcd4b181eb71beb0dc Mon Sep 17 00:00:00 2001 From: StyleProof Test Date: Sun, 5 Jul 2026 20:28:20 +0100 Subject: [PATCH] feat(report): bound report.md so GitHub can always render it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A large redesign produced a report.md too big for GitHub's markdown viewer (it refuses past ~512 KB) — the reviewer clicked through and got "we can't show files this big", so the report was useless exactly when the change was biggest (seen on a 218-surface nav rebuild: 4.7 MB, 133k table rows). The generator now holds report.md to a byte budget (maxReportBytes, default ~400 KB): full property tables greedily, then remaining changed surfaces as one-liners (name · change count · crop link) under an announced banner. The exhaustive per-row detail is always in report.json and every crop in crops/, so nothing is dropped from the certification — only the inline detail is capped. Verified: a 40-surface synthetic that renders 139 KB uncapped comes out 17 KB under a 15 KB budget with all 40 surfaces still in report.json; small reports (the demo) are byte-identical. Regression test in report.test.mjs. On by default. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 14 ++++++++++ package.json | 2 +- src/report.ts | 61 ++++++++++++++++++++++++++++++++++++++++++-- test/report.test.mjs | 58 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 461f5cb..4d277f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [3.17.0] - 2026-07-05 + +### Changed + +- **`report.md` is bounded so GitHub can always render it.** A large redesign used to + produce a `report.md` too big for GitHub's markdown viewer (it refuses to render past + ~512 KB) — the reviewer clicked through and got _"we can't show files this big"_, so the + report was useless exactly when the change was biggest. The generator now holds + `report.md` to a byte budget (`maxReportBytes`, default ~400 KB): it emits full property + tables greedily, then lists any remaining changed surfaces as one-liners (name · change + count · crop link) under an announced banner. The exhaustive per-row detail is always in + `report.json` and every crop in `crops/`, so nothing is dropped from the certification — + only the inline detail is capped. A report a reviewer can't open isn't a source of truth. + ### Added - **`affectedSurfaces` — selective-remap core (opt-in, advisory).** Given the files a diff --git a/package.json b/package.json index b03b364..1a6c8a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "styleproof", - "version": "3.16.0", + "version": "3.17.0", "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.", "keywords": [ "playwright", diff --git a/src/report.ts b/src/report.ts index 32114e2..20eeecd 100644 --- a/src/report.ts +++ b/src/report.ts @@ -91,6 +91,15 @@ export type ReportOptions = { * cause) for the reviewer's eye. */ includeContent?: boolean; + /** + * Byte ceiling for report.md so GitHub can always render it (its markdown viewer + * refuses to render files past ~512 KB). Once the accumulated report would exceed + * this, the remaining changed surfaces are listed as one-liners (name · change + * count · crop link) instead of full property tables — the exhaustive per-row + * detail is always kept in report.json and every crop in crops/, so nothing is + * lost, just relocated. Default 400_000 (~0.4 MB). Set to Infinity to never cap. + */ + maxReportBytes?: number; }; export type ReportResult = { @@ -1495,6 +1504,33 @@ function renderNewSurface( return { md, json, cropSeq }; } +/** The one-time banner where report.md switches from full detail to one-line + * summaries, so the reader knows nothing is missing — only relocated to report.json. */ +function cappedNoticeLines(budget: number): string[] { + return [ + '', + '## … more changed surfaces (summarized to keep this report renderable)', + '', + `_This report reached its ~${Math.round(budget / 1000)} KB display budget (GitHub does not render ` + + `markdown past ~512 KB), so the surfaces below are listed as one-liners. Their full property ` + + `tables are in \`report.json\` and their crops in \`crops/\` — the certification above covers every ` + + `surface; only the inline detail is capped._`, + '', + ]; +} + +/** One-line summary for a changed surface whose full detail was budget-capped: its + * name (and how many surfaces share the identical change) · change count · a crop + * link so the reviewer can still see it without opening report.json. */ +function compactChangeSummary(cg: ChangeGroup, json: Record, img: (rel: string) => string): string { + const surface = cg.rep.sd.surface; + const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : ''; + const regions = (json.regions as Array<{ images?: { composite?: string } }> | undefined) ?? []; + const composite = regions[0]?.images?.composite; + const link = composite ? ` — [crop](${img(composite)})` : ''; + return `- \`${surface}\`${more} · ${cg.rep.findings.length} change(s)${link}`; +} + export function generateStyleMapReport(opts: ReportOptions): ReportResult { const { beforeDir, @@ -1512,6 +1548,7 @@ export function generateStyleMapReport(opts: ReportOptions): ReportResult { // own focused frame rather than one wide merged one. maxCrops = 8, foldDetailsAt = 0, + maxReportBytes = 400_000, } = opts; const includeNoise = opts.includeLayoutNoise ?? false; @@ -1579,18 +1616,38 @@ export function generateStyleMapReport(opts: ReportOptions): ReportResult { let totalFindings = 0; let cropSeq = 0; + // report.md must stay renderable — GitHub refuses to render markdown past ~512 KB. + // Emit full detail greedily until the byte budget is reached, then list any remaining + // surfaces as one-liners. The exhaustive per-row detail is always in report.json and + // every crop in crops/, so the cap changes what's shown inline, never what's certified. + let reportBytes = md.join('\n').length; + let capped = false; + const emitDetail = (detail: string[], summary: string): void => { + const cost = detail.join('\n').length + 1; + if (!capped && reportBytes + cost <= maxReportBytes) { + md.push(...detail); + reportBytes += cost; + return; + } + if (!capped) { + md.push(...cappedNoticeLines(maxReportBytes)); + capped = true; + } + md.push(summary); + reportBytes += summary.length + 1; + }; for (const cg of changeGroups) { const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq); - md.push(...r.md); json.push(r.json); totalFindings += r.findingCount; cropSeq = r.cropSeq; + emitDetail(r.md, compactChangeSummary(cg, r.json, img)); } for (const p of missing) { const r = renderNewSurface(p, ctx, cropSeq); - md.push(...r.md); json.push(r.json); cropSeq = r.cropSeq; + emitDetail(r.md, `- \`${p.sd.surface}\` · new surface`); } md.push(...contentSection.md); diff --git a/test/report.test.mjs b/test/report.test.mjs index 4430759..1773eb2 100644 --- a/test/report.test.mjs +++ b/test/report.test.mjs @@ -1038,3 +1038,61 @@ test('end-to-end: annotation boxes the changed CHILD, not its changed container' assert.ok(hilite < 1500, `highlight footprint ${hilite} should be child-sized, not container-sized`); rmTmp(root); }); + +// ---------------------------------------- bounded report (always GitHub-renderable) + +test('report.md stays under its byte budget (GitHub-renderable); report.json keeps every surface', () => { + const { beforeDir, afterDir, outDir, root } = tmpDirs(); + const N = 40; + const M = 15; + // Each surface carries a DISTINCT change (values keyed by surface index) so they + // don't collapse into one "identical across N surfaces" group — N real detail blocks. + const surfaceMap = (s, shift) => + makeMap({ + elements: Object.fromEntries( + Array.from({ length: M }, (_, k) => [ + `body > div:nth-child(${k + 1})`, + { + tag: 'div', + cls: `s${s}-c${k}`, + rect: [10, 20 + k * 30, 200, 24], + style: { + color: `rgb(${(shift + s) % 256}, ${k}, 0)`, + 'padding-top': `${10 + shift + s}px`, + 'font-size': `${12 + k}px`, + 'margin-top': `${4 + shift}px`, + }, + }, + ]), + ), + }); + for (let s = 0; s < N; s++) { + const surface = `surface-${s}@1280`; + writeCapture(beforeDir, surface, surfaceMap(s, 0), solidPng(1280, 800)); + writeCapture(afterDir, surface, surfaceMap(s, 5), solidPng(1280, 800)); + } + + const budget = 15_000; + const res = generateStyleMapReport({ beforeDir, afterDir, outDir, maxReportBytes: budget }); + const md = fs.readFileSync(res.reportMdPath, 'utf8'); + const json = JSON.parse(fs.readFileSync(res.reportJsonPath, 'utf8')); + + assert.ok(md.length < budget * 2, `report.md must stay bounded near the budget (was ${md.length})`); + assert.match(md, /summarized to keep this report renderable/, 'the cap is announced, not silent'); + assert.match(md, /· \d+ change\(s\)/, 'capped surfaces appear as one-line summaries'); + assert.equal(json.surfaces.length, N, 'report.json keeps every surface — the cap relocates detail, never drops it'); + + // The cap must actually shrink a large report (uncapped is far bigger). + const full = generateStyleMapReport({ + beforeDir, + afterDir, + outDir: path.join(root, 'out2'), + maxReportBytes: Infinity, + }); + const mdFull = fs.readFileSync(full.reportMdPath, 'utf8'); + assert.ok( + mdFull.length > md.length * 2, + `uncapped report should be far larger (capped ${md.length}, full ${mdFull.length})`, + ); + rmTmp(root); +});