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
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { trackCount } from './describe.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pure grouping / classification of diff findings — the report's dedup brain,
|
|
4
|
+
* lifted out of the crop-and-PNG machinery so BOTH the visual report
|
|
5
|
+
* (`report.ts`, which renders screenshots on top) and the terminal differ
|
|
6
|
+
* (`bin/styleproof-diff.mjs`, a leaf that must not pull Playwright-adjacent
|
|
7
|
+
* modules) share ONE implementation. No `fs`, no `pngjs`, no `capture.js`: this
|
|
8
|
+
* is a leaf so a bin can import it directly (#186 — bins import leaves, not the
|
|
9
|
+
* barrel).
|
|
10
|
+
*
|
|
11
|
+
* What lives here: collapse logical→physical longhands and shorthand families
|
|
12
|
+
* (`summarizeProps`), strip reflow-casualty/derived longhands (`cleanFindings`),
|
|
13
|
+
* a canonical per-surface signature so surfaces that changed the SAME way group
|
|
14
|
+
* once (`signatureOf` / `groupBySignature`), and the shared-chrome tier that
|
|
15
|
+
* promotes a change spanning every hosting surface to a single callout
|
|
16
|
+
* (`classifyChrome`).
|
|
17
|
+
*/
|
|
18
|
+
// ── property summarisation: dedupe logical longhands, collapse shorthand
|
|
19
|
+
// families, humanize values ────────────────────────────────────────────────
|
|
20
|
+
// Logical longhand → its physical equivalent (LTR, horizontal-tb). Dropped when
|
|
21
|
+
// the physical one changed identically, so each change appears once.
|
|
22
|
+
const LOGICAL_TO_PHYSICAL = (() => {
|
|
23
|
+
const m = {};
|
|
24
|
+
const sides = {
|
|
25
|
+
'block-start': 'top',
|
|
26
|
+
'block-end': 'bottom',
|
|
27
|
+
'inline-start': 'left',
|
|
28
|
+
'inline-end': 'right',
|
|
29
|
+
};
|
|
30
|
+
for (const [l, p] of Object.entries(sides)) {
|
|
31
|
+
for (const k of ['color', 'width', 'style'])
|
|
32
|
+
m[`border-${l}-${k}`] = `border-${p}-${k}`;
|
|
33
|
+
m[`margin-${l}`] = `margin-${p}`;
|
|
34
|
+
m[`padding-${l}`] = `padding-${p}`;
|
|
35
|
+
m[`inset-${l}`] = p;
|
|
36
|
+
}
|
|
37
|
+
const radius = {
|
|
38
|
+
'start-start': 'top-left',
|
|
39
|
+
'start-end': 'top-right',
|
|
40
|
+
'end-start': 'bottom-left',
|
|
41
|
+
'end-end': 'bottom-right',
|
|
42
|
+
};
|
|
43
|
+
for (const [l, p] of Object.entries(radius))
|
|
44
|
+
m[`border-${l}-radius`] = `border-${p}-radius`;
|
|
45
|
+
return m;
|
|
46
|
+
})();
|
|
47
|
+
// Length-valued 4-side families → CSS 1–4-value shorthand whenever all four
|
|
48
|
+
// sides changed (e.g. `padding: 26px 24px → 28px`).
|
|
49
|
+
const BOX4 = [
|
|
50
|
+
{ short: 'margin', parts: ['top', 'right', 'bottom', 'left'].map((s) => `margin-${s}`) },
|
|
51
|
+
{ short: 'padding', parts: ['top', 'right', 'bottom', 'left'].map((s) => `padding-${s}`) },
|
|
52
|
+
{ short: 'border-width', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-width`) },
|
|
53
|
+
{
|
|
54
|
+
short: 'border-radius',
|
|
55
|
+
parts: ['top-left', 'top-right', 'bottom-right', 'bottom-left'].map((s) => `border-${s}-radius`),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
// Colour/keyword families → one row only when all four sides match (else
|
|
59
|
+
// per-side, which keeps each colour swatchable).
|
|
60
|
+
const UNIFORM = [
|
|
61
|
+
{ short: 'border-color', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-color`) },
|
|
62
|
+
{ short: 'border-style', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-style`) },
|
|
63
|
+
];
|
|
64
|
+
const boxShorthand = ([t, r, b, l]) => t === r && r === b && b === l
|
|
65
|
+
? t
|
|
66
|
+
: t === b && r === l
|
|
67
|
+
? `${t} ${r}`
|
|
68
|
+
: r === l
|
|
69
|
+
? `${t} ${r} ${b}`
|
|
70
|
+
: `${t} ${r} ${b} ${l}`;
|
|
71
|
+
const PROP_ORDER = [
|
|
72
|
+
'display',
|
|
73
|
+
'position',
|
|
74
|
+
'grid-template-columns',
|
|
75
|
+
'grid-template-rows',
|
|
76
|
+
'flex-direction',
|
|
77
|
+
'justify-content',
|
|
78
|
+
'align-items',
|
|
79
|
+
'gap',
|
|
80
|
+
'margin',
|
|
81
|
+
'padding',
|
|
82
|
+
'border-width',
|
|
83
|
+
'border-style',
|
|
84
|
+
'border-color',
|
|
85
|
+
'border-radius',
|
|
86
|
+
'outline',
|
|
87
|
+
'background-color',
|
|
88
|
+
'background-image',
|
|
89
|
+
'color',
|
|
90
|
+
'box-shadow',
|
|
91
|
+
'opacity',
|
|
92
|
+
'transform',
|
|
93
|
+
'font-family',
|
|
94
|
+
'font-size',
|
|
95
|
+
'font-weight',
|
|
96
|
+
'line-height',
|
|
97
|
+
'letter-spacing',
|
|
98
|
+
'text-transform',
|
|
99
|
+
'text-align',
|
|
100
|
+
];
|
|
101
|
+
const orderIdx = (p) => {
|
|
102
|
+
const i = PROP_ORDER.indexOf(p);
|
|
103
|
+
return i === -1 ? PROP_ORDER.length : i;
|
|
104
|
+
};
|
|
105
|
+
function cleanVal(v) {
|
|
106
|
+
// Verbatim by design: no rounding. Rounding once showed alpha 0.18 as 0.2 —
|
|
107
|
+
// and could erase a real 0.18→0.2 diff entirely via the no-op filter below.
|
|
108
|
+
let s = v;
|
|
109
|
+
if (!s.includes('(')) {
|
|
110
|
+
const toks = s.split(' ');
|
|
111
|
+
if (toks.length > 1 && new Set(toks).size === 1)
|
|
112
|
+
s = `${toks[0]} ×${toks.length}`;
|
|
113
|
+
}
|
|
114
|
+
return s.replace(/rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/g, 'transparent');
|
|
115
|
+
}
|
|
116
|
+
// These default to `currentColor`, so a `color` change drags them all along —
|
|
117
|
+
// pure echoes, dropped when they match the `color` change.
|
|
118
|
+
const CURRENTCOLOR_FOLLOWERS = [
|
|
119
|
+
'caret-color',
|
|
120
|
+
'outline-color',
|
|
121
|
+
'column-rule-color',
|
|
122
|
+
'text-decoration-color',
|
|
123
|
+
'text-emphasis-color',
|
|
124
|
+
'-webkit-text-fill-color',
|
|
125
|
+
'-webkit-text-stroke-color',
|
|
126
|
+
];
|
|
127
|
+
// "No value here" markers: a forced-state delta that doesn't apply, an unset
|
|
128
|
+
// longhand, or a capture artifact where a path didn't line up. A change BETWEEN
|
|
129
|
+
// two of these (e.g. `— → (gone)`) is meaningless and must never read as a diff.
|
|
130
|
+
const NON_VALUE = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)', '(gone)']);
|
|
131
|
+
export const isNonValue = (v) => NON_VALUE.has(v);
|
|
132
|
+
/** Combine longhands into a shorthand value; all-non-value sides collapse to one. */
|
|
133
|
+
const combineValues = (vals) => (vals.every(isNonValue) ? '(unset)' : vals.join(' '));
|
|
134
|
+
// A flat sequence of independent shorthand/longhand collapse rules — each `if` is
|
|
135
|
+
// one family, not nested logic. Pre-existing (moved verbatim from report.ts, where
|
|
136
|
+
// it was already in the health baseline); splitting it per-family would scatter one
|
|
137
|
+
// cohesive pass across a dozen tiny functions for no readability gain.
|
|
138
|
+
// fallow-ignore-next-line complexity
|
|
139
|
+
export function summarizeProps(props) {
|
|
140
|
+
const map = new Map(props.map((p) => [p.prop, { ...p }]));
|
|
141
|
+
for (const [logical, physical] of Object.entries(LOGICAL_TO_PHYSICAL)) {
|
|
142
|
+
const lo = map.get(logical);
|
|
143
|
+
const ph = map.get(physical);
|
|
144
|
+
if (lo && ph && lo.before === ph.before && lo.after === ph.after)
|
|
145
|
+
map.delete(logical);
|
|
146
|
+
}
|
|
147
|
+
const color = map.get('color');
|
|
148
|
+
if (color)
|
|
149
|
+
for (const f of CURRENTCOLOR_FOLLOWERS) {
|
|
150
|
+
const m = map.get(f);
|
|
151
|
+
if (m && m.before === color.before && m.after === color.after)
|
|
152
|
+
map.delete(f);
|
|
153
|
+
}
|
|
154
|
+
for (const fam of BOX4) {
|
|
155
|
+
const members = fam.parts.map((p) => map.get(p));
|
|
156
|
+
if (members.every((m) => !!m)) {
|
|
157
|
+
fam.parts.forEach((p) => map.delete(p));
|
|
158
|
+
map.set(fam.short, {
|
|
159
|
+
prop: fam.short,
|
|
160
|
+
before: boxShorthand(members.map((m) => m.before)),
|
|
161
|
+
after: boxShorthand(members.map((m) => m.after)),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const rg = map.get('row-gap');
|
|
166
|
+
const cg = map.get('column-gap');
|
|
167
|
+
if (rg && cg) {
|
|
168
|
+
map.delete('row-gap');
|
|
169
|
+
map.delete('column-gap');
|
|
170
|
+
map.set('gap', {
|
|
171
|
+
prop: 'gap',
|
|
172
|
+
before: rg.before === cg.before ? rg.before : `${rg.before} ${cg.before}`,
|
|
173
|
+
after: rg.after === cg.after ? rg.after : `${rg.after} ${cg.after}`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
for (const fam of UNIFORM) {
|
|
177
|
+
const members = fam.parts.map((p) => map.get(p)).filter((m) => !!m);
|
|
178
|
+
if (members.length === fam.parts.length &&
|
|
179
|
+
members.every((m) => m.before === members[0].before && m.after === members[0].after)) {
|
|
180
|
+
fam.parts.forEach((p) => map.delete(p));
|
|
181
|
+
map.set(fam.short, { prop: fam.short, before: members[0].before, after: members[0].after });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// outline-width/-style/-color → one `outline` row (offset stays separate).
|
|
185
|
+
const ow = map.get('outline-width');
|
|
186
|
+
const os = map.get('outline-style');
|
|
187
|
+
const oc = map.get('outline-color');
|
|
188
|
+
if (ow && os && oc) {
|
|
189
|
+
map.delete('outline-width');
|
|
190
|
+
map.delete('outline-style');
|
|
191
|
+
map.delete('outline-color');
|
|
192
|
+
map.set('outline', {
|
|
193
|
+
prop: 'outline',
|
|
194
|
+
before: combineValues([ow.before, os.before, oc.before]),
|
|
195
|
+
after: combineValues([ow.after, os.after, oc.after]),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return ([...map.values()]
|
|
199
|
+
.map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
|
|
200
|
+
// Drop no-ops: a value that didn't actually change, or a change between two
|
|
201
|
+
// "no value here" markers (`— → (gone)`), which carries no information.
|
|
202
|
+
.filter((p) => p.before !== p.after && !(isNonValue(p.before) && isNonValue(p.after)))
|
|
203
|
+
.sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop)));
|
|
204
|
+
}
|
|
205
|
+
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
206
|
+
export function prettyLabel(p, cls) {
|
|
207
|
+
const tag = (p.split('>').pop() ?? '').trim().replace(/:nth-child\(\d+\)/, '') || 'el';
|
|
208
|
+
const first = cls.split(/\s+/)[0] ?? '';
|
|
209
|
+
return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
|
|
210
|
+
}
|
|
211
|
+
// ── surface-key helpers (pure — no map reads) ────────────────────────────────
|
|
212
|
+
// Surface keys originate from artifact filenames — attacker-controlled in the
|
|
213
|
+
// fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
|
|
214
|
+
// (the Action slices report.md above the first `### `). Strip the Markdown/HTML
|
|
215
|
+
// control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
|
|
216
|
+
// or table into that bot comment. Escaping at the render boundary — the keys stay
|
|
217
|
+
// legible; only the injection surface is removed. (Crop FILENAMES are separately
|
|
218
|
+
// restricted to [a-z0-9-]; this is the display-side equivalent.)
|
|
219
|
+
export const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
|
|
220
|
+
export const surfaceBase = (s) => s.replace(/@\d+$/, '');
|
|
221
|
+
export const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
|
|
222
|
+
export function pushSurfaceWidth(byBase, base, surface) {
|
|
223
|
+
const arr = byBase.get(base) ?? [];
|
|
224
|
+
arr.push(surfaceWidth(surface));
|
|
225
|
+
byBase.set(base, arr);
|
|
226
|
+
}
|
|
227
|
+
export function renderSurfaceGroups(byBase) {
|
|
228
|
+
return [...byBase]
|
|
229
|
+
.map(([base, ws]) => {
|
|
230
|
+
const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
|
|
231
|
+
return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
|
|
232
|
+
})
|
|
233
|
+
.join(' · ');
|
|
234
|
+
}
|
|
235
|
+
/** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
|
|
236
|
+
export function formatSurfaceList(surfaces) {
|
|
237
|
+
const byBase = new Map();
|
|
238
|
+
for (const s of surfaces)
|
|
239
|
+
pushSurfaceWidth(byBase, surfaceBase(s), s);
|
|
240
|
+
return renderSurfaceGroups(byBase);
|
|
241
|
+
}
|
|
242
|
+
// ── signature + grouping ─────────────────────────────────────────────────────
|
|
243
|
+
/** Group findings by their element path (one group per changed element). */
|
|
244
|
+
export function groupByPath(findings) {
|
|
245
|
+
const byPath = new Map();
|
|
246
|
+
for (const f of findings) {
|
|
247
|
+
const arr = byPath.get(f.path) ?? [];
|
|
248
|
+
arr.push(f);
|
|
249
|
+
byPath.set(f.path, arr);
|
|
250
|
+
}
|
|
251
|
+
return [...byPath.values()];
|
|
252
|
+
}
|
|
253
|
+
// Grid-track longhands compute to width-dependent pixels (`282px ×2` at one width,
|
|
254
|
+
// `282px 228px` at another), so the SAME responsive change would otherwise get a
|
|
255
|
+
// different signature per width. Key them by track COUNT — what actually
|
|
256
|
+
// identifies the change — so responsive variants group into one section.
|
|
257
|
+
function sigValue(c) {
|
|
258
|
+
if (c.prop === 'grid-template-columns' || c.prop === 'grid-template-rows') {
|
|
259
|
+
return `${c.prop}=${trackCount(c.before)}t>${trackCount(c.after)}t`;
|
|
260
|
+
}
|
|
261
|
+
return `${c.prop}=${c.before}>${c.after}`;
|
|
262
|
+
}
|
|
263
|
+
/** Canonical signature of a surface's findings: surfaces that changed in the
|
|
264
|
+
* same way collapse into one section + one image (the rects differ per width;
|
|
265
|
+
* the change itself does not). */
|
|
266
|
+
export function signatureOf(findings) {
|
|
267
|
+
return JSON.stringify(findings
|
|
268
|
+
.map((f) => ({
|
|
269
|
+
p: f.path,
|
|
270
|
+
k: f.kind,
|
|
271
|
+
t: f.kind === 'dom' ? f.change : f.kind === 'state' ? f.state : (f.pseudo ?? ''),
|
|
272
|
+
v: f.kind === 'dom' ? '' : summarizeProps(f.props).map(sigValue).join('|'),
|
|
273
|
+
}))
|
|
274
|
+
.sort((a, b) => `${a.p}|${a.k}|${a.t}`.localeCompare(`${b.p}|${b.k}|${b.t}`)));
|
|
275
|
+
}
|
|
276
|
+
/** A one-line heading for a change group: "1 element added", "2 elements restyled". */
|
|
277
|
+
export function groupTitle(findings) {
|
|
278
|
+
const added = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'added').map((f) => f.path));
|
|
279
|
+
const removed = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'removed').map((f) => f.path));
|
|
280
|
+
const retagged = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'retagged').map((f) => f.path));
|
|
281
|
+
const restyled = new Set(findings.filter((f) => f.kind !== 'dom' && !added.has(f.path) && !removed.has(f.path)).map((f) => f.path));
|
|
282
|
+
const n = (c, w) => `${c} ${w}${c === 1 ? '' : 's'}`;
|
|
283
|
+
const parts = [];
|
|
284
|
+
if (added.size)
|
|
285
|
+
parts.push(`${n(added.size, 'element')} added`);
|
|
286
|
+
if (removed.size)
|
|
287
|
+
parts.push(`${n(removed.size, 'element')} removed`);
|
|
288
|
+
if (retagged.size)
|
|
289
|
+
parts.push(`${n(retagged.size, 'element')} retagged`);
|
|
290
|
+
if (restyled.size)
|
|
291
|
+
parts.push(`${n(restyled.size, 'element')} restyled`);
|
|
292
|
+
return parts.join(', ') || `${n(new Set(findings.map((f) => f.path)).size, 'element')} changed`;
|
|
293
|
+
}
|
|
294
|
+
// ── noise cleaning ───────────────────────────────────────────────────────────
|
|
295
|
+
// Computed values that follow from an element's box size or position rather than
|
|
296
|
+
// its styling. On any reflow they change all the way up the ancestor chain
|
|
297
|
+
// (body, main, section…), so an element whose ONLY changes are these is a reflow
|
|
298
|
+
// casualty: it must not anchor a crop region (that would zoom to the whole page)
|
|
299
|
+
// nor clutter the findings. The certification differ keeps them — a reflow IS a
|
|
300
|
+
// change to certify — but the visual report focuses on styling intent.
|
|
301
|
+
const DERIVED_PROPS = new Set([
|
|
302
|
+
'width',
|
|
303
|
+
'height',
|
|
304
|
+
'block-size',
|
|
305
|
+
'inline-size',
|
|
306
|
+
'min-width',
|
|
307
|
+
'min-height',
|
|
308
|
+
'max-width',
|
|
309
|
+
'max-height',
|
|
310
|
+
'perspective-origin',
|
|
311
|
+
'transform-origin',
|
|
312
|
+
// position offsets shift with the document on any reflow
|
|
313
|
+
'top',
|
|
314
|
+
'right',
|
|
315
|
+
'bottom',
|
|
316
|
+
'left',
|
|
317
|
+
'inset-block-start',
|
|
318
|
+
'inset-block-end',
|
|
319
|
+
'inset-inline-start',
|
|
320
|
+
'inset-inline-end',
|
|
321
|
+
]);
|
|
322
|
+
// Props stripped from forced :hover/:focus/:active deltas specifically. Layout
|
|
323
|
+
// and grid-track values that shift when a state forces a relayout are capture
|
|
324
|
+
// noise, not interaction feedback — a state finding is meant to catch a changed
|
|
325
|
+
// hover/focus/active *style* (colour, outline, shadow), not a reflow.
|
|
326
|
+
const STATE_STRIP = new Set([
|
|
327
|
+
...DERIVED_PROPS,
|
|
328
|
+
'grid-template-columns',
|
|
329
|
+
'grid-template-rows',
|
|
330
|
+
'grid-template-areas',
|
|
331
|
+
'grid-auto-columns',
|
|
332
|
+
'grid-auto-rows',
|
|
333
|
+
'grid-auto-flow',
|
|
334
|
+
]);
|
|
335
|
+
/** How many of a surface's summarised props are derived/box longhands — the count
|
|
336
|
+
* the CLI folds behind `(+N derived longhands)`. Counts on the RAW finding props
|
|
337
|
+
* (before cleaning) so the CLI can advertise exactly what it suppressed. */
|
|
338
|
+
export function derivedLonghandCount(findings) {
|
|
339
|
+
let n = 0;
|
|
340
|
+
for (const f of findings) {
|
|
341
|
+
if (f.kind === 'dom')
|
|
342
|
+
continue;
|
|
343
|
+
const strip = f.kind === 'state' ? STATE_STRIP : DERIVED_PROPS;
|
|
344
|
+
for (const p of summarizeProps(f.props))
|
|
345
|
+
if (strip.has(p.prop))
|
|
346
|
+
n++;
|
|
347
|
+
}
|
|
348
|
+
return n;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Strip the noise the visual report shouldn't carry, cross-referencing each
|
|
352
|
+
* element's layers so the forced-state layer stops echoing the base:
|
|
353
|
+
* - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
|
|
354
|
+
* - forced states: drop derived + grid-track props, drop a delta the BASE
|
|
355
|
+
* already changed (a `:hover color` that just follows a recoloured base is an
|
|
356
|
+
* echo, not a dropped variant), and drop non-value↔non-value rows;
|
|
357
|
+
* - any finding left with no props is removed entirely.
|
|
358
|
+
*/
|
|
359
|
+
export function cleanFindings(findings) {
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const group of groupByPath(findings)) {
|
|
362
|
+
const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
|
|
363
|
+
const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
|
|
364
|
+
// For an ADDED element the base style is a full (unset)→value snapshot, not a
|
|
365
|
+
// delta — so a forced-state value is never an "echo" of a base *change*; keep
|
|
366
|
+
// every state row (suppressing them would drop a real :hover/:focus value).
|
|
367
|
+
const isAdded = group.some((f) => f.kind === 'dom' && f.change === 'added');
|
|
368
|
+
for (const f of group) {
|
|
369
|
+
if (f.kind === 'dom') {
|
|
370
|
+
out.push(f);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const props = f.kind === 'style'
|
|
374
|
+
? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
|
|
375
|
+
: f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
|
|
376
|
+
(isAdded || !baseChanged.has(p.prop)) &&
|
|
377
|
+
!(isNonValue(p.before) && isNonValue(p.after)));
|
|
378
|
+
if (props.length)
|
|
379
|
+
out.push({ ...f, props });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Group surfaces that changed identically (same signature) into one group each,
|
|
386
|
+
* keeping the widest surface as the representative. The rects differ per width;
|
|
387
|
+
* the change itself does not. Callers pass the already-prepared per-surface
|
|
388
|
+
* findings (missing/one-sided surfaces excluded upstream).
|
|
389
|
+
*/
|
|
390
|
+
export function groupBySignature(prepared) {
|
|
391
|
+
const bySig = new Map();
|
|
392
|
+
for (const p of prepared) {
|
|
393
|
+
const sig = signatureOf(p.findings);
|
|
394
|
+
const existing = bySig.get(sig);
|
|
395
|
+
if (existing) {
|
|
396
|
+
existing.surfaces.push(p.surface);
|
|
397
|
+
if (surfaceWidth(p.surface) > surfaceWidth(existing.rep.surface))
|
|
398
|
+
existing.rep = p;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
bySig.set(sig, { surfaces: [p.surface], rep: p, findings: p.findings });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return [...bySig.values()];
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The set of element paths that changed as SHARED CHROME — a persistent frame
|
|
408
|
+
* element (nav rail, header, footer) that every view renders and that moved on
|
|
409
|
+
* every view that renders it.
|
|
410
|
+
*
|
|
411
|
+
* The rule is STRUCTURAL, deliberately NOT a tunable percentage. For each changed
|
|
412
|
+
* path we compare two base-key sets:
|
|
413
|
+
* - `hosting` — the surface bases whose style map contains the path at all;
|
|
414
|
+
* - `changed` — the surface bases where the path appears in a finding.
|
|
415
|
+
* The path is chrome iff it is hosted on MORE THAN ONE base and it changed on
|
|
416
|
+
* EVERY base that hosts it (`changed ⊇ hosting`). "Every surface that has this
|
|
417
|
+
* element changed it" is exactly what shared chrome means; it needs no threshold
|
|
418
|
+
* to tune or defend. A content element (hosted on one base) fails the >1 guard; a
|
|
419
|
+
* partial change (some hosting bases unchanged) fails the coverage guard.
|
|
420
|
+
*
|
|
421
|
+
* Widths of one base collapse to the base key, so a nav present at @1280 and @390
|
|
422
|
+
* counts once. `surfacePaths` maps each captured surface key → the element paths
|
|
423
|
+
* it renders (union of both sides from the caller).
|
|
424
|
+
*/
|
|
425
|
+
export function chromePaths(changedOnSurfaces, surfacePaths) {
|
|
426
|
+
const hosting = new Map();
|
|
427
|
+
for (const [surface, paths] of surfacePaths) {
|
|
428
|
+
const base = surfaceBase(surface);
|
|
429
|
+
for (const p of paths) {
|
|
430
|
+
const set = hosting.get(p) ?? new Set();
|
|
431
|
+
set.add(base);
|
|
432
|
+
hosting.set(p, set);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const changed = new Map();
|
|
436
|
+
for (const f of changedOnSurfaces) {
|
|
437
|
+
const set = changed.get(f.path) ?? new Set();
|
|
438
|
+
for (const s of f.surfaces)
|
|
439
|
+
set.add(surfaceBase(s));
|
|
440
|
+
changed.set(f.path, set);
|
|
441
|
+
}
|
|
442
|
+
const chrome = new Set();
|
|
443
|
+
for (const [path, changedBases] of changed) {
|
|
444
|
+
const hostingBases = hosting.get(path) ?? new Set([path]);
|
|
445
|
+
if (hostingBases.size > 1 && [...hostingBases].every((b) => changedBases.has(b)))
|
|
446
|
+
chrome.add(path);
|
|
447
|
+
}
|
|
448
|
+
return chrome;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Split signature groups into the shared-chrome tier and the rest. A group is
|
|
452
|
+
* promoted only when EVERY one of its affected element paths is a chrome path
|
|
453
|
+
* (see `chromePaths`) — a group that entangles a frame change with a view's own
|
|
454
|
+
* content change stays in `rest` and renders in place, so we never hide a
|
|
455
|
+
* content change under a chrome banner.
|
|
456
|
+
*/
|
|
457
|
+
export function classifyChrome(groups, surfacePaths) {
|
|
458
|
+
// Findings tagged with the surfaces the group spans, so chromePaths sees which
|
|
459
|
+
// bases each path changed on across ALL groups (a path bundled with content on
|
|
460
|
+
// one view still counts as changed there).
|
|
461
|
+
const tagged = groups.flatMap((g) => g.findings.map((f) => ({ path: f.path, surfaces: g.surfaces })));
|
|
462
|
+
const paths = chromePaths(tagged, surfacePaths);
|
|
463
|
+
const chrome = [];
|
|
464
|
+
const rest = [];
|
|
465
|
+
for (const g of groups) {
|
|
466
|
+
const affected = new Set(g.findings.map((f) => f.path));
|
|
467
|
+
const isChrome = affected.size > 0 && [...affected].every((p) => paths.has(p));
|
|
468
|
+
(isChrome ? chrome : rest).push(g);
|
|
469
|
+
}
|
|
470
|
+
return { chrome, rest, chromePaths: paths };
|
|
471
|
+
}
|
package/dist/coverage.d.ts
CHANGED
|
@@ -86,6 +86,13 @@ export type CoverageLedger = {
|
|
|
86
86
|
exclude: Record<string, string>;
|
|
87
87
|
/** How this capture's determinism was established (3.10.0). Absent on older bundles. */
|
|
88
88
|
determinism?: DeterminismBasis;
|
|
89
|
+
/**
|
|
90
|
+
* The data-residue guard mode this capture ran under (issue #205). `'gate'` (the v4
|
|
91
|
+
* default) — an unacknowledged failing data endpoint blocks the diff; `'warn'` — the
|
|
92
|
+
* explicit opt-out, residue is recorded and warned but never blocks. Absent on bundles
|
|
93
|
+
* captured before the field existed, and read as warn so they never gate retroactively.
|
|
94
|
+
*/
|
|
95
|
+
dataResidue?: 'warn' | 'gate';
|
|
89
96
|
};
|
|
90
97
|
export type DeterminismVerdict = {
|
|
91
98
|
/** `proven` — both sides self-checked or replayed; `unproven` — a side was neither, so
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** One data-boundary request that FAILED during capture — an embedded fallback branch. */
|
|
2
|
+
export type DataResidueEntry = {
|
|
3
|
+
/**
|
|
4
|
+
* Stable identity across captures: `<surface>·<endpoint>` where `endpoint` is the
|
|
5
|
+
* request URL's `pathname` (query stripped so `?all=1` vs `?all=2` don't fork the
|
|
6
|
+
* key). Escaped so it can't inject Markdown into the report/PR-comment summary.
|
|
7
|
+
*/
|
|
8
|
+
key: string;
|
|
9
|
+
/** The captured surface key this failure was observed on. */
|
|
10
|
+
surface: string;
|
|
11
|
+
/** The failing endpoint's URL pathname (query stripped). */
|
|
12
|
+
endpoint: string;
|
|
13
|
+
/** Why it failed: a network error text (`net::ERR_CONNECTION_REFUSED`) or `HTTP 503`. */
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
/** `key -> reason` — failing endpoints that are intentional/known and on the record. */
|
|
17
|
+
export type AcknowledgedResidue = Record<string, string>;
|
|
18
|
+
/** Acknowledgement file, parallel to the inventory guard's `styleproof.inventory.json`. */
|
|
19
|
+
export declare const DATA_RESIDUE_ACK_FILE = "styleproof.data-residue.json";
|
|
20
|
+
/**
|
|
21
|
+
* Read the acknowledged-residue file (`$STYLEPROOF_DATA_RESIDUE` or
|
|
22
|
+
* `styleproof.data-residue.json`). `{}` when absent; THROWS on malformed JSON — the
|
|
23
|
+
* caller picks the policy (the CI gate fails loud so a broken ack file can't silently
|
|
24
|
+
* un-acknowledge a real failure; the advisory report degrades to `{}`). Mirrors
|
|
25
|
+
* `readAckFile` in the inventory guard exactly.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readResidueAckFile(): AcknowledgedResidue;
|
|
28
|
+
/** Build a residue key from a surface and an endpoint URL. Query stripped, escaped. */
|
|
29
|
+
export declare function residueKey(surface: string, endpoint: string): string;
|
|
30
|
+
/** The endpoint pathname a URL resolves to, query stripped. Falls back to the raw URL. */
|
|
31
|
+
export declare function endpointOf(url: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Union the per-surface `map.dataResidue` of a whole run into one deduped set, keyed by
|
|
34
|
+
* `key` (surface·endpoint), so the same failure seen across widths / a self-check re-run
|
|
35
|
+
* is ONE entry, not a spray. Sorted for a stable rendering order.
|
|
36
|
+
*/
|
|
37
|
+
export declare function unionResidue(perSurface: Array<{
|
|
38
|
+
dataResidue?: DataResidueEntry[];
|
|
39
|
+
} | undefined>): DataResidueEntry[];
|
|
40
|
+
export type ResidueAudit = {
|
|
41
|
+
/** Every failing endpoint observed across the run (union, deduped). */
|
|
42
|
+
residue: DataResidueEntry[];
|
|
43
|
+
/** Failing endpoints NOT acknowledged in the ledger — the gate fails on a non-empty set. */
|
|
44
|
+
unacknowledged: DataResidueEntry[];
|
|
45
|
+
/** Acknowledged keys no longer present in the residue (the endpoint is fixtured/gone) —
|
|
46
|
+
* a rotted opt-out, so the ledger can't quietly rot (mirrors the `exclude` guard). */
|
|
47
|
+
staleAcknowledgements: string[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The gate. A failing endpoint whose key isn't in `acknowledged` (key -> reason) is
|
|
51
|
+
* unacknowledged — the caller fails on a non-empty result. An `acknowledged` key that
|
|
52
|
+
* isn't actually failing is stale, returned separately so the ledger can't rot. Unlike
|
|
53
|
+
* the inventory guard (a base-vs-head REMOVAL), residue is present-on-HEAD: the head
|
|
54
|
+
* capture's failing endpoints are audited directly, so only the head maps matter.
|
|
55
|
+
*/
|
|
56
|
+
export declare function auditResidue(headMaps: Array<{
|
|
57
|
+
dataResidue?: DataResidueEntry[];
|
|
58
|
+
} | undefined>, acknowledged?: AcknowledgedResidue): ResidueAudit;
|
|
59
|
+
/**
|
|
60
|
+
* Run-level entry point for a gate/report: audit the HEAD bundle's residue against the
|
|
61
|
+
* acknowledgement ledger, carrying whether the guard was ARMED to gate. `armed` comes
|
|
62
|
+
* from the head coverage ledger's `dataResidue: 'gate'` (the default; only an explicit
|
|
63
|
+
* `'warn'` — or an older bundle with no field — is unarmed). When not armed, the caller
|
|
64
|
+
* still surfaces residue (warn is the explicit opt-out) but must not block.
|
|
65
|
+
*/
|
|
66
|
+
export declare function auditRunResidue(headMaps: Array<{
|
|
67
|
+
dataResidue?: DataResidueEntry[];
|
|
68
|
+
} | undefined>, acknowledged: AcknowledgedResidue, armed: boolean): ResidueAudit & {
|
|
69
|
+
armed: boolean;
|
|
70
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Data-residue guard — name the data-boundary requests that FAILED during capture.
|
|
2
|
+
//
|
|
3
|
+
// The certification diff proves "did surface X change?" but is structurally blind to
|
|
4
|
+
// a whole class of high-stakes miss: a surface requests a data endpoint that nothing
|
|
5
|
+
// controls — no fixture routes it, so it falls through and FAILS (network error, or a
|
|
6
|
+
// 4xx/5xx) — and the view silently renders its FALLBACK branch. Capture after capture
|
|
7
|
+
// embeds that fallback state; the response-driven state its real data would produce is
|
|
8
|
+
// never captured, so a restyle confined to that state ships green. StyleProof's
|
|
9
|
+
// request tracker watched the request fail every time and said nothing.
|
|
10
|
+
//
|
|
11
|
+
// This module records, per surface, any request matching the data boundary (the
|
|
12
|
+
// `replayUrl` glob) that failed or errored. The residue travels on the StyleMap (like
|
|
13
|
+
// `inventory`), so the diff/report can surface it; a stderr warning names it at capture
|
|
14
|
+
// time; and the gate (on by default, opted down via `dataResidue: 'warn'`) blocks an
|
|
15
|
+
// unacknowledged failing endpoint — mirroring the `exclude`/inventory-ack discipline.
|
|
16
|
+
//
|
|
17
|
+
// Observing that a data request failed needs NO app knowledge — it's the same move as
|
|
18
|
+
// the unreadable-stylesheet residue: convert silent blindness into a name. Declaring
|
|
19
|
+
// the app's data STATES stays app-owned (see issue #202); this only observes failures.
|
|
20
|
+
//
|
|
21
|
+
// Deliberately OUT OF SCOPE (unsound or noisy):
|
|
22
|
+
// - Flagging endpoints that responded 2xx but weren't fixtured. In recording mode
|
|
23
|
+
// every live response is legitimately recorded, so a blanket "uncontrolled" flag
|
|
24
|
+
// would fire on every healthy record run. A sound record-fulfilled-vs-network
|
|
25
|
+
// discriminator (if Playwright exposes one) could be a follow-up; we do not ship a
|
|
26
|
+
// heuristic version. Only FAILED requests (network error / 4xx / 5xx) are residue.
|
|
27
|
+
// - Synthesising payloads or surfaces for un-exercised response variants (app
|
|
28
|
+
// knowledge; issue #202's territory).
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { safeKey } from './change-groups.js';
|
|
32
|
+
/** Acknowledgement file, parallel to the inventory guard's `styleproof.inventory.json`. */
|
|
33
|
+
export const DATA_RESIDUE_ACK_FILE = 'styleproof.data-residue.json';
|
|
34
|
+
/**
|
|
35
|
+
* Read the acknowledged-residue file (`$STYLEPROOF_DATA_RESIDUE` or
|
|
36
|
+
* `styleproof.data-residue.json`). `{}` when absent; THROWS on malformed JSON — the
|
|
37
|
+
* caller picks the policy (the CI gate fails loud so a broken ack file can't silently
|
|
38
|
+
* un-acknowledge a real failure; the advisory report degrades to `{}`). Mirrors
|
|
39
|
+
* `readAckFile` in the inventory guard exactly.
|
|
40
|
+
*/
|
|
41
|
+
export function readResidueAckFile() {
|
|
42
|
+
const p = path.resolve(process.env.STYLEPROOF_DATA_RESIDUE ?? DATA_RESIDUE_ACK_FILE);
|
|
43
|
+
if (!fs.existsSync(p))
|
|
44
|
+
return {};
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
throw new Error(`${p} is not valid JSON — ${e.message}`, { cause: e });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Build a residue key from a surface and an endpoint URL. Query stripped, escaped. */
|
|
53
|
+
export function residueKey(surface, endpoint) {
|
|
54
|
+
return `${safeKey(surface)}·${safeKey(endpoint)}`;
|
|
55
|
+
}
|
|
56
|
+
/** The endpoint pathname a URL resolves to, query stripped. Falls back to the raw URL. */
|
|
57
|
+
export function endpointOf(url) {
|
|
58
|
+
try {
|
|
59
|
+
return new URL(url).pathname;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ── pure union / audit / reconciliation ─────────────────────────────────────────
|
|
66
|
+
/**
|
|
67
|
+
* Union the per-surface `map.dataResidue` of a whole run into one deduped set, keyed by
|
|
68
|
+
* `key` (surface·endpoint), so the same failure seen across widths / a self-check re-run
|
|
69
|
+
* is ONE entry, not a spray. Sorted for a stable rendering order.
|
|
70
|
+
*/
|
|
71
|
+
export function unionResidue(perSurface) {
|
|
72
|
+
const byKey = new Map();
|
|
73
|
+
for (const map of perSurface) {
|
|
74
|
+
for (const entry of map?.dataResidue ?? [])
|
|
75
|
+
if (!byKey.has(entry.key))
|
|
76
|
+
byKey.set(entry.key, entry);
|
|
77
|
+
}
|
|
78
|
+
return Array.from(byKey.values()).sort((a, b) => a.key.localeCompare(b.key));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The gate. A failing endpoint whose key isn't in `acknowledged` (key -> reason) is
|
|
82
|
+
* unacknowledged — the caller fails on a non-empty result. An `acknowledged` key that
|
|
83
|
+
* isn't actually failing is stale, returned separately so the ledger can't rot. Unlike
|
|
84
|
+
* the inventory guard (a base-vs-head REMOVAL), residue is present-on-HEAD: the head
|
|
85
|
+
* capture's failing endpoints are audited directly, so only the head maps matter.
|
|
86
|
+
*/
|
|
87
|
+
export function auditResidue(headMaps, acknowledged = {}) {
|
|
88
|
+
const residue = unionResidue(headMaps);
|
|
89
|
+
const residueKeys = new Set(residue.map((r) => r.key));
|
|
90
|
+
return {
|
|
91
|
+
residue,
|
|
92
|
+
unacknowledged: residue.filter((r) => !(r.key in acknowledged)),
|
|
93
|
+
staleAcknowledgements: Object.keys(acknowledged).filter((k) => !residueKeys.has(k)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Run-level entry point for a gate/report: audit the HEAD bundle's residue against the
|
|
98
|
+
* acknowledgement ledger, carrying whether the guard was ARMED to gate. `armed` comes
|
|
99
|
+
* from the head coverage ledger's `dataResidue: 'gate'` (the default; only an explicit
|
|
100
|
+
* `'warn'` — or an older bundle with no field — is unarmed). When not armed, the caller
|
|
101
|
+
* still surfaces residue (warn is the explicit opt-out) but must not block.
|
|
102
|
+
*/
|
|
103
|
+
export function auditRunResidue(headMaps, acknowledged, armed) {
|
|
104
|
+
return { armed, ...auditResidue(headMaps, acknowledged) };
|
|
105
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests, trackDataResidue, urlMatcher, } from './capture.js';
|
|
2
2
|
export * from './inventory.js';
|
|
3
|
+
export * from './data-residue.js';
|
|
3
4
|
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
4
5
|
export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
|
|
5
6
|
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|