styleproof 3.21.0 → 4.0.1

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.
@@ -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';
package/dist/index.js 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 { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
5
6
  export { loadSetupSteps } from './capture-url.js';
@@ -61,7 +61,12 @@ export declare function expectedCompatibilityKey(options?: {
61
61
  }): string;
62
62
  export declare function currentGitSha(cwd?: string, env?: NodeJS.ProcessEnv): string;
63
63
  export declare function refSha(ref: string, cwd?: string): string;
64
- export declare function workingTreeDirty(cwd?: string): boolean;
64
+ /**
65
+ * True if any tracked file is modified/added/deleted. `ignorePrefix` (a repo-relative
66
+ * directory) is excluded — pass the map OUTPUT dir when re-sampling AFTER a capture, so
67
+ * the maps the capture just wrote don't read as tree dirt and mask a real source edit.
68
+ */
69
+ export declare function workingTreeDirty(cwd?: string, ignorePrefix?: string): boolean;
65
70
  export declare function remoteExists(remote?: string, cwd?: string): boolean;
66
71
  export declare function writeMapManifest(options: {
67
72
  dir: string;
@@ -72,7 +77,38 @@ export declare function writeMapManifest(options: {
72
77
  cwd?: string;
73
78
  env?: NodeJS.ProcessEnv;
74
79
  }): MapManifest;
80
+ /**
81
+ * Write a `styleproof-manifest.json` for a one-shot `styleproof-capture` output dir,
82
+ * so a two-directory `styleproof-diff design <build>` has the same-environment guard
83
+ * on both sides (v4 refuses to compare a manifest-less side). Unlike
84
+ * {@link writeMapManifest}, this may run OUTSIDE a git repo (a design mockup, a static
85
+ * export), so the git-derived fields degrade gracefully: `sha` falls back to
86
+ * `'uncommitted'` and `dirty` to `true` rather than throwing. The parts the guard
87
+ * actually consumes — `compatibilityKey`, `platform`/`arch`/`nodeMajor`,
88
+ * `playwrightVersion`, `browserVersion`, `baseUrl` — are recorded the same way as a
89
+ * spec capture. Overwrites any existing manifest in `dir`.
90
+ */
91
+ export declare function writeCaptureManifest(options: {
92
+ dir: string;
93
+ screenshots: boolean;
94
+ cwd?: string;
95
+ env?: NodeJS.ProcessEnv;
96
+ }): MapManifest;
75
97
  export declare function readMapManifest(dir: string): MapManifest | null;
98
+ /** Which side(s) of a two-directory compare hold captured maps but NO
99
+ * `styleproof-manifest.json` — a legacy committed-map bundle. Since v4 that is
100
+ * unsupported: without a manifest the same-environment guard can't be enforced, so the
101
+ * CLI refuses (exit 2). `null` means every side WITH maps also has a manifest (nothing to
102
+ * refuse). A side with zero maps is NOT flagged — an empty/bare dir is "no baseline yet",
103
+ * handled by the base/head-missing guards, not this one. Pure: presence reads only, so the
104
+ * CLI layer owns the exit code and the library stays side-effect-free. */
105
+ export declare function manifestlessSide(beforeDir: string, afterDir: string): 'before' | 'after' | 'both' | null;
106
+ /** Fail-loud message for a manifest-less compare. Since v4 a side without a
107
+ * `styleproof-manifest.json` is unsupported: the same-environment guard can't be
108
+ * enforced, so captures from different browser builds or platforms would diff as
109
+ * false changes. The CLI raises this and exits 2 (usage/capture error) — the
110
+ * legacy "compare anyway" tolerance is gone. */
111
+ export declare function manifestlessError(side: 'before' | 'after' | 'both'): string;
76
112
  export declare function assertCompatibleMapDirs(beforeDir: string, afterDir: string): void;
77
113
  export declare function publishMapBundle(options: {
78
114
  dir: string;
package/dist/map-store.js CHANGED
@@ -153,28 +153,40 @@ export function refSha(ref, cwd = process.cwd()) {
153
153
  throw new MapStoreError(`could not resolve ${ref} to a commit`);
154
154
  return sha;
155
155
  }
156
- export function workingTreeDirty(cwd = process.cwd()) {
156
+ /**
157
+ * True if any tracked file is modified/added/deleted. `ignorePrefix` (a repo-relative
158
+ * directory) is excluded — pass the map OUTPUT dir when re-sampling AFTER a capture, so
159
+ * the maps the capture just wrote don't read as tree dirt and mask a real source edit.
160
+ */
161
+ export function workingTreeDirty(cwd = process.cwd(), ignorePrefix) {
157
162
  const r = runGit(cwd, ['status', '--porcelain']);
158
163
  const status = r.status === 0 ? r.stdout.trimEnd() : '';
159
164
  if (!status)
160
165
  return false;
166
+ const prefix = ignorePrefix ? `${ignorePrefix.replace(/\/+$/, '')}/` : undefined;
161
167
  return status.split(/\r?\n/).some((line) => {
162
168
  const file = line.slice(3).trim();
163
- return file && !GENERATED_DIRTY_ALLOWLIST.has(file);
169
+ if (!file || GENERATED_DIRTY_ALLOWLIST.has(file))
170
+ return false;
171
+ if (prefix && (file === prefix.slice(0, -1) || file.startsWith(prefix)))
172
+ return false;
173
+ return true;
164
174
  });
165
175
  }
166
176
  export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
167
177
  return runGit(cwd, ['remote', 'get-url', remote], 1 << 20).status === 0;
168
178
  }
169
- export function writeMapManifest(options) {
170
- const cwd = options.cwd ?? process.cwd();
171
- const input = compatibilityInput({ cwd, spec: options.spec, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
172
- const browserVersion = readBrowserBuildSidecar(options.dir);
173
- const manifest = {
179
+ /** Assemble a {@link MapManifest} from the compatibility inputs and the caller-resolved
180
+ * git fields. Shared by {@link writeMapManifest} (spec capture) and
181
+ * {@link writeCaptureManifest} (one-shot capture) so the object shape lives in one place. */
182
+ function buildManifest(options) {
183
+ const { dir, input } = options;
184
+ const browserVersion = readBrowserBuildSidecar(dir);
185
+ return {
174
186
  version: 1,
175
187
  packageVersion: input.packageVersion,
176
- sha: options.sha ?? currentGitSha(cwd, options.env),
177
- dirty: options.dirty ?? workingTreeDirty(cwd),
188
+ sha: options.sha,
189
+ dirty: options.dirty,
178
190
  spec: input.spec,
179
191
  specHash: input.specHash,
180
192
  ...(input.lockfile ? { lockfile: input.lockfile } : {}),
@@ -186,10 +198,49 @@ export function writeMapManifest(options) {
186
198
  nodeMajor: input.nodeMajor,
187
199
  ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
188
200
  screenshots: options.screenshots,
189
- har: hasHar(options.dir),
201
+ har: hasHar(dir),
190
202
  compatibilityKey: hash(JSON.stringify(input)).slice(0, 16),
191
203
  createdAt: new Date().toISOString(),
192
204
  };
205
+ }
206
+ export function writeMapManifest(options) {
207
+ const cwd = options.cwd ?? process.cwd();
208
+ const input = compatibilityInput({ cwd, spec: options.spec, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
209
+ const manifest = buildManifest({
210
+ dir: options.dir,
211
+ input,
212
+ sha: options.sha ?? currentGitSha(cwd, options.env),
213
+ dirty: options.dirty ?? workingTreeDirty(cwd),
214
+ screenshots: options.screenshots,
215
+ });
216
+ fs.writeFileSync(path.join(options.dir, MAP_MANIFEST), JSON.stringify(manifest, null, 2));
217
+ return manifest;
218
+ }
219
+ /**
220
+ * Write a `styleproof-manifest.json` for a one-shot `styleproof-capture` output dir,
221
+ * so a two-directory `styleproof-diff design <build>` has the same-environment guard
222
+ * on both sides (v4 refuses to compare a manifest-less side). Unlike
223
+ * {@link writeMapManifest}, this may run OUTSIDE a git repo (a design mockup, a static
224
+ * export), so the git-derived fields degrade gracefully: `sha` falls back to
225
+ * `'uncommitted'` and `dirty` to `true` rather than throwing. The parts the guard
226
+ * actually consumes — `compatibilityKey`, `platform`/`arch`/`nodeMajor`,
227
+ * `playwrightVersion`, `browserVersion`, `baseUrl` — are recorded the same way as a
228
+ * spec capture. Overwrites any existing manifest in `dir`.
229
+ */
230
+ export function writeCaptureManifest(options) {
231
+ const cwd = options.cwd ?? process.cwd();
232
+ // A one-shot capture has no spec file; key comparability off the capture inputs only.
233
+ const input = compatibilityInput({ cwd, spec: MAP_MANIFEST, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
234
+ // Degrade the git fields gracefully outside a repo (a design mockup): no HEAD → uncommitted/dirty.
235
+ const sha = gitOutput(cwd, ['rev-parse', 'HEAD']) || 'uncommitted';
236
+ const manifest = buildManifest({
237
+ dir: options.dir,
238
+ input,
239
+ sha,
240
+ dirty: sha === 'uncommitted' ? true : workingTreeDirty(cwd),
241
+ screenshots: options.screenshots,
242
+ });
243
+ fs.mkdirSync(options.dir, { recursive: true });
193
244
  fs.writeFileSync(path.join(options.dir, MAP_MANIFEST), JSON.stringify(manifest, null, 2));
194
245
  return manifest;
195
246
  }
@@ -201,6 +252,48 @@ export function readMapManifest(dir) {
201
252
  return null;
202
253
  }
203
254
  }
255
+ /** True if `dir` holds at least one captured surface map (`<key>@<width>.json[.gz]`),
256
+ * ignoring metadata sidecars. Distinguishes a real capture that merely lacks a manifest
257
+ * (a legacy committed-map bundle — v4 refuses it) from an empty/bare dir (no baseline
258
+ * yet — the missing-map guards own that). */
259
+ function dirHasMaps(dir) {
260
+ try {
261
+ return fs.readdirSync(dir).some(isMapFile);
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ }
267
+ /** Which side(s) of a two-directory compare hold captured maps but NO
268
+ * `styleproof-manifest.json` — a legacy committed-map bundle. Since v4 that is
269
+ * unsupported: without a manifest the same-environment guard can't be enforced, so the
270
+ * CLI refuses (exit 2). `null` means every side WITH maps also has a manifest (nothing to
271
+ * refuse). A side with zero maps is NOT flagged — an empty/bare dir is "no baseline yet",
272
+ * handled by the base/head-missing guards, not this one. Pure: presence reads only, so the
273
+ * CLI layer owns the exit code and the library stays side-effect-free. */
274
+ export function manifestlessSide(beforeDir, afterDir) {
275
+ const before = dirHasMaps(beforeDir) && readMapManifest(beforeDir) == null;
276
+ const after = dirHasMaps(afterDir) && readMapManifest(afterDir) == null;
277
+ if (before && after)
278
+ return 'both';
279
+ if (before)
280
+ return 'before';
281
+ if (after)
282
+ return 'after';
283
+ return null;
284
+ }
285
+ /** Fail-loud message for a manifest-less compare. Since v4 a side without a
286
+ * `styleproof-manifest.json` is unsupported: the same-environment guard can't be
287
+ * enforced, so captures from different browser builds or platforms would diff as
288
+ * false changes. The CLI raises this and exits 2 (usage/capture error) — the
289
+ * legacy "compare anyway" tolerance is gone. */
290
+ export function manifestlessError(side) {
291
+ const carry = side === 'both' ? 'before and after carry' : `${side} carries`;
292
+ return (`styleproof: ${carry} no ${MAP_MANIFEST} — environment compatibility can't be verified, so ` +
293
+ 'captures from different browser builds or platforms would diff as false changes. ' +
294
+ 'Re-capture with current StyleProof (styleproof-map, or styleproof-capture for a one-shot ' +
295
+ 'diff); maps without a manifest are unsupported since v4.');
296
+ }
204
297
  export function assertCompatibleMapDirs(beforeDir, afterDir) {
205
298
  const before = readMapManifest(beforeDir);
206
299
  const after = readMapManifest(afterDir);
package/dist/report.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { PNG } from 'pngjs';
4
- import { loadStyleMap, readInventories, surfaceElementPaths, } 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
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';
11
12
  // The pure grouping / classification brain — shared with the CLI. report.ts keeps
12
13
  // the crop-and-PNG rendering on top of these.
13
14
  import { cleanFindings, groupByPath, groupTitle, isNonValue, prettyLabel, safeKey, signatureOf, summarizeProps, surfaceBase, surfaceWidth, pushSurfaceWidth, renderSurfaceGroups, formatSurfaceList, classifyChrome, } from './change-groups.js';
@@ -625,6 +626,29 @@ function readAcknowledgedRemovals() {
625
626
  return {};
626
627
  }
627
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
+ }
628
652
  /**
629
653
  * The certification block a reviewer reads FIRST — the source-of-truth gates (coverage
630
654
  * complete? determinism proven? did the navigable set shrink?), not just the pixel diff.
@@ -634,15 +658,20 @@ function certificationLines(beforeDir, afterDir) {
634
658
  const baseLedger = readLedgerFile(beforeDir);
635
659
  const headLedger = readLedgerFile(afterDir);
636
660
  const inv = auditRunInventory(readInventories(beforeDir), readInventories(afterDir), readAcknowledgedRemovals());
661
+ const res = auditRunResidue(readResidue(afterDir), readAcknowledgedResidue(), headLedger?.dataResidue === 'gate');
637
662
  const hasLedger = baseLedger !== null || headLedger !== null;
638
663
  const hasInvChange = inv.delta.removed.length > 0 || inv.delta.added.length > 0;
639
- if (!hasLedger && !hasInvChange)
664
+ const hasResidue = res.residue.length > 0 || res.armed;
665
+ if (!hasLedger && !hasInvChange && !hasResidue)
640
666
  return [];
641
667
  return [
642
668
  '**Certification**',
643
669
  coverageLine(auditCoverage(surfaceKeysIn(afterDir), headLedger)),
644
670
  determinismLine(auditDeterminism(baseLedger, headLedger)),
645
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)] : []),
646
675
  '',
647
676
  ];
648
677
  }
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;