styleproof 3.20.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +192 -0
- package/README.md +437 -321
- package/bin/styleproof-capture.mjs +4 -0
- package/bin/styleproof-diff.mjs +192 -27
- package/bin/styleproof-init.mjs +75 -7
- package/bin/styleproof-map.mjs +23 -9
- package/bin/styleproof-report.mjs +6 -0
- package/dist/capture-url.js +5 -0
- package/dist/capture.d.ts +59 -0
- package/dist/capture.js +127 -5
- package/dist/change-groups.d.ts +91 -0
- package/dist/change-groups.js +471 -0
- package/dist/coverage.d.ts +7 -0
- package/dist/data-residue.d.ts +70 -0
- package/dist/data-residue.js +105 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/map-store.d.ts +37 -1
- package/dist/map-store.js +103 -10
- package/dist/report.d.ts +1 -4
- package/dist/report.js +100 -353
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +63 -9
- package/example/styleproof-approve.yml +108 -0
- package/package.json +7 -4
package/dist/report.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
|
-
import { loadStyleMap, readInventories, } from './capture.js';
|
|
4
|
+
import { loadStyleMap, readInventories, readResidue, surfaceElementPaths, } from './capture.js';
|
|
5
5
|
import { isMapFile } from './map-store.js';
|
|
6
6
|
import { fillRect } from './png-util.js';
|
|
7
7
|
import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
|
|
8
|
-
import { describeChange, tokenIndex, toHex
|
|
8
|
+
import { describeChange, tokenIndex, toHex } from './describe.js';
|
|
9
9
|
import { auditCoverage, auditDeterminism, COVERAGE_LEDGER, } from './coverage.js';
|
|
10
10
|
import { auditRunInventory, readAckFile } from './inventory.js';
|
|
11
|
+
import { auditRunResidue, readResidueAckFile } from './data-residue.js';
|
|
12
|
+
// The pure grouping / classification brain — shared with the CLI. report.ts keeps
|
|
13
|
+
// the crop-and-PNG rendering on top of these.
|
|
14
|
+
import { cleanFindings, groupByPath, groupTitle, isNonValue, prettyLabel, safeKey, signatureOf, summarizeProps, surfaceBase, surfaceWidth, pushSurfaceWidth, renderSurfaceGroups, formatSurfaceList, classifyChrome, } from './change-groups.js';
|
|
11
15
|
// Re-export the plain-English summariser so consumers (and tests) reach it
|
|
12
16
|
// through the package's report module rather than a deep path.
|
|
13
17
|
export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
|
|
18
|
+
// Re-export the grouping primitives historically exported from here so existing
|
|
19
|
+
// imports (`from 'styleproof'` → report) keep resolving.
|
|
20
|
+
export { summarizeProps, prettyLabel } from './change-groups.js';
|
|
14
21
|
// Hidden marker appended to a new-surface heading. Invisible in rendered
|
|
15
22
|
// markdown; lets the PR-comment layer recognize one-sided surfaces.
|
|
16
23
|
const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
|
|
@@ -52,16 +59,6 @@ function changeCountLabel(shown) {
|
|
|
52
59
|
parts.push(`${shown.state} state-delta difference(s)`);
|
|
53
60
|
return parts.join(' · ');
|
|
54
61
|
}
|
|
55
|
-
/** Group findings by their element path (one group per changed element). */
|
|
56
|
-
function groupByPath(findings) {
|
|
57
|
-
const byPath = new Map();
|
|
58
|
-
for (const f of findings) {
|
|
59
|
-
const arr = byPath.get(f.path) ?? [];
|
|
60
|
-
arr.push(f);
|
|
61
|
-
byPath.set(f.path, arr);
|
|
62
|
-
}
|
|
63
|
-
return [...byPath.values()];
|
|
64
|
-
}
|
|
65
62
|
function groupRegions(paths, a, b, padBy) {
|
|
66
63
|
const groups = paths.map((p) => {
|
|
67
64
|
const ra = a.elements[p]?.rect;
|
|
@@ -197,224 +194,8 @@ function readPng(file) {
|
|
|
197
194
|
return null;
|
|
198
195
|
return PNG.sync.read(fs.readFileSync(file));
|
|
199
196
|
}
|
|
200
|
-
// --- readable findings: dedupe
|
|
201
|
-
//
|
|
202
|
-
// Logical longhand → its physical equivalent (LTR, horizontal-tb). Dropped when
|
|
203
|
-
// the physical one changed identically, so each change appears once.
|
|
204
|
-
const LOGICAL_TO_PHYSICAL = (() => {
|
|
205
|
-
const m = {};
|
|
206
|
-
const sides = {
|
|
207
|
-
'block-start': 'top',
|
|
208
|
-
'block-end': 'bottom',
|
|
209
|
-
'inline-start': 'left',
|
|
210
|
-
'inline-end': 'right',
|
|
211
|
-
};
|
|
212
|
-
for (const [l, p] of Object.entries(sides)) {
|
|
213
|
-
for (const k of ['color', 'width', 'style'])
|
|
214
|
-
m[`border-${l}-${k}`] = `border-${p}-${k}`;
|
|
215
|
-
m[`margin-${l}`] = `margin-${p}`;
|
|
216
|
-
m[`padding-${l}`] = `padding-${p}`;
|
|
217
|
-
m[`inset-${l}`] = p;
|
|
218
|
-
}
|
|
219
|
-
const radius = {
|
|
220
|
-
'start-start': 'top-left',
|
|
221
|
-
'start-end': 'top-right',
|
|
222
|
-
'end-start': 'bottom-left',
|
|
223
|
-
'end-end': 'bottom-right',
|
|
224
|
-
};
|
|
225
|
-
for (const [l, p] of Object.entries(radius))
|
|
226
|
-
m[`border-${l}-radius`] = `border-${p}-radius`;
|
|
227
|
-
return m;
|
|
228
|
-
})();
|
|
229
|
-
// Length-valued 4-side families → CSS 1–4-value shorthand whenever all four
|
|
230
|
-
// sides changed (e.g. `padding: 26px 24px → 28px`).
|
|
231
|
-
const BOX4 = [
|
|
232
|
-
{ short: 'margin', parts: ['top', 'right', 'bottom', 'left'].map((s) => `margin-${s}`) },
|
|
233
|
-
{ short: 'padding', parts: ['top', 'right', 'bottom', 'left'].map((s) => `padding-${s}`) },
|
|
234
|
-
{ short: 'border-width', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-width`) },
|
|
235
|
-
{
|
|
236
|
-
short: 'border-radius',
|
|
237
|
-
parts: ['top-left', 'top-right', 'bottom-right', 'bottom-left'].map((s) => `border-${s}-radius`),
|
|
238
|
-
},
|
|
239
|
-
];
|
|
240
|
-
// Colour/keyword families → one row only when all four sides match (else
|
|
241
|
-
// per-side, which keeps each colour swatchable).
|
|
242
|
-
const UNIFORM = [
|
|
243
|
-
{ short: 'border-color', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-color`) },
|
|
244
|
-
{ short: 'border-style', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-style`) },
|
|
245
|
-
];
|
|
246
|
-
const boxShorthand = ([t, r, b, l]) => t === r && r === b && b === l
|
|
247
|
-
? t
|
|
248
|
-
: t === b && r === l
|
|
249
|
-
? `${t} ${r}`
|
|
250
|
-
: r === l
|
|
251
|
-
? `${t} ${r} ${b}`
|
|
252
|
-
: `${t} ${r} ${b} ${l}`;
|
|
253
|
-
const PROP_ORDER = [
|
|
254
|
-
'display',
|
|
255
|
-
'position',
|
|
256
|
-
'grid-template-columns',
|
|
257
|
-
'grid-template-rows',
|
|
258
|
-
'flex-direction',
|
|
259
|
-
'justify-content',
|
|
260
|
-
'align-items',
|
|
261
|
-
'gap',
|
|
262
|
-
'margin',
|
|
263
|
-
'padding',
|
|
264
|
-
'border-width',
|
|
265
|
-
'border-style',
|
|
266
|
-
'border-color',
|
|
267
|
-
'border-radius',
|
|
268
|
-
'outline',
|
|
269
|
-
'background-color',
|
|
270
|
-
'background-image',
|
|
271
|
-
'color',
|
|
272
|
-
'box-shadow',
|
|
273
|
-
'opacity',
|
|
274
|
-
'transform',
|
|
275
|
-
'font-family',
|
|
276
|
-
'font-size',
|
|
277
|
-
'font-weight',
|
|
278
|
-
'line-height',
|
|
279
|
-
'letter-spacing',
|
|
280
|
-
'text-transform',
|
|
281
|
-
'text-align',
|
|
282
|
-
];
|
|
283
|
-
const orderIdx = (p) => {
|
|
284
|
-
const i = PROP_ORDER.indexOf(p);
|
|
285
|
-
return i === -1 ? PROP_ORDER.length : i;
|
|
286
|
-
};
|
|
287
|
-
function cleanVal(v) {
|
|
288
|
-
// Verbatim by design: no rounding. Rounding once showed alpha 0.18 as 0.2 —
|
|
289
|
-
// and could erase a real 0.18→0.2 diff entirely via the no-op filter below.
|
|
290
|
-
let s = v;
|
|
291
|
-
if (!s.includes('(')) {
|
|
292
|
-
const toks = s.split(' ');
|
|
293
|
-
if (toks.length > 1 && new Set(toks).size === 1)
|
|
294
|
-
s = `${toks[0]} ×${toks.length}`;
|
|
295
|
-
}
|
|
296
|
-
return s.replace(/rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/g, 'transparent');
|
|
297
|
-
}
|
|
298
|
-
// These default to `currentColor`, so a `color` change drags them all along —
|
|
299
|
-
// pure echoes, dropped when they match the `color` change.
|
|
300
|
-
const CURRENTCOLOR_FOLLOWERS = [
|
|
301
|
-
'caret-color',
|
|
302
|
-
'outline-color',
|
|
303
|
-
'column-rule-color',
|
|
304
|
-
'text-decoration-color',
|
|
305
|
-
'text-emphasis-color',
|
|
306
|
-
'-webkit-text-fill-color',
|
|
307
|
-
'-webkit-text-stroke-color',
|
|
308
|
-
];
|
|
309
|
-
// "No value here" markers: a forced-state delta that doesn't apply, an unset
|
|
310
|
-
// longhand, or a capture artifact where a path didn't line up. A change BETWEEN
|
|
311
|
-
// two of these (e.g. `— → (gone)`) is meaningless and must never read as a diff.
|
|
312
|
-
const NON_VALUE = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)', '(gone)']);
|
|
313
|
-
const isNonValue = (v) => NON_VALUE.has(v);
|
|
314
|
-
/** Combine longhands into a shorthand value; all-non-value sides collapse to one. */
|
|
315
|
-
const combineValues = (vals) => (vals.every(isNonValue) ? '(unset)' : vals.join(' '));
|
|
316
|
-
export function summarizeProps(props) {
|
|
317
|
-
const map = new Map(props.map((p) => [p.prop, { ...p }]));
|
|
318
|
-
for (const [logical, physical] of Object.entries(LOGICAL_TO_PHYSICAL)) {
|
|
319
|
-
const lo = map.get(logical);
|
|
320
|
-
const ph = map.get(physical);
|
|
321
|
-
if (lo && ph && lo.before === ph.before && lo.after === ph.after)
|
|
322
|
-
map.delete(logical);
|
|
323
|
-
}
|
|
324
|
-
const color = map.get('color');
|
|
325
|
-
if (color)
|
|
326
|
-
for (const f of CURRENTCOLOR_FOLLOWERS) {
|
|
327
|
-
const m = map.get(f);
|
|
328
|
-
if (m && m.before === color.before && m.after === color.after)
|
|
329
|
-
map.delete(f);
|
|
330
|
-
}
|
|
331
|
-
for (const fam of BOX4) {
|
|
332
|
-
const members = fam.parts.map((p) => map.get(p));
|
|
333
|
-
if (members.every((m) => !!m)) {
|
|
334
|
-
fam.parts.forEach((p) => map.delete(p));
|
|
335
|
-
map.set(fam.short, {
|
|
336
|
-
prop: fam.short,
|
|
337
|
-
before: boxShorthand(members.map((m) => m.before)),
|
|
338
|
-
after: boxShorthand(members.map((m) => m.after)),
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
const rg = map.get('row-gap');
|
|
343
|
-
const cg = map.get('column-gap');
|
|
344
|
-
if (rg && cg) {
|
|
345
|
-
map.delete('row-gap');
|
|
346
|
-
map.delete('column-gap');
|
|
347
|
-
map.set('gap', {
|
|
348
|
-
prop: 'gap',
|
|
349
|
-
before: rg.before === cg.before ? rg.before : `${rg.before} ${cg.before}`,
|
|
350
|
-
after: rg.after === cg.after ? rg.after : `${rg.after} ${cg.after}`,
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
for (const fam of UNIFORM) {
|
|
354
|
-
const members = fam.parts.map((p) => map.get(p)).filter((m) => !!m);
|
|
355
|
-
if (members.length === fam.parts.length &&
|
|
356
|
-
members.every((m) => m.before === members[0].before && m.after === members[0].after)) {
|
|
357
|
-
fam.parts.forEach((p) => map.delete(p));
|
|
358
|
-
map.set(fam.short, { prop: fam.short, before: members[0].before, after: members[0].after });
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
// outline-width/-style/-color → one `outline` row (offset stays separate).
|
|
362
|
-
const ow = map.get('outline-width');
|
|
363
|
-
const os = map.get('outline-style');
|
|
364
|
-
const oc = map.get('outline-color');
|
|
365
|
-
if (ow && os && oc) {
|
|
366
|
-
map.delete('outline-width');
|
|
367
|
-
map.delete('outline-style');
|
|
368
|
-
map.delete('outline-color');
|
|
369
|
-
map.set('outline', {
|
|
370
|
-
prop: 'outline',
|
|
371
|
-
before: combineValues([ow.before, os.before, oc.before]),
|
|
372
|
-
after: combineValues([ow.after, os.after, oc.after]),
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
return ([...map.values()]
|
|
376
|
-
.map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
|
|
377
|
-
// Drop no-ops: a value that didn't actually change, or a change between two
|
|
378
|
-
// "no value here" markers (`— → (gone)`), which carries no information.
|
|
379
|
-
.filter((p) => p.before !== p.after && !(isNonValue(p.before) && isNonValue(p.after)))
|
|
380
|
-
.sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop)));
|
|
381
|
-
}
|
|
382
|
-
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
383
|
-
export function prettyLabel(p, cls) {
|
|
384
|
-
const tag = (p.split('>').pop() ?? '').trim().replace(/:nth-child\(\d+\)/, '') || 'el';
|
|
385
|
-
const first = cls.split(/\s+/)[0] ?? '';
|
|
386
|
-
return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
|
|
387
|
-
}
|
|
388
|
-
// Surface keys originate from artifact filenames — attacker-controlled in the
|
|
389
|
-
// fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
|
|
390
|
-
// (the Action slices report.md above the first `### `). Strip the Markdown/HTML
|
|
391
|
-
// control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
|
|
392
|
-
// or table into that bot comment. Escaping at the render boundary — the keys stay
|
|
393
|
-
// legible; only the injection surface is removed. (Crop FILENAMES are separately
|
|
394
|
-
// restricted to [a-z0-9-]; this is the display-side equivalent.)
|
|
395
|
-
const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
|
|
396
|
-
const surfaceBase = (s) => s.replace(/@\d+$/, '');
|
|
397
|
-
const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
|
|
398
|
-
function pushSurfaceWidth(byBase, base, surface) {
|
|
399
|
-
const arr = byBase.get(base) ?? [];
|
|
400
|
-
arr.push(surfaceWidth(surface));
|
|
401
|
-
byBase.set(base, arr);
|
|
402
|
-
}
|
|
403
|
-
function renderSurfaceGroups(byBase) {
|
|
404
|
-
return [...byBase]
|
|
405
|
-
.map(([base, ws]) => {
|
|
406
|
-
const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
|
|
407
|
-
return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
|
|
408
|
-
})
|
|
409
|
-
.join(' · ');
|
|
410
|
-
}
|
|
411
|
-
/** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
|
|
412
|
-
function formatSurfaceList(surfaces) {
|
|
413
|
-
const byBase = new Map();
|
|
414
|
-
for (const s of surfaces)
|
|
415
|
-
pushSurfaceWidth(byBase, surfaceBase(s), s);
|
|
416
|
-
return renderSurfaceGroups(byBase);
|
|
417
|
-
}
|
|
197
|
+
// --- readable findings: the dedupe/summarise/label brain lives in
|
|
198
|
+
// change-groups.ts (shared with the CLI). report.ts renders crops on top. ---
|
|
418
199
|
function surfaceContext(...maps) {
|
|
419
200
|
const metadata = maps.find((m) => m?.metadata)?.metadata;
|
|
420
201
|
if (!metadata?.variantKey)
|
|
@@ -457,47 +238,6 @@ function collectLiveCandidateLabels(beforeDir, afterDir) {
|
|
|
457
238
|
}
|
|
458
239
|
return [...seen].sort();
|
|
459
240
|
}
|
|
460
|
-
// Grid-track longhands compute to width-dependent pixels (`282px ×2` at one width,
|
|
461
|
-
// `282px 228px` at another), so the SAME responsive change would otherwise get a
|
|
462
|
-
// different signature per width. Key them by track COUNT — what actually
|
|
463
|
-
// identifies the change — so responsive variants group into one section.
|
|
464
|
-
function sigValue(c) {
|
|
465
|
-
if (c.prop === 'grid-template-columns' || c.prop === 'grid-template-rows') {
|
|
466
|
-
return `${c.prop}=${trackCount(c.before)}t>${trackCount(c.after)}t`;
|
|
467
|
-
}
|
|
468
|
-
return `${c.prop}=${c.before}>${c.after}`;
|
|
469
|
-
}
|
|
470
|
-
/** Canonical signature of a surface's findings: surfaces that changed in the
|
|
471
|
-
* same way collapse into one section + one image (the rects differ per width;
|
|
472
|
-
* the change itself does not). */
|
|
473
|
-
function signatureOf(findings) {
|
|
474
|
-
return JSON.stringify(findings
|
|
475
|
-
.map((f) => ({
|
|
476
|
-
p: f.path,
|
|
477
|
-
k: f.kind,
|
|
478
|
-
t: f.kind === 'dom' ? f.change : f.kind === 'state' ? f.state : (f.pseudo ?? ''),
|
|
479
|
-
v: f.kind === 'dom' ? '' : summarizeProps(f.props).map(sigValue).join('|'),
|
|
480
|
-
}))
|
|
481
|
-
.sort((a, b) => `${a.p}|${a.k}|${a.t}`.localeCompare(`${b.p}|${b.k}|${b.t}`)));
|
|
482
|
-
}
|
|
483
|
-
/** A one-line heading for a change group: "1 element added", "2 elements restyled". */
|
|
484
|
-
function groupTitle(findings) {
|
|
485
|
-
const added = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'added').map((f) => f.path));
|
|
486
|
-
const removed = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'removed').map((f) => f.path));
|
|
487
|
-
const retagged = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'retagged').map((f) => f.path));
|
|
488
|
-
const restyled = new Set(findings.filter((f) => f.kind !== 'dom' && !added.has(f.path) && !removed.has(f.path)).map((f) => f.path));
|
|
489
|
-
const n = (c, w) => `${c} ${w}${c === 1 ? '' : 's'}`;
|
|
490
|
-
const parts = [];
|
|
491
|
-
if (added.size)
|
|
492
|
-
parts.push(`${n(added.size, 'element')} added`);
|
|
493
|
-
if (removed.size)
|
|
494
|
-
parts.push(`${n(removed.size, 'element')} removed`);
|
|
495
|
-
if (retagged.size)
|
|
496
|
-
parts.push(`${n(retagged.size, 'element')} retagged`);
|
|
497
|
-
if (restyled.size)
|
|
498
|
-
parts.push(`${n(restyled.size, 'element')} restyled`);
|
|
499
|
-
return parts.join(', ') || `${n(new Set(findings.map((f) => f.path)).size, 'element')} changed`;
|
|
500
|
-
}
|
|
501
241
|
/**
|
|
502
242
|
* A crop's heading: the element it's anchored on, then what happened inside it —
|
|
503
243
|
* `` `who-grid` · 5 elements restyled ``. Naming the anchor is what ties the
|
|
@@ -704,80 +444,6 @@ function renderCropChanges(findings, foldAt, ctx) {
|
|
|
704
444
|
const summary = bullets.length ? bullets.map((b) => `- ${b}`) : ['_see changes_'];
|
|
705
445
|
return ['', ...summary, '', '<details>', `<summary>${foldSummary(findings)}</summary>`, ...tables, '', '</details>'];
|
|
706
446
|
}
|
|
707
|
-
// Computed values that follow from an element's box size or position rather than
|
|
708
|
-
// its styling. On any reflow they change all the way up the ancestor chain
|
|
709
|
-
// (body, main, section…), so an element whose ONLY changes are these is a reflow
|
|
710
|
-
// casualty: it must not anchor a crop region (that would zoom to the whole page)
|
|
711
|
-
// nor clutter the findings. The certification differ keeps them — a reflow IS a
|
|
712
|
-
// change to certify — but the visual report focuses on styling intent.
|
|
713
|
-
const DERIVED_PROPS = new Set([
|
|
714
|
-
'width',
|
|
715
|
-
'height',
|
|
716
|
-
'block-size',
|
|
717
|
-
'inline-size',
|
|
718
|
-
'min-width',
|
|
719
|
-
'min-height',
|
|
720
|
-
'max-width',
|
|
721
|
-
'max-height',
|
|
722
|
-
'perspective-origin',
|
|
723
|
-
'transform-origin',
|
|
724
|
-
// position offsets shift with the document on any reflow
|
|
725
|
-
'top',
|
|
726
|
-
'right',
|
|
727
|
-
'bottom',
|
|
728
|
-
'left',
|
|
729
|
-
'inset-block-start',
|
|
730
|
-
'inset-block-end',
|
|
731
|
-
'inset-inline-start',
|
|
732
|
-
'inset-inline-end',
|
|
733
|
-
]);
|
|
734
|
-
// Props stripped from forced :hover/:focus/:active deltas specifically. Layout
|
|
735
|
-
// and grid-track values that shift when a state forces a relayout are capture
|
|
736
|
-
// noise, not interaction feedback — a state finding is meant to catch a changed
|
|
737
|
-
// hover/focus/active *style* (colour, outline, shadow), not a reflow.
|
|
738
|
-
const STATE_STRIP = new Set([
|
|
739
|
-
...DERIVED_PROPS,
|
|
740
|
-
'grid-template-columns',
|
|
741
|
-
'grid-template-rows',
|
|
742
|
-
'grid-template-areas',
|
|
743
|
-
'grid-auto-columns',
|
|
744
|
-
'grid-auto-rows',
|
|
745
|
-
'grid-auto-flow',
|
|
746
|
-
]);
|
|
747
|
-
/**
|
|
748
|
-
* Strip the noise the visual report shouldn't carry, cross-referencing each
|
|
749
|
-
* element's layers so the forced-state layer stops echoing the base:
|
|
750
|
-
* - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
|
|
751
|
-
* - forced states: drop derived + grid-track props, drop a delta the BASE
|
|
752
|
-
* already changed (a `:hover color` that just follows a recoloured base is an
|
|
753
|
-
* echo, not a dropped variant), and drop non-value↔non-value rows;
|
|
754
|
-
* - any finding left with no props is removed entirely.
|
|
755
|
-
*/
|
|
756
|
-
function cleanFindings(findings) {
|
|
757
|
-
const out = [];
|
|
758
|
-
for (const group of groupByPath(findings)) {
|
|
759
|
-
const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
|
|
760
|
-
const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
|
|
761
|
-
// For an ADDED element the base style is a full (unset)→value snapshot, not a
|
|
762
|
-
// delta — so a forced-state value is never an "echo" of a base *change*; keep
|
|
763
|
-
// every state row (suppressing them would drop a real :hover/:focus value).
|
|
764
|
-
const isAdded = group.some((f) => f.kind === 'dom' && f.change === 'added');
|
|
765
|
-
for (const f of group) {
|
|
766
|
-
if (f.kind === 'dom') {
|
|
767
|
-
out.push(f);
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
const props = f.kind === 'style'
|
|
771
|
-
? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
|
|
772
|
-
: f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
|
|
773
|
-
(isAdded || !baseChanged.has(p.prop)) &&
|
|
774
|
-
!(isNonValue(p.before) && isNonValue(p.after)));
|
|
775
|
-
if (props.length)
|
|
776
|
-
out.push({ ...f, props });
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
return out;
|
|
780
|
-
}
|
|
781
447
|
/** Per-element view for the plain-English summariser: the base deltas (summarised)
|
|
782
448
|
* plus which interactive states genuinely changed. */
|
|
783
449
|
function buildElementChanges(findings) {
|
|
@@ -923,13 +589,29 @@ function determinismLine(det) {
|
|
|
923
589
|
return `- **Determinism** — ✗ NOT proven (base ${det.base}, head ${det.head}) — a clean diff could be two nondeterministic reads`;
|
|
924
590
|
return '- **Determinism** — ⚠ unknown (a capture predates the determinism ledger)';
|
|
925
591
|
}
|
|
592
|
+
// Truncated, escaped, comma-joined key list — the same discipline for removals and
|
|
593
|
+
// additions, so neither can inject Markdown into the privileged PR-comment summary.
|
|
594
|
+
function keyList(items) {
|
|
595
|
+
const keys = items.map((i) => safeKey(i.key));
|
|
596
|
+
return `${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
|
|
597
|
+
}
|
|
598
|
+
// Additions never gate, but the report must not contradict the diff (which prints
|
|
599
|
+
// them) — so echo them as an informational, still-✓-class clause. Returns a leading
|
|
600
|
+
// `; …` fragment to append after whatever the removal side decided, or '' when none.
|
|
601
|
+
function additionsClause(added) {
|
|
602
|
+
if (added.length === 0)
|
|
603
|
+
return '';
|
|
604
|
+
return `; ${added.length} navigable affordance(s) added: ${keyList(added)} (additions don't gate)`;
|
|
605
|
+
}
|
|
926
606
|
function inventoryLine(inv) {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${
|
|
930
|
-
}
|
|
607
|
+
const added = additionsClause(inv.delta.added);
|
|
608
|
+
if (inv.unexplained.length > 0)
|
|
609
|
+
return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keyList(inv.unexplained)}${added}`;
|
|
931
610
|
if (inv.delta.removed.length > 0)
|
|
932
|
-
return `- **Inventory** — ✓ ${inv.delta.removed.length} removal(s), all acknowledged`;
|
|
611
|
+
return `- **Inventory** — ✓ ${inv.delta.removed.length} removal(s), all acknowledged${added}`;
|
|
612
|
+
// Addition-only: drop the leading `; ` so the clause reads as the whole ✓ line.
|
|
613
|
+
if (added)
|
|
614
|
+
return `- **Inventory** — ✓${added.slice(1)}`;
|
|
933
615
|
return '- **Inventory** — ✓ navigable set unchanged';
|
|
934
616
|
}
|
|
935
617
|
// Acknowledged removals for the report — lenient: a missing OR malformed ack file
|
|
@@ -944,6 +626,29 @@ function readAcknowledgedRemovals() {
|
|
|
944
626
|
return {};
|
|
945
627
|
}
|
|
946
628
|
}
|
|
629
|
+
// Lenient acknowledged-residue read — same advisory degradation as the inventory one.
|
|
630
|
+
function readAcknowledgedResidue() {
|
|
631
|
+
try {
|
|
632
|
+
return readResidueAckFile();
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return {};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// One data-residue clause. A failing data endpoint captured the fallback branch, so its
|
|
639
|
+
// response-driven states are unproven; an ARMED gate escalates an unacknowledged one to ✗.
|
|
640
|
+
function dataResidueLine(res) {
|
|
641
|
+
const { residue, unacknowledged, staleAcknowledgements, armed } = res;
|
|
642
|
+
if (armed && (unacknowledged.length > 0 || staleAcknowledgements.length > 0)) {
|
|
643
|
+
const stale = staleAcknowledgements.length ? `; ${staleAcknowledgements.length} stale acknowledgement(s)` : '';
|
|
644
|
+
return `- **Data residue** — ✗ ${unacknowledged.length} failing data endpoint(s), unacknowledged: ${keyList(unacknowledged)}${stale}`;
|
|
645
|
+
}
|
|
646
|
+
if (unacknowledged.length > 0)
|
|
647
|
+
return `- **Data residue** — ⚠ ${unacknowledged.length} failing data endpoint(s) (fallback branch captured): ${keyList(unacknowledged)} — recorded, not gating (\`dataResidue: 'warn'\` opt-out)`;
|
|
648
|
+
if (residue.length > 0)
|
|
649
|
+
return `- **Data residue** — ✓ ${residue.length} failing endpoint(s), all acknowledged`;
|
|
650
|
+
return '- **Data residue** — ✓ no failing data-boundary request during capture';
|
|
651
|
+
}
|
|
947
652
|
/**
|
|
948
653
|
* The certification block a reviewer reads FIRST — the source-of-truth gates (coverage
|
|
949
654
|
* complete? determinism proven? did the navigable set shrink?), not just the pixel diff.
|
|
@@ -953,15 +658,20 @@ function certificationLines(beforeDir, afterDir) {
|
|
|
953
658
|
const baseLedger = readLedgerFile(beforeDir);
|
|
954
659
|
const headLedger = readLedgerFile(afterDir);
|
|
955
660
|
const inv = auditRunInventory(readInventories(beforeDir), readInventories(afterDir), readAcknowledgedRemovals());
|
|
661
|
+
const res = auditRunResidue(readResidue(afterDir), readAcknowledgedResidue(), headLedger?.dataResidue === 'gate');
|
|
956
662
|
const hasLedger = baseLedger !== null || headLedger !== null;
|
|
957
663
|
const hasInvChange = inv.delta.removed.length > 0 || inv.delta.added.length > 0;
|
|
958
|
-
|
|
664
|
+
const hasResidue = res.residue.length > 0 || res.armed;
|
|
665
|
+
if (!hasLedger && !hasInvChange && !hasResidue)
|
|
959
666
|
return [];
|
|
960
667
|
return [
|
|
961
668
|
'**Certification**',
|
|
962
669
|
coverageLine(auditCoverage(surfaceKeysIn(afterDir), headLedger)),
|
|
963
670
|
determinismLine(auditDeterminism(baseLedger, headLedger)),
|
|
964
671
|
inventoryLine(inv),
|
|
672
|
+
// Only add the residue line when there's residue or the gate was armed — an ordinary
|
|
673
|
+
// bundle (no failing endpoint, not armed) keeps its exact prior 3-line block.
|
|
674
|
+
...(hasResidue ? [dataResidueLine(res)] : []),
|
|
965
675
|
'',
|
|
966
676
|
];
|
|
967
677
|
}
|
|
@@ -1221,6 +931,24 @@ function renderNewSurface(p, ctx, cropSeq) {
|
|
|
1221
931
|
md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
|
|
1222
932
|
return { md, json, cropSeq };
|
|
1223
933
|
}
|
|
934
|
+
/** The shared-chrome tier banner (#193), emitted once above the promoted groups:
|
|
935
|
+
* "the frame every view draws changed" — so the reviewer reads it as one global
|
|
936
|
+
* change, not a per-view surprise. The affected group(s) render in full beneath.
|
|
937
|
+
* `nChrome` is how many distinct chrome changes were promoted; `nSurfaces` is the
|
|
938
|
+
* captured-surface-base count they span. */
|
|
939
|
+
function chromeCalloutLines(nChrome, nSurfaces) {
|
|
940
|
+
const what = nChrome === 1 ? 'change' : 'changes';
|
|
941
|
+
return [
|
|
942
|
+
'',
|
|
943
|
+
'---',
|
|
944
|
+
'',
|
|
945
|
+
`## 🧱 Global chrome ${what} — across all ${nSurfaces} surface(s)`,
|
|
946
|
+
'',
|
|
947
|
+
`_${nChrome} change(s) rode the shared frame every view draws (a persistent nav, header, or footer): ` +
|
|
948
|
+
`each touched every surface that renders the affected element, so it reads as ONE global change, not a ` +
|
|
949
|
+
`per-view one. The detail is folded beneath — review it once._`,
|
|
950
|
+
];
|
|
951
|
+
}
|
|
1224
952
|
/** The one-time banner where report.md switches from full detail to one-line
|
|
1225
953
|
* summaries, so the reader knows nothing is missing — only relocated to report.json. */
|
|
1226
954
|
function cappedNoticeLines(budget) {
|
|
@@ -1271,6 +999,14 @@ export function generateStyleMapReport(opts) {
|
|
|
1271
999
|
.filter((p) => p.sd.missing || p.findings.length > 0);
|
|
1272
1000
|
const missing = prepared.filter((p) => p.sd.missing);
|
|
1273
1001
|
const changeGroups = groupBySignature(prepared);
|
|
1002
|
+
// Shared-chrome tier (#193): promote a change that rode the frame every view
|
|
1003
|
+
// draws (nav rail, header) to a callout, so the reviewer reads "the nav changed
|
|
1004
|
+
// everywhere" once instead of inferring it from a long surface list on several
|
|
1005
|
+
// entries. Purely presentational — counts, groups, exit code, and report.json
|
|
1006
|
+
// are unchanged; only the render order and one heading differ. In the common
|
|
1007
|
+
// small-surface case (e.g. the demo) nothing qualifies and this is a no-op.
|
|
1008
|
+
const { chrome, rest } = classifyChrome(changeGroups, surfaceElementPaths(beforeDir, afterDir));
|
|
1009
|
+
const orderedGroups = [...chrome, ...rest];
|
|
1274
1010
|
const shown = countShownChanges(changeGroups);
|
|
1275
1011
|
// Surfaces carrying a reviewable change — NOT the new (one-sided) ones, which
|
|
1276
1012
|
// have no baseline to compare and are summarised on their own line below so the
|
|
@@ -1331,12 +1067,23 @@ export function generateStyleMapReport(opts) {
|
|
|
1331
1067
|
md.push(summary);
|
|
1332
1068
|
reportBytes += summary.length + 1;
|
|
1333
1069
|
};
|
|
1334
|
-
|
|
1070
|
+
// The captured-surface-base count (all surfaces, not just changed ones) so the
|
|
1071
|
+
// chrome callout can read "N of M surfaces". M is bases, matching the tier's
|
|
1072
|
+
// base-keyed coverage rule.
|
|
1073
|
+
const totalSurfaceBases = new Set(surfaceKeysIn(afterDir).map(surfaceBase)).size;
|
|
1074
|
+
const chromeSet = new Set(chrome);
|
|
1075
|
+
let chromeHeaderEmitted = false;
|
|
1076
|
+
for (const cg of orderedGroups) {
|
|
1335
1077
|
const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
|
|
1336
1078
|
json.push(r.json);
|
|
1337
1079
|
totalFindings += r.findingCount;
|
|
1338
1080
|
cropSeq = r.cropSeq;
|
|
1339
|
-
|
|
1081
|
+
// Prepend the shared-chrome banner once, above the first promoted group. It
|
|
1082
|
+
// rides on the same emitDetail budget so the cap still applies.
|
|
1083
|
+
const detail = chromeSet.has(cg) && !chromeHeaderEmitted
|
|
1084
|
+
? ((chromeHeaderEmitted = true), [...chromeCalloutLines(chrome.length, totalSurfaceBases), ...r.md])
|
|
1085
|
+
: r.md;
|
|
1086
|
+
emitDetail(detail, compactChangeSummary(cg, r.json, img));
|
|
1340
1087
|
}
|
|
1341
1088
|
for (const p of missing) {
|
|
1342
1089
|
const r = renderNewSurface(p, ctx, cropSeq);
|
package/dist/runner.d.ts
CHANGED
|
@@ -177,6 +177,20 @@ export type DefineOptions = {
|
|
|
177
177
|
* See `docs/inventory-guard.md`.
|
|
178
178
|
*/
|
|
179
179
|
inventory?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Data-residue guard. During capture, any request matching the data boundary
|
|
182
|
+
* (`replayUrl`, default `**\/api/**`) that FAILS — a network error or a 4xx/5xx —
|
|
183
|
+
* means the captured state renders that endpoint's FALLBACK branch, so states driven
|
|
184
|
+
* by its real responses are uncaptured and unproven (issue #205). Such a failure is
|
|
185
|
+
* ALWAYS named on stderr and recorded on the capture (`StyleMap.dataResidue`) so the
|
|
186
|
+
* diff/report can surface it. `'gate'` (the default) makes an UNACKNOWLEDGED failing
|
|
187
|
+
* endpoint block `styleproof-diff` (exit 1); acknowledge intentional ones in
|
|
188
|
+
* `styleproof.data-residue.json` (`key -> reason`). `'warn'` is the explicit opt-out —
|
|
189
|
+
* failures are still named + recorded but never block. A capture with no failing data
|
|
190
|
+
* request is byte-identical either way. A 2xx that merely wasn't fixtured is NEVER
|
|
191
|
+
* flagged (recording legitimately records live 2xx).
|
|
192
|
+
*/
|
|
193
|
+
dataResidue?: 'warn' | 'gate';
|
|
180
194
|
};
|
|
181
195
|
export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
|
|
182
196
|
type ResolvedPopupCaptureOptions = Required<PopupCaptureOptions>;
|
|
@@ -237,6 +251,12 @@ export declare function resolveBaseDir(baseDir: string | undefined, env?: string
|
|
|
237
251
|
* can generate reviewable reports without recapturing.
|
|
238
252
|
*/
|
|
239
253
|
export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
|
|
254
|
+
/**
|
|
255
|
+
* The data-residue guard mode: `'gate'` (the v4 default) blocks the diff on an
|
|
256
|
+
* unacknowledged failing data endpoint; `'warn'` is the explicit opt-out that records +
|
|
257
|
+
* warns without gating. Single source of truth for the default, so the flip lives here.
|
|
258
|
+
*/
|
|
259
|
+
export declare function resolveDataResidue(mode: 'warn' | 'gate' | undefined): 'warn' | 'gate';
|
|
240
260
|
/** The capture settings every capturer shares (everything bar the surface set). */
|
|
241
261
|
type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
|
|
242
262
|
export declare function defineStyleMapCapture(options: DefineOptions): void;
|