styleproof 3.6.0 → 3.7.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 CHANGED
@@ -7,6 +7,26 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.7.0] - 2026-07-04
11
+
12
+ ### Added
13
+
14
+ - **Inventory guard** — StyleProof can now assert the navigable UI doesn't silently
15
+ shrink. Opt in with `captureStyleMap(page, { inventory: true })` and each surface's
16
+ navigable affordances — internal route links, `role=tab`, `role=menuitem`,
17
+ button-only nav — are harvested (keyed stably) into `StyleMap.inventory`.
18
+ `styleproof-diff` then unions the reachable set across both sides and **exits 1 on
19
+ any affordance present on base but absent on head** — a feature that stopped being
20
+ reachable — unless it's acknowledged in `styleproof.inventory.json` (`{"<key>":
21
+ "<why>"}`, path overridable via `STYLEPROOF_INVENTORY`; a stale acknowledgement is
22
+ flagged so the ledger can't rot). Closes the certification diff's blind spot for the
23
+ information-architecture / replacement class: a redesign staged as a new surface, or
24
+ a nav item / route that disappears, which a same-surface computed-style diff catches
25
+ only incidentally. **Off by default (no map carries inventory ⇒ the CLI is
26
+ byte-for-byte unchanged); the certification diff itself is untouched.** The
27
+ programmatic entry point `auditRunInventory(baseMaps, headMaps, allowRemoved)`
28
+ stays available for custom gates. See `docs/inventory-guard.md`.
29
+
10
30
  ## [3.6.0] - 2026-07-03
11
31
 
12
32
  ### Changed
@@ -39,9 +39,77 @@ import {
39
39
  showHelpAndExit,
40
40
  unknownFlagMessage,
41
41
  } from '../dist/cli-errors.js';
42
+ import { loadStyleMap } from '../dist/capture.js';
43
+ import { auditRunInventory } from '../dist/inventory.js';
42
44
 
43
45
  const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.mjs$/, '');
44
46
 
47
+ // ── inventory guard (opt-in) ────────────────────────────────────────────────────
48
+ // Surfaces the navigable-inventory audit through the CLI. When captures carry
49
+ // `inventory` (from captureStyleMap({ inventory: true })), an affordance base
50
+ // offered and head no longer does BLOCKS unless acknowledged. Inert when no map
51
+ // carries inventory, so every existing capture behaves exactly as before.
52
+
53
+ // Mirror of indexDir() in diff.ts — the capture-dir file filter, kept local so the
54
+ // bin needn't reach into diff internals for one small read.
55
+ function dirInventories(dir) {
56
+ return fs
57
+ .readdirSync(dir)
58
+ .filter((f) => f !== 'styleproof-manifest.json' && /\.json(\.gz)?$/.test(f))
59
+ .map((f) => loadStyleMap(path.join(dir, f)))
60
+ .map((m) => (m.inventory ? { inventory: m.inventory } : {}));
61
+ }
62
+
63
+ // `key -> reason` acknowledged removals. Optional file; absent → none. Malformed
64
+ // JSON fails loud (exit 2) rather than silently un-acknowledging a real removal.
65
+ function loadAllowRemoved() {
66
+ const p = path.resolve(process.env.STYLEPROOF_INVENTORY ?? 'styleproof.inventory.json');
67
+ if (!fs.existsSync(p)) return {};
68
+ try {
69
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
70
+ } catch (e) {
71
+ console.error(`${COMMAND}: ${p} is not valid JSON — ${e.message}`);
72
+ process.exit(2);
73
+ }
74
+ }
75
+
76
+ // Read both sides' inventory and audit removals. MUST run before any cached-map
77
+ // cleanup deletes the restored dirs. Returns null when no capture carries inventory.
78
+ function readInventoryAudit(dirA, dirB) {
79
+ const baseInv = dirInventories(dirA);
80
+ const headInv = dirInventories(dirB);
81
+ if (![...baseInv, ...headInv].some((m) => m.inventory?.length)) return null;
82
+ const allowed = loadAllowRemoved();
83
+ return { allowed, ...auditRunInventory(baseInv, headInv, allowed) };
84
+ }
85
+
86
+ // Print the Inventory section from a prior audit; return the count of UNACKNOWLEDGED
87
+ // removals (which block). No-op/0 when there was nothing with inventory to audit.
88
+ function printInventoryAudit(audit) {
89
+ if (!audit) return 0;
90
+ const { delta, unexplained, staleAllowances, allowed } = audit;
91
+ if (!delta.added.length && !delta.removed.length && !staleAllowances.length) {
92
+ console.log('\n📐 Inventory: navigable set unchanged across captured surfaces');
93
+ return 0;
94
+ }
95
+ console.log('\n📐 Inventory (navigable affordances — route links, tabs, menu items, nav buttons):');
96
+ for (const it of delta.removed) {
97
+ const why = allowed[it.key];
98
+ console.log(
99
+ why
100
+ ? ` removed: ${it.key} ("${it.label}") — acknowledged: ${why}`
101
+ : ` ✗ REMOVED, unacknowledged: ${it.key} ("${it.label}")`,
102
+ );
103
+ }
104
+ for (const it of delta.added) console.log(` + added: ${it.key} ("${it.label}")`);
105
+ for (const k of staleAllowances) console.log(` ⚠ stale allowRemoved (key is not actually removed): ${k}`);
106
+ if (unexplained.length)
107
+ console.log(
108
+ ` → ${unexplained.length} unacknowledged removal(s): restore the affordance, or record the decision in styleproof.inventory.json {"<key>":"<why>"}.`,
109
+ );
110
+ return unexplained.length;
111
+ }
112
+
45
113
  const HELP = `${COMMAND} — certify a CSS refactor by diffing two computed-style map captures
46
114
 
47
115
  usage: ${COMMAND} [baseRef] [options]
@@ -132,9 +200,13 @@ if (args.length <= 1) {
132
200
  }
133
201
 
134
202
  let result;
203
+ let inventoryAudit = null;
135
204
  try {
136
205
  assertCompatibleMapDirs(dirA, dirB);
137
206
  result = diffStyleMapDirs(dirA, dirB);
207
+ // Read inventory here, while the (possibly cached/restored) dirs still exist —
208
+ // the finally below deletes them in cached-map mode.
209
+ inventoryAudit = readInventoryAudit(dirA, dirB);
138
210
  } catch (e) {
139
211
  console.error(e.message);
140
212
  process.exit(2);
@@ -170,6 +242,8 @@ for (const sd of surfaces) {
170
242
  if (lines.length > MAX) console.log(` ... and ${lines.length - MAX} more lines (re-run with --max ${lines.length})`);
171
243
  }
172
244
 
245
+ const invRemovals = printInventoryAudit(inventoryAudit);
246
+
173
247
  if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces, compared }, null, 2));
174
248
 
175
249
  const total = counts.dom + counts.style + counts.state;
@@ -177,13 +251,14 @@ const newSurfaces = surfaces.filter((s) => s.missing).length;
177
251
  // One SurfaceDiff per distinct surface across both sides (incl. missing-on-one-side).
178
252
  const surfaceCount = surfaces.length;
179
253
  const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
254
+ const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
180
255
  console.log(
181
- total === 0
256
+ total === 0 && invRemovals === 0
182
257
  ? newSurfaces === 0
183
258
  ? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
184
259
  : `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
185
- : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
260
+ : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}`,
186
261
  );
187
- // 0 = identical, 1 = reviewable differences, 3 = only new surfaces (no baseline,
188
- // nothing to review). 2 stays reserved for usage/capture errors.
189
- process.exit(total > 0 ? 1 : newSurfaces > 0 ? 3 : 0);
262
+ // 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory
263
+ // removals), 3 = only new surfaces (no baseline). 2 reserved for usage/capture errors.
264
+ process.exit(total > 0 || invRemovals > 0 ? 1 : newSurfaces > 0 ? 3 : 0);
package/dist/capture.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Page } from '@playwright/test';
2
+ import { type NavigableItem } from './inventory.js';
2
3
  /**
3
4
  * Computed-style capture: the browser's final resolved value for every CSS
4
5
  * longhand on every element, keyed by DOM structure (never by class name, so
@@ -124,6 +125,13 @@ export type StyleMap = {
124
125
  * surface from the document root; per-subtree overrides aren't tracked.
125
126
  */
126
127
  tokens?: Record<string, string>;
128
+ /**
129
+ * The surface's navigable inventory — user-reachable affordances (route links,
130
+ * tabs, menu items, button-only nav), keyed stably. Present only when captured
131
+ * with `inventory: true`. Diffed by the inventory guard (a removal gates), never
132
+ * by the certification diff. See docs/inventory-guard.md.
133
+ */
134
+ inventory?: NavigableItem[];
127
135
  };
128
136
  export type CaptureOptions = {
129
137
  /**
@@ -135,6 +143,12 @@ export type CaptureOptions = {
135
143
  * is volatile without paying the settle wait for it.
136
144
  */
137
145
  ignore?: string[];
146
+ /**
147
+ * Harvest the surface's navigable inventory (route links, tabs, menu items,
148
+ * button-only nav) into `StyleMap.inventory`, for the inventory guard. Off by
149
+ * default; advisory to the certification diff — a removal gates separately.
150
+ */
151
+ inventory?: boolean;
138
152
  /**
139
153
  * Settle the page before capturing, and auto-exclude live regions (default
140
154
  * on). StyleProof polls the (motion-frozen) page until its computed-style map
@@ -234,12 +248,6 @@ export declare function trackInflightRequests(page: Page): {
234
248
  pending: () => number;
235
249
  dispose: () => void;
236
250
  };
237
- /**
238
- * Capture the page's complete style map. Drive the page to the state you want
239
- * first (navigate, open menus); by default the capture then auto-settles the
240
- * page and excludes live regions (see `stabilize`), so a fetch/stream that
241
- * paints after `go()` resolves is captured loaded, not mid-load.
242
- */
243
251
  export declare function captureStyleMap(page: Page, options?: CaptureOptions): Promise<StyleMap>;
244
252
  /** Write a style map to disk; gzipped when the path ends in `.gz`. */
245
253
  export declare function saveStyleMap(filePath: string, map: StyleMap): void;
package/dist/capture.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { gzipSync, gunzipSync } from 'node:zlib';
4
+ import { classifyInventory, collectNavAffordances } from './inventory.js';
4
5
  const INTERACTIVE = 'a, button, input, textarea, select, summary, [role="button"], [tabindex]';
5
6
  // Freeze motion so every captured value is a settled end state, not a frame
6
7
  // of an animation or a mid-flight transition after a forced :hover.
@@ -354,6 +355,7 @@ function detectOverlayCandidates({ ignore }) {
354
355
  }
355
356
  /** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
356
357
  // Serialized into the browser by page.evaluate; cannot call module helpers.
358
+ // fallow-ignore-next-line complexity -- pre-existing (cog 16); in-page fn can't call module helpers, so it can't be split. Exposed here only because the inventory feature edits this file. Refactor separately.
357
359
  function snapSubtree({ selector, index }) {
358
360
  const el = document.querySelectorAll(selector)[index];
359
361
  const pathOf = (n) => {
@@ -665,6 +667,13 @@ function mergeMotion(elements, motion) {
665
667
  * page and excludes live regions (see `stabilize`), so a fetch/stream that
666
668
  * paints after `go()` resolves is captured loaded, not mid-load.
667
669
  */
670
+ /**
671
+ * Harvest the surface's navigable inventory when enabled; `[]` otherwise. Kept out
672
+ * of captureStyleMap so that function stays within the complexity budget.
673
+ */
674
+ async function harvestInventoryFor(page, enabled) {
675
+ return enabled ? classifyInventory(await page.evaluate(collectNavAffordances)) : [];
676
+ }
668
677
  export async function captureStyleMap(page, options = {}) {
669
678
  // Framework/non-visual noise is always skipped, so it can't read as a DOM
670
679
  // change; the caller's `ignore` adds to it (not replaces it).
@@ -730,6 +739,7 @@ export async function captureStyleMap(page, options = {}) {
730
739
  statesSkipped = forced.skipped;
731
740
  }
732
741
  const tokens = await page.evaluate(capturePageTokens);
742
+ const inventory = await harvestInventoryFor(page, options.inventory);
733
743
  return {
734
744
  ...(options.metadata ? { metadata: options.metadata } : {}),
735
745
  defaults: base.defaults,
@@ -740,6 +750,7 @@ export async function captureStyleMap(page, options = {}) {
740
750
  ...(liveCandidates.length ? { liveCandidates } : {}),
741
751
  ...(overlays.length ? { overlays } : {}),
742
752
  ...(Object.keys(tokens).length ? { tokens } : {}),
753
+ ...(inventory.length ? { inventory } : {}),
743
754
  };
744
755
  }
745
756
  /** Write a style map to disk; gzipped when the path ends in `.gz`. */
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
+ export * from './inventory.js';
2
3
  export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
3
4
  export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
4
5
  export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
+ export * from './inventory.js';
2
3
  export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
3
4
  export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
4
5
  export { loadSetupSteps } from './capture-url.js';
@@ -0,0 +1,70 @@
1
+ /** One user-reachable navigation affordance, keyed stably across base/head. */
2
+ export type NavigableItem = {
3
+ /**
4
+ * Stable identity across captures. `route:<pathname><search>` for internal
5
+ * links; `<role>:<slug(name)>` for tabs / menu items / button-only nav — so a
6
+ * tab labelled "MODEL CONFIG" keys as `tab:model-config` regardless of styling,
7
+ * and lines up with an app's own view id.
8
+ */
9
+ key: string;
10
+ kind: 'link' | 'tab' | 'menuitem' | 'nav-button';
11
+ /** Visible accessible name at capture time (for the report). */
12
+ label: string;
13
+ /** Resolved same-origin path, for `kind: 'link'`. */
14
+ href?: string;
15
+ };
16
+ export type InventoryDelta = {
17
+ /** Present on head, absent on base — a newly-offered affordance (informational). */
18
+ added: NavigableItem[];
19
+ /** Present on base, absent on head — a feature the UI stopped offering (gates). */
20
+ removed: NavigableItem[];
21
+ };
22
+ /** `key -> reason` — removals that are intentional, reviewed, and on the record. */
23
+ export type AllowedRemovals = Record<string, string>;
24
+ /** One raw navigable affordance as read from the DOM, before classification. */
25
+ export type RawAffordance = {
26
+ tag: string;
27
+ role: string;
28
+ name: string;
29
+ /** pathname+search for a same-origin `<a href>`; null otherwise. Resolved in-page. */
30
+ internalPath: string | null;
31
+ };
32
+ /** In-page: collect visible navigable affordances. No classification. */
33
+ export declare function collectNavAffordances(): RawAffordance[];
34
+ /** Pure: turn raw affordances into keyed, deduped, sorted navigable items. */
35
+ export declare function classifyInventory(raw: RawAffordance[]): NavigableItem[];
36
+ /** Harvest a page's inventory: thin in-page collect + pure classify. */
37
+ export declare function harvestInventory(page: {
38
+ evaluate: <T>(fn: () => T) => Promise<T>;
39
+ }): Promise<NavigableItem[]>;
40
+ /** Union the per-surface inventories of a whole run into one reachable set. */
41
+ export declare function unionInventory(perSurface: Array<{
42
+ inventory?: NavigableItem[];
43
+ } | undefined>): NavigableItem[];
44
+ /** Base vs head reachable sets → what the UI newly offers / stopped offering. */
45
+ export declare function diffInventory(base: NavigableItem[], head: NavigableItem[]): InventoryDelta;
46
+ /**
47
+ * The gate. Removals that aren't acknowledged in `allowed` (key -> reason) are
48
+ * unexplained — the caller fails on a non-empty result. An `allowed` key that
49
+ * isn't actually removed is a stale acknowledgement, returned separately so the
50
+ * ledger can't quietly rot (mirrors the `exclude` coverage guard).
51
+ */
52
+ export declare function auditRemovals(delta: InventoryDelta, allowed?: AllowedRemovals): {
53
+ unexplained: NavigableItem[];
54
+ staleAllowances: string[];
55
+ };
56
+ /**
57
+ * Run-level entry point: union both sides' per-surface `map.inventory`, diff, and
58
+ * audit removals. This is what a gate calls — pass every base map and every head
59
+ * map (the reachable set is the union across all surfaces). `unexplained` non-empty
60
+ * ⇒ the gate should fail; `staleAllowances` non-empty ⇒ prune the ledger.
61
+ */
62
+ export declare function auditRunInventory(baseMaps: Array<{
63
+ inventory?: NavigableItem[];
64
+ } | undefined>, headMaps: Array<{
65
+ inventory?: NavigableItem[];
66
+ } | undefined>, allowed?: AllowedRemovals): {
67
+ delta: InventoryDelta;
68
+ unexplained: NavigableItem[];
69
+ staleAllowances: string[];
70
+ };
@@ -0,0 +1,135 @@
1
+ // Inventory guard — assert the navigable UI doesn't silently shrink.
2
+ //
3
+ // StyleProof's certification diff answers "did surface X change between base and
4
+ // head?" — a same-key regression check. It is structurally blind to a whole class
5
+ // of high-stakes change: a redesign delivered as a NEW surface beside the old one
6
+ // (the diff is old-vs-old, clean), or a nav item / route that DISAPPEARS (a feature
7
+ // stops being reachable). Those aren't restyles — they're the reachable set of the
8
+ // UI shrinking, which is an information-architecture change the pixel diff catches
9
+ // only incidentally, if at all.
10
+ //
11
+ // This module harvests the *navigable inventory* of each captured surface — the
12
+ // user-reachable affordances (route links, tabs, menu items, button-only SPA nav) —
13
+ // keyed by a stable id, then diffs the UNION across a run. A key present on base but
14
+ // absent on head is a REMOVAL: a feature the UI no longer offers. Removals gate
15
+ // (like the `exclude` coverage ledger) unless explicitly acknowledged with a reason,
16
+ // so "we dropped Model Config" is a decision on the record, never a silent green.
17
+ //
18
+ // The harvest (`collectNavAffordances`) runs in-page, mirroring
19
+ // `detectOverlayCandidates`; the diff/union/guard are pure and unit-testable.
20
+ // ── in-page harvest ───────────────────────────────────────────────────────────
21
+ // Split in two: the DOM-touching half stays thin (and serializable to the page,
22
+ // like detectOverlayCandidates); the classification is pure and unit-testable.
23
+ /** In-page: collect visible navigable affordances. No classification. */
24
+ export function collectNavAffordances() {
25
+ const visible = (el) => {
26
+ if (el.hidden || el.getAttribute('aria-hidden') === 'true')
27
+ return false;
28
+ const r = el.getBoundingClientRect();
29
+ const s = getComputedStyle(el);
30
+ return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
31
+ };
32
+ const nameOf = (el) => (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80);
33
+ const internalPath = (el) => {
34
+ const raw = (el.getAttribute('href') || '').trim();
35
+ if (!raw || raw.startsWith('#'))
36
+ return null;
37
+ try {
38
+ const u = new URL(raw, location.href);
39
+ return u.origin === location.origin ? `${u.pathname}${u.search}` : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ };
45
+ // Semantic nav first (a[href], role=tab/menuitem, <nav>/tablist buttons); then a
46
+ // conservative class heuristic for button-only navs that skip ARIA — a container
47
+ // whose class strongly implies navigation (nav / navtab / subnav / tabs / subtab).
48
+ // Erring broad is correct here: a stray non-nav button is harmless noise, but a
49
+ // MISSED nav item defeats the guard. Prefer semantic markup (role=tablist) for
50
+ // fully reliable harvesting; see docs/inventory-guard.md.
51
+ const SEL = 'a[href], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], nav button, [role="navigation"] button, [role="tablist"] button, [class*="navtab" i] button, [class*="nav-tab" i] button, [class*="subnav" i] button, [class*="subtab" i] button, [class*="tabs" i] button';
52
+ return Array.from(document.querySelectorAll(SEL))
53
+ .filter(visible)
54
+ .map((el) => ({
55
+ tag: el.tagName.toLowerCase(),
56
+ role: (el.getAttribute('role') || '').toLowerCase(),
57
+ name: nameOf(el),
58
+ internalPath: el.tagName === 'A' ? internalPath(el) : null,
59
+ }));
60
+ }
61
+ const slug = (s) => s
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, '-')
64
+ .replace(/^-+|-+$/g, '')
65
+ .slice(0, 60);
66
+ /** Pure: turn raw affordances into keyed, deduped, sorted navigable items. */
67
+ export function classifyInventory(raw) {
68
+ const items = new Map();
69
+ const add = (key, kind, label, href) => {
70
+ if (key && !items.has(key))
71
+ items.set(key, href ? { key, kind, label, href } : { key, kind, label });
72
+ };
73
+ for (const c of raw) {
74
+ if (c.tag === 'a' && c.internalPath) {
75
+ add(`route:${c.internalPath}`, 'link', c.name || c.internalPath, c.internalPath);
76
+ }
77
+ else if (c.name && c.role === 'tab') {
78
+ add(`tab:${slug(c.name)}`, 'tab', c.name);
79
+ }
80
+ else if (c.name && c.role.startsWith('menuitem')) {
81
+ add(`menuitem:${slug(c.name)}`, 'menuitem', c.name);
82
+ }
83
+ else if (c.name && c.tag === 'button') {
84
+ add(`nav-button:${slug(c.name)}`, 'nav-button', c.name);
85
+ }
86
+ }
87
+ return Array.from(items.values()).sort((a, b) => a.key.localeCompare(b.key));
88
+ }
89
+ /** Harvest a page's inventory: thin in-page collect + pure classify. */
90
+ export async function harvestInventory(page) {
91
+ return classifyInventory(await page.evaluate(collectNavAffordances));
92
+ }
93
+ // ── pure diff / union / guard ───────────────────────────────────────────────────
94
+ /** Union the per-surface inventories of a whole run into one reachable set. */
95
+ export function unionInventory(perSurface) {
96
+ const byKey = new Map();
97
+ for (const map of perSurface) {
98
+ for (const item of map?.inventory ?? [])
99
+ if (!byKey.has(item.key))
100
+ byKey.set(item.key, item);
101
+ }
102
+ return Array.from(byKey.values()).sort((a, b) => a.key.localeCompare(b.key));
103
+ }
104
+ /** Base vs head reachable sets → what the UI newly offers / stopped offering. */
105
+ export function diffInventory(base, head) {
106
+ const headKeys = new Set(head.map((i) => i.key));
107
+ const baseKeys = new Set(base.map((i) => i.key));
108
+ return {
109
+ added: head.filter((i) => !baseKeys.has(i.key)),
110
+ removed: base.filter((i) => !headKeys.has(i.key)),
111
+ };
112
+ }
113
+ /**
114
+ * The gate. Removals that aren't acknowledged in `allowed` (key -> reason) are
115
+ * unexplained — the caller fails on a non-empty result. An `allowed` key that
116
+ * isn't actually removed is a stale acknowledgement, returned separately so the
117
+ * ledger can't quietly rot (mirrors the `exclude` coverage guard).
118
+ */
119
+ export function auditRemovals(delta, allowed = {}) {
120
+ const removedKeys = new Set(delta.removed.map((i) => i.key));
121
+ return {
122
+ unexplained: delta.removed.filter((i) => !(i.key in allowed)),
123
+ staleAllowances: Object.keys(allowed).filter((k) => !removedKeys.has(k)),
124
+ };
125
+ }
126
+ /**
127
+ * Run-level entry point: union both sides' per-surface `map.inventory`, diff, and
128
+ * audit removals. This is what a gate calls — pass every base map and every head
129
+ * map (the reachable set is the union across all surfaces). `unexplained` non-empty
130
+ * ⇒ the gate should fail; `staleAllowances` non-empty ⇒ prune the ledger.
131
+ */
132
+ export function auditRunInventory(baseMaps, headMaps, allowed = {}) {
133
+ const delta = diffInventory(unionInventory(baseMaps), unionInventory(headMaps));
134
+ return { delta, ...auditRemovals(delta, allowed) };
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "playwright",