@ia-qa/self-healing 1.7.11 → 1.7.14

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/README.md CHANGED
@@ -130,6 +130,7 @@ env/SSM — see **Configuration**) or capture-during-run, where your own suite l
130
130
  | day one, no baseline — is my suite still valid? | `audit` (advisory; never gate on it) |
131
131
  | is the app itself sound? | `check` — dead links, unnamed elements, name collisions, orphan pages |
132
132
  | which pages am I *not* testing? | `discover` (`--sitemap`, or `--crawl` behind the login) |
133
+ | **my menus, tabs and modals aren't in the contract** | **`map --deep`** — opens them and puts what they reveal in the same contract |
133
134
  | what do my tests actually use? | `ingest` |
134
135
  | show me the app's structure | `graph --format mermaid\|svg\|json\|markdown` |
135
136
  | is this getting better or worse? | `history` |
@@ -208,6 +209,33 @@ extensions and the code paths are exercised, but the test suite's rewrite cases
208
209
  against `.js`/`.ts` files. Treat non-JS rewriting as working-but-less-travelled: run
209
210
  `fix --dry-run` first, which you should be doing anyway.
210
211
 
212
+ ### One more axis: how much of the page is under contract at all
213
+
214
+ `map` records the state a page **loads in**. Everything behind a dropdown, a tab, an accordion
215
+ or a modal is in no contract — so when it drifts, nothing says so. It is a coverage hole and
216
+ never a false alarm, which is what makes it survive: the gate stays green over a surface it
217
+ never looked at.
218
+
219
+ ```bash
220
+ ia-qa-heal map --deep # open them, and contract what appears
221
+ ```
222
+
223
+ Measured on ia-qa.com: one page 105 → 133 elements, another 126 → 264. Across eight unrelated
224
+ sites (MDN, Bootstrap, Material UI, GOV.UK, Wikipedia, Docusaurus…), seven gained elements —
225
+ the gain is largest on component-framework apps and near zero on content sites, so it is worth
226
+ saying which one you have before reaching for it.
227
+
228
+ - **It cannot mutate your app.** Every non-GET request is aborted for the walk, and controls
229
+ named like actions (*log out*, *delete*, *pay*…) are never clicked — a logout handler that
230
+ only clears local storage sends no request for the network guard to catch.
231
+ - **What it reaches but cannot contract is reported**, not hidden (`↩ N left out`): a selector
232
+ must name exactly one element, and a positional one captured inside a menu can collide with
233
+ the page behind it.
234
+ - **A deep baseline is only comparable to a deep capture.** Mixing the two reports everything
235
+ behind a menu as `lost`; `diff` and the MCP both warn before the verdict.
236
+ - **~1 minute per page** against seconds for a plain `map`. It is for building a baseline, not
237
+ for a per-commit gate.
238
+
211
239
  ## The verdict
212
240
 
213
241
  ```
package/TUTORIAL.md CHANGED
@@ -11,7 +11,7 @@
11
11
  2. [The 30-second mental model](#2-the-30-second-mental-model)
12
12
  3. [Before you start](#3-before-you-start)
13
13
  4. [Path A — I use an AI agent (easiest)](#4-path-a--i-use-an-ai-agent-easiest)
14
- 5. [Path B — I use a terminal](#5-path-b--i-use-a-terminal)
14
+ 5. [Path B — I use a terminal](#5-path-b--i-use-a-terminal) — including [menus, tabs and modals: `map --deep`](#step-53b--what-about-my-menus-tabs-and-modals-map---deep)
15
15
  6. [Your first real workflow](#6-your-first-real-workflow-the-whole-point) — including [a test just failed, is it drift?](#step-64b--you-didnt-run-diff-you-got-a-red-build--explain) · [the console, if you don't want a terminal](#step-68--no-terminal-open-the-console)
16
16
  7. [Reading the verdict](#7-reading-the-verdict)
17
17
  8. [Putting it in CI](#8-putting-it-in-ci) — [JUnit trends](#82-trend-charts-for-free--junit) · [the run history](#83-the-trend--ia-qa-heal-history)
@@ -344,6 +344,48 @@ Plus, for the whole app:
344
344
 
345
345
  Commit them.
346
346
 
347
+ ### Step 5.3b — What about my menus, tabs and modals? `map --deep`
348
+
349
+ A page has more than one state, and `map` records the one it loads in. Everything behind a
350
+ dropdown, a tab, an accordion or a dialog is simply not there — so if it drifts, nothing tells
351
+ you. It is a **coverage** hole rather than a false alarm, which is what makes it easy to miss:
352
+ the gate stays green over a surface it never looked at.
353
+
354
+ ```bash
355
+ npx ia-qa-heal map --deep
356
+ ```
357
+
358
+ It opens what can be opened and contracts what appears. On ia-qa.com: `/method` 105 → 133
359
+ elements, the home page 126 → 264.
360
+
361
+ ```
362
+ ✔ 264 interactive elements → .ia-qa/mapping/home.json + .ia-qa/mapping/home.md
363
+ 🔍 deep: +140 behind interaction (124 on load) · 37 states · 54 clicks, 9 reloads
364
+ ↩ 133 revealed elements left out — selector already names another element
365
+ 🛡 34 non-GET requests blocked during exploration (nothing mutating left the browser)
366
+ ```
367
+
368
+ Four things worth knowing before you use it:
369
+
370
+ **It cannot break your app.** Every non-GET request is aborted for the whole walk, so a click
371
+ that would have deleted, paid or submitted anything never reaches your server — and controls
372
+ whose name reads like an action (*log out*, *delete*, *pay*…) are not clicked at all, because a
373
+ logout handler that just clears local storage sends no request for the guard to stop.
374
+
375
+ **Elements it reaches but cannot contract are reported, not hidden** (`↩ 133 left out`). A
376
+ selector must name exactly one element; a positional one captured inside a menu can collide with
377
+ an element on the page behind it, and a contract holding the same selector twice makes the diff
378
+ pair rows at random. Those are dropped and counted.
379
+
380
+ **A deep baseline may only be diffed against a deep capture.** Mixing them reports everything
381
+ behind a menu as `lost` — the tool warns you before the verdict, in the CLI and over MCP alike.
382
+ Decide once, per baseline.
383
+
384
+ **It costs about a minute per page** where a plain `map` costs seconds. Use it to build or
385
+ refresh a baseline, not on every commit. `--depth 2` goes one click deeper; it was measured at
386
+ three times the time for a contract that never finishes, so leave it alone unless you have a
387
+ reason.
388
+
347
389
  ### Step 5.4 — Day one, before any drift: `audit`
348
390
 
349
391
  Everything after this point pays off at the *next* UI change. `audit` pays off now: it asks **does my test suite still name things that exist?**
@@ -858,6 +900,7 @@ npx ia-qa-heal init # one-time setup
858
900
  npx ia-qa-heal discover [--crawl] [--apply] # optional: propose pages for config.json (sitemap or safe crawl)
859
901
  npx ia-qa-heal map # scan → *.json + *.md + _overview.md + _navigation.md/.svg
860
902
  npx ia-qa-heal map --screenshots # + one JPEG per page, shown in the --report HTML (gitignore _shots/)
903
+ npx ia-qa-heal map --deep # + open menus/tabs/dialogs and contract what they reveal (see below)
861
904
  npx ia-qa-heal baseline # promote mapping/ → baseline/ (commit it)
862
905
  # …change the app, then `map` again…
863
906
  npx ia-qa-heal diff # verdict: PASS / FIX / BLOCK (baseline/ vs mapping/)
package/dist/aom.d.ts CHANGED
@@ -34,6 +34,20 @@ export interface MappedElement {
34
34
  */
35
35
  rawName?: string;
36
36
  nameMasked?: boolean;
37
+ /**
38
+ * How to reach this element, when reaching it takes clicks: the accessible names of the
39
+ * controls opened on the way (`["☰", "RAG & Agents"]`). Written by `map --deep` only, and
40
+ * omitted on everything visible at load — so a contract captured without exploration stays
41
+ * byte-identical to one captured before `via` existed.
42
+ *
43
+ * Never part of identity. `diffMappings` matches on role + name + selector and must keep
44
+ * doing so: an element that moved from behind a menu into the open is the *same* element,
45
+ * and a diff that called that drift would report a UX improvement as a regression.
46
+ *
47
+ * Its reader is a person or an agent asking "how do I even get to this button" — which is
48
+ * the question a deep contract creates and owes an answer to.
49
+ */
50
+ via?: string[];
37
51
  }
38
52
  export interface PageMapping {
39
53
  page: string;
@@ -52,6 +66,17 @@ export interface PageMapping {
52
66
  * means *unknown*, never *matching*.
53
67
  */
54
68
  captureState?: CaptureState;
69
+ /**
70
+ * How many clicks deep this contract was explored (`map --deep`). Absent means the loaded
71
+ * state only — every contract written before exploration existed, which is why absent must
72
+ * never be read as `0`-and-therefore-comparable-to-a-deep-one on purpose.
73
+ *
74
+ * It is recorded for the same reason `source` is: a deep baseline diffed against a shallow
75
+ * capture reports every explored element as `lost`, which is drift manufactured out of the
76
+ * capture method rather than out of the app. Depth is a property of a *baseline*, and a
77
+ * mismatch has to be nameable.
78
+ */
79
+ exploredDepth?: number;
55
80
  }
56
81
  /**
57
82
  * Did these two mappings come out of the same `map` run?
@@ -77,6 +102,35 @@ export declare function isSameCapture(baseline: {
77
102
  } | null | undefined, current: {
78
103
  capturedAt?: string;
79
104
  } | null | undefined): boolean;
105
+ /**
106
+ * Were these two contracts explored to the same depth?
107
+ *
108
+ * A deep contract holds the elements behind menus, tabs and dialogs; a shallow one cannot, so
109
+ * diffing one against the other reports every one of them as `lost`. That is not a handful of
110
+ * noisy rows — most of a page's surface can sit behind interaction, so the mismatch manufactures
111
+ * the whole BLOCK out of how the two captures were taken.
112
+ *
113
+ * Absent means depth 0 — the loaded state only. That is a *known* value, not an unknown one:
114
+ * every contract written before `--deep` existed describes exactly one state. (`captureState` is
115
+ * the opposite case, where absent genuinely means "cannot tell", and must never read as a match.)
116
+ *
117
+ * Lives here, beside `isSameCapture`, for the same reason: both doors into a diff — the CLI and
118
+ * the MCP server — have to agree on what is not a comparison. One warning and the other silent
119
+ * would just move the phantom verdict to whichever door the user knocked on.
120
+ */
121
+ export declare function depthMismatch(baseline: {
122
+ exploredDepth?: number;
123
+ } | null | undefined, current: {
124
+ exploredDepth?: number;
125
+ } | null | undefined): {
126
+ baseline: number;
127
+ current: number;
128
+ } | null;
129
+ /** The sentence every door prints or attaches when `depthMismatch` returns non-null. */
130
+ export declare function depthMismatchMessage(m: {
131
+ baseline: number;
132
+ current: number;
133
+ }): string;
80
134
  export declare function extractInteractiveElements(page: Page): Promise<MappedElement[]>;
81
135
  export declare function saveMapping(mapping: PageMapping, dir?: string): string;
82
136
  export declare function loadMapping(pageName: string, dir?: string): PageMapping;
package/dist/aom.js CHANGED
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.isSameCapture = isSameCapture;
37
+ exports.depthMismatch = depthMismatch;
38
+ exports.depthMismatchMessage = depthMismatchMessage;
37
39
  exports.extractInteractiveElements = extractInteractiveElements;
38
40
  exports.saveMapping = saveMapping;
39
41
  exports.loadMapping = loadMapping;
@@ -63,6 +65,36 @@ const extract_1 = require("./browser/extract");
63
65
  function isSameCapture(baseline, current) {
64
66
  return Boolean(baseline?.capturedAt) && baseline.capturedAt === current?.capturedAt;
65
67
  }
68
+ /**
69
+ * Were these two contracts explored to the same depth?
70
+ *
71
+ * A deep contract holds the elements behind menus, tabs and dialogs; a shallow one cannot, so
72
+ * diffing one against the other reports every one of them as `lost`. That is not a handful of
73
+ * noisy rows — most of a page's surface can sit behind interaction, so the mismatch manufactures
74
+ * the whole BLOCK out of how the two captures were taken.
75
+ *
76
+ * Absent means depth 0 — the loaded state only. That is a *known* value, not an unknown one:
77
+ * every contract written before `--deep` existed describes exactly one state. (`captureState` is
78
+ * the opposite case, where absent genuinely means "cannot tell", and must never read as a match.)
79
+ *
80
+ * Lives here, beside `isSameCapture`, for the same reason: both doors into a diff — the CLI and
81
+ * the MCP server — have to agree on what is not a comparison. One warning and the other silent
82
+ * would just move the phantom verdict to whichever door the user knocked on.
83
+ */
84
+ function depthMismatch(baseline, current) {
85
+ const b = baseline?.exploredDepth ?? 0;
86
+ const c = current?.exploredDepth ?? 0;
87
+ return b === c ? null : { baseline: b, current: c };
88
+ }
89
+ /** The sentence every door prints or attaches when `depthMismatch` returns non-null. */
90
+ function depthMismatchMessage(m) {
91
+ const deeper = m.baseline > m.current ? 'baseline' : 'current capture';
92
+ const shallower = deeper === 'baseline' ? 'current capture' : 'baseline';
93
+ return (`Exploration depth mismatch — the ${deeper} was captured with \`map --deep\`, the ${shallower} ` +
94
+ `was not (depth ${m.baseline} vs ${m.current}). A deep contract holds the elements behind ` +
95
+ `menus, tabs and dialogs; a shallow one cannot, so those elements read as \`lost\` here. Most ` +
96
+ `of this verdict is probably that, not drift in your app. Capture both sides the same way.`);
97
+ }
66
98
  async function extractInteractiveElements(page) {
67
99
  return page.evaluate(extract_1.extractInPage);
68
100
  }
package/dist/aom.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"aom.js","sourceRoot":"","sources":["../src/aom.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFA,sCAKC;AAED,gEAEC;AAED,kCAwBC;AAED,kCAQC;AA/HD,uCAAyB;AACzB,2CAA6B;AAE7B,qCAAmD;AACnD,+CAAkD;AA2DlD;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,aAAa,CAC3B,QAAoD,EACpD,OAAmD;IAEnD,OAAO,OAAO,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,QAAS,CAAC,UAAU,KAAK,OAAO,EAAE,UAAU,CAAC;AACvF,CAAC;AAEM,KAAK,UAAU,0BAA0B,CAAC,IAAU;IACzD,OAAO,IAAI,CAAC,QAAQ,CAAC,uBAAa,CAA6B,CAAC;AAClE,CAAC;AAED,SAAgB,WAAW,CAAC,OAAoB,EAAE,MAAc,IAAA,mBAAU,GAAE;IAC1E,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,IAAA,oBAAW,EAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC5C,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACxB,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAgB,CAAC;QACtE,IAAI,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,UAAU,KAAK,OAAO,CAAC,UAAU,EAAE,CAAC;YAC9D,OAAO,CAAC,IAAI,CAAC,MAAM,OAAO,CAAC,IAAI,8BAA8B,IAAI,CAAC,UAAU,iBAAiB,CAAC,CAAC;QACjG,CAAC;IACH,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;IACxD,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IAExC,KAAK,MAAM,KAAK,IAAI,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,EAAE,CAAC;QACxC,IAAI,KAAK,KAAK,GAAG,OAAO,CAAC,IAAI,OAAO;YAAE,SAAS;QAC/C,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QAChE,IAAI,CAAC;YACH,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,OAAO,EAAE,CAAC;gBAC/D,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;gBAC3C,OAAO,CAAC,IAAI,CAAC,MAAM,OAAO,CAAC,IAAI,wBAAwB,KAAK,oCAAoC,CAAC,CAAC;YACpG,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,qBAAqB,CAAC,CAAC;IACnC,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,WAAW,CAAC,QAAgB,EAAE,MAAc,IAAA,mBAAU,GAAE;IACtE,MAAM,IAAI,GAAG,IAAA,oBAAW,EAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IACxC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,8BAA8B,QAAQ,MAAM,IAAI,2BAA2B,QAAQ,WAAW,CAC/F,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAgB,CAAC;AAClE,CAAC"}
1
+ {"version":3,"file":"aom.js","sourceRoot":"","sources":["../src/aom.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2GA,sCAKC;AAkBD,sCAOC;AAGD,oDASC;AAED,gEAEC;AAED,kCAwBC;AAED,kCAQC;AA7LD,uCAAyB;AACzB,2CAA6B;AAE7B,qCAAmD;AACnD,+CAAkD;AAoFlD;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,aAAa,CAC3B,QAAoD,EACpD,OAAmD;IAEnD,OAAO,OAAO,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,QAAS,CAAC,UAAU,KAAK,OAAO,EAAE,UAAU,CAAC;AACvF,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,SAAgB,aAAa,CAC3B,QAAuD,EACvD,OAAsD;IAEtD,MAAM,CAAC,GAAG,QAAQ,EAAE,aAAa,IAAI,CAAC,CAAC;IACvC,MAAM,CAAC,GAAG,OAAO,EAAE,aAAa,IAAI,CAAC,CAAC;IACtC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;AACtD,CAAC;AAED,wFAAwF;AACxF,SAAgB,oBAAoB,CAAC,CAAwC;IAC3E,MAAM,MAAM,GAAG,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,iBAAiB,CAAC;IACvE,MAAM,SAAS,GAAG,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,UAAU,CAAC;IACzE,OAAO,CACL,oCAAoC,MAAM,0CAA0C,SAAS,GAAG;QAChG,kBAAkB,CAAC,CAAC,QAAQ,OAAO,CAAC,CAAC,OAAO,+CAA+C;QAC3F,+FAA+F;QAC/F,2FAA2F,CAC5F,CAAC;AACJ,CAAC;AAEM,KAAK,UAAU,0BAA0B,CAAC,IAAU;IACzD,OAAO,IAAI,CAAC,QAAQ,CAAC,uBAAa,CAA6B,CAAC;AAClE,CAAC;AAED,SAAgB,WAAW,CAAC,OAAoB,EAAE,MAAc,IAAA,mBAAU,GAAE;IAC1E,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,IAAA,oBAAW,EAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC5C,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACxB,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAgB,CAAC;QACtE,IAAI,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,UAAU,KAAK,OAAO,CAAC,UAAU,EAAE,CAAC;YAC9D,OAAO,CAAC,IAAI,CAAC,MAAM,OAAO,CAAC,IAAI,8BAA8B,IAAI,CAAC,UAAU,iBAAiB,CAAC,CAAC;QACjG,CAAC;IACH,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;IACxD,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IAExC,KAAK,MAAM,KAAK,IAAI,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,EAAE,CAAC;QACxC,IAAI,KAAK,KAAK,GAAG,OAAO,CAAC,IAAI,OAAO;YAAE,SAAS;QAC/C,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QAChE,IAAI,CAAC;YACH,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,OAAO,EAAE,CAAC;gBAC/D,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;gBAC3C,OAAO,CAAC,IAAI,CAAC,MAAM,OAAO,CAAC,IAAI,wBAAwB,KAAK,oCAAoC,CAAC,CAAC;YACpG,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,qBAAqB,CAAC,CAAC;IACnC,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,WAAW,CAAC,QAAgB,EAAE,MAAc,IAAA,mBAAU,GAAE;IACtE,MAAM,IAAI,GAAG,IAAA,oBAAW,EAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IACxC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,8BAA8B,QAAQ,MAAM,IAAI,2BAA2B,QAAQ,WAAW,CAC/F,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAgB,CAAC;AAClE,CAAC"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * In-page finder for controls that *reveal* elements — the widened half of discovery.
3
+ *
4
+ * Self-contained by the same rule as `browser/extract.js` and `browser/discover.js`:
5
+ * Playwright serialises it with `toString()` into `page.evaluate()`, so no imports, no
6
+ * closures, no TypeScript. Unlike those two it is **npm-only** — the web tool imports
7
+ * `match.js`, `contract.js` and `extract.js` and nothing else, so a change here owes one
8
+ * publish, not two.
9
+ *
10
+ * ## Why this exists next to `discover.js` rather than replacing it
11
+ *
12
+ * `findRevealTargets` answers "what is safe to open **while crawling for links**". It is
13
+ * deliberately ARIA-only: a crawler that clicks a bare `<button>` on someone's app to find
14
+ * an `href` is trading a large risk for a small reward. That restraint is correct there and
15
+ * must not be loosened — `crawl.ts` keeps using it.
16
+ *
17
+ * This function answers a different question: "what is safe to open **to complete the
18
+ * contract of this page**". The reward is the whole feature — a contract that describes one
19
+ * state of a page describes an application the way a screenshot describes a film. Measured
20
+ * on a React site with no ARIA disclosure attributes at all, the closed state held 105
21
+ * elements and the reachable surface held 303. `findRevealTargets` returns 0 there, because
22
+ * `aria-expanded` is documentation the app never wrote.
23
+ *
24
+ * So the candidate set is widened to bare `<button>`, `[role=tab]` and `[onclick]`, and the
25
+ * safety is moved from *what we are willing to click* to *what a click is allowed to do*:
26
+ * `explore.ts` blocks every non-GET request for the duration. That is a stronger guarantee
27
+ * than an attribute allowlist, because it holds against the app rather than against its
28
+ * markup, and it is the reason widening here is defensible.
29
+ *
30
+ * What is still refused, and why each one:
31
+ * - a navigating `<a href>` — that is navigation, and the page it reaches has a contract
32
+ * of its own. Following it here would fold two pages into one.
33
+ * - `type="submit"` / `type="reset"`, and a bare `<button>` inside a `<form>` (which
34
+ * submits by default) — a blocked POST still leaves the app showing an error state, and
35
+ * a contract captured over an error banner is a lie about the page.
36
+ * - an accessible name that reads like an action (log out, delete, pay…). The network
37
+ * guard already stops the server-side effect; this stops the *client-side* one — a
38
+ * `logout` handler that clears localStorage and re-renders needs no request at all.
39
+ * - `aria-expanded="true"` — already open. Clicking that collapses, which removes elements
40
+ * instead of revealing them.
41
+ */
42
+ export function findOpenables(): {
43
+ handle: string;
44
+ name: any;
45
+ }[];
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findOpenables = findOpenables;
4
+ /**
5
+ * In-page finder for controls that *reveal* elements — the widened half of discovery.
6
+ *
7
+ * Self-contained by the same rule as `browser/extract.js` and `browser/discover.js`:
8
+ * Playwright serialises it with `toString()` into `page.evaluate()`, so no imports, no
9
+ * closures, no TypeScript. Unlike those two it is **npm-only** — the web tool imports
10
+ * `match.js`, `contract.js` and `extract.js` and nothing else, so a change here owes one
11
+ * publish, not two.
12
+ *
13
+ * ## Why this exists next to `discover.js` rather than replacing it
14
+ *
15
+ * `findRevealTargets` answers "what is safe to open **while crawling for links**". It is
16
+ * deliberately ARIA-only: a crawler that clicks a bare `<button>` on someone's app to find
17
+ * an `href` is trading a large risk for a small reward. That restraint is correct there and
18
+ * must not be loosened — `crawl.ts` keeps using it.
19
+ *
20
+ * This function answers a different question: "what is safe to open **to complete the
21
+ * contract of this page**". The reward is the whole feature — a contract that describes one
22
+ * state of a page describes an application the way a screenshot describes a film. Measured
23
+ * on a React site with no ARIA disclosure attributes at all, the closed state held 105
24
+ * elements and the reachable surface held 303. `findRevealTargets` returns 0 there, because
25
+ * `aria-expanded` is documentation the app never wrote.
26
+ *
27
+ * So the candidate set is widened to bare `<button>`, `[role=tab]` and `[onclick]`, and the
28
+ * safety is moved from *what we are willing to click* to *what a click is allowed to do*:
29
+ * `explore.ts` blocks every non-GET request for the duration. That is a stronger guarantee
30
+ * than an attribute allowlist, because it holds against the app rather than against its
31
+ * markup, and it is the reason widening here is defensible.
32
+ *
33
+ * What is still refused, and why each one:
34
+ * - a navigating `<a href>` — that is navigation, and the page it reaches has a contract
35
+ * of its own. Following it here would fold two pages into one.
36
+ * - `type="submit"` / `type="reset"`, and a bare `<button>` inside a `<form>` (which
37
+ * submits by default) — a blocked POST still leaves the app showing an error state, and
38
+ * a contract captured over an error banner is a lie about the page.
39
+ * - an accessible name that reads like an action (log out, delete, pay…). The network
40
+ * guard already stops the server-side effect; this stops the *client-side* one — a
41
+ * `logout` handler that clears localStorage and re-renders needs no request at all.
42
+ * - `aria-expanded="true"` — already open. Clicking that collapses, which removes elements
43
+ * instead of revealing them.
44
+ */
45
+ function findOpenables() {
46
+ // Matched against the accessible name, English + French. Word-bounded so "cancellation
47
+ // policy" is not read as "cancel" — but `\b` does not fire on accented letters in some
48
+ // engines, so the French verbs are also matched bare.
49
+ var ACTION_RE = new RegExp('\\b(log\\s?out|sign\\s?out|logout|signout|delete|remove|destroy|discard|deactivate' +
50
+ '|disable|unsubscribe|revoke|buy|pay|payment|checkout|purchase|order|submit|send' +
51
+ '|confirm|approve|reject|save|reset|publish|cancel|upgrade|subscribe|renew)\\b' +
52
+ '|supprimer|effacer|payer|acheter|commander|envoyer|valider|confirmer|annuler' +
53
+ '|d[ée]connexion|se d[ée]connecter|d[ée]sabonner|r[ée]initialiser|enregistrer', 'i');
54
+ var OPENABLE_SELECTOR = [
55
+ 'button',
56
+ '[role="button"]',
57
+ '[role="tab"]',
58
+ '[role="menuitem"]',
59
+ 'summary',
60
+ '[aria-haspopup]',
61
+ '[aria-expanded]',
62
+ '[aria-controls]',
63
+ '[onclick]',
64
+ ].join(', ');
65
+ function isVisible(el) {
66
+ var rect = el.getBoundingClientRect();
67
+ if (rect.width === 0 || rect.height === 0)
68
+ return false;
69
+ var style = window.getComputedStyle(el);
70
+ return style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0';
71
+ }
72
+ function accName(el) {
73
+ var label = el.getAttribute('aria-label');
74
+ if (label)
75
+ return label.trim().replace(/\s+/g, ' ');
76
+ var labelledBy = el.getAttribute('aria-labelledby');
77
+ if (labelledBy) {
78
+ var joined = labelledBy
79
+ .split(/\s+/)
80
+ .map(function (id) {
81
+ var n = document.getElementById(id);
82
+ return n ? n.textContent : '';
83
+ })
84
+ .join(' ')
85
+ .trim();
86
+ if (joined)
87
+ return joined.replace(/\s+/g, ' ');
88
+ }
89
+ var text = (el.textContent || '').trim().replace(/\s+/g, ' ');
90
+ return text || (el.getAttribute('title') || '').trim();
91
+ }
92
+ var found = document.querySelectorAll(OPENABLE_SELECTOR);
93
+ var out = [];
94
+ var i = 0;
95
+ for (var k = 0; k < found.length; k++) {
96
+ var el = found[k];
97
+ var tag = el.tagName.toLowerCase();
98
+ if (!isVisible(el))
99
+ continue;
100
+ if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true')
101
+ continue;
102
+ if (tag === 'a') {
103
+ var href = el.getAttribute('href');
104
+ if (href && href !== '#' && href.indexOf('javascript:') !== 0)
105
+ continue;
106
+ }
107
+ if (tag === 'input')
108
+ continue;
109
+ var type = (el.getAttribute('type') || '').toLowerCase();
110
+ if (type === 'submit' || type === 'reset')
111
+ continue;
112
+ if (tag === 'button' && el.closest('form') && type !== 'button')
113
+ continue;
114
+ if (el.getAttribute('aria-expanded') === 'true')
115
+ continue;
116
+ var name = accName(el);
117
+ if (ACTION_RE.test(name))
118
+ continue;
119
+ // A stamped attribute is the one selector guaranteed to re-find this exact element,
120
+ // and adding a data attribute changes nothing the app reacts to. The stamp does not
121
+ // survive a reload, which is why `explore.ts` re-stamps after every reset.
122
+ el.setAttribute('data-iaqa-open', String(i));
123
+ out.push({ handle: '[data-iaqa-open="' + i + '"]', name: name.slice(0, 80) });
124
+ i++;
125
+ }
126
+ return out;
127
+ }
128
+ //# sourceMappingURL=openables.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openables.js","sourceRoot":"","sources":["../../src/browser/openables.js"],"names":[],"mappings":"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"}
package/dist/cli/args.js CHANGED
@@ -18,7 +18,7 @@ exports.KNOWN_FLAGS = {
18
18
  init: ['--yes', '--base-url', '--pages', '--discover', '--tests', '--test-command', '--bootstrap'],
19
19
  discover: ['--sitemap', '--crawl', '--no-reveal', '--strict-host', '--apply', '--max', '--depth', '--session'],
20
20
  login: ['--url', '--session'],
21
- map: ['--screenshots', '--session'],
21
+ map: ['--screenshots', '--session', '--deep', '--depth', '--deep-budget'],
22
22
  baseline: ['--dry-run'],
23
23
  diff: ['--strict', '--json', '--junit', '--dir', '--report', '--open'],
24
24
  fix: ['--dry-run', '--locators', '--dir', '--json'],
@@ -1 +1 @@
1
- {"version":3,"file":"args.js","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":";;;AAgCA,gDAeC;AA/CD;;;;;;;;;;;GAWG;AACU,QAAA,WAAW,GAAsC;IAC5D,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,gBAAgB,EAAE,aAAa,CAAC;IAClG,QAAQ,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC;IAC9G,KAAK,EAAE,CAAC,OAAO,EAAE,WAAW,CAAC;IAC7B,GAAG,EAAE,CAAC,eAAe,EAAE,WAAW,CAAC;IACnC,QAAQ,EAAE,CAAC,WAAW,CAAC;IACvB,IAAI,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IACtE,GAAG,EAAE,CAAC,WAAW,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,CAAC;IACnD,MAAM,EAAE,CAAC,QAAQ,CAAC;IAClB,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC7B,OAAO,EAAE,CAAC,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,QAAQ,CAAC;IACvD,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,eAAe,CAAC;IAC3D,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC;IAChD,GAAG,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC;IACtF,KAAK,EAAE,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC;IAC7D,OAAO,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC;IAC9B,YAAY,EAAE,CAAC,QAAQ,CAAC;IACxB,EAAE,EAAE,CAAC,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,CAAC;CACnF,CAAC;AAEF,SAAgB,kBAAkB,CAAC,OAAe,EAAE,IAAc;IAChE,MAAM,KAAK,GAAG,mBAAW,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO;IAChC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QACjC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,SAAS;QAChC,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CACb,eAAe,OAAO,0FAA0F;gBAC9G,4GAA4G;gBAC5G,8FAA8F,CACjG,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,qBAAqB,OAAO,8CAA8C,CAAC,CAAC;IAChH,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"args.js","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":";;;AAgCA,gDAeC;AA/CD;;;;;;;;;;;GAWG;AACU,QAAA,WAAW,GAAsC;IAC5D,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,gBAAgB,EAAE,aAAa,CAAC;IAClG,QAAQ,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC;IAC9G,KAAK,EAAE,CAAC,OAAO,EAAE,WAAW,CAAC;IAC7B,GAAG,EAAE,CAAC,eAAe,EAAE,WAAW,EAAE,QAAQ,EAAE,SAAS,EAAE,eAAe,CAAC;IACzE,QAAQ,EAAE,CAAC,WAAW,CAAC;IACvB,IAAI,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IACtE,GAAG,EAAE,CAAC,WAAW,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,CAAC;IACnD,MAAM,EAAE,CAAC,QAAQ,CAAC;IAClB,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC7B,OAAO,EAAE,CAAC,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,QAAQ,CAAC;IACvD,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,eAAe,CAAC;IAC3D,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC;IAChD,GAAG,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC;IACtF,KAAK,EAAE,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC;IAC7D,OAAO,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC;IAC9B,YAAY,EAAE,CAAC,QAAQ,CAAC;IACxB,EAAE,EAAE,CAAC,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,CAAC;CACnF,CAAC;AAEF,SAAgB,kBAAkB,CAAC,OAAe,EAAE,IAAc;IAChE,MAAM,KAAK,GAAG,mBAAW,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO;IAChC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QACjC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,SAAS;QAChC,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CACb,eAAe,OAAO,0FAA0F;gBAC9G,4GAA4G;gBAC5G,8FAA8F,CACjG,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,qBAAqB,OAAO,8CAA8C,CAAC,CAAC;IAChH,CAAC;AACH,CAAC"}
@@ -122,6 +122,21 @@ export interface DirDiffCollection {
122
122
  * qualifies, which is the ordinary case and costs nothing.
123
123
  */
124
124
  systemic: SystemicOutcome;
125
+ /**
126
+ * The two sides were explored to different depths (`map --deep`), when either declares one.
127
+ *
128
+ * A deep baseline diffed against a shallow capture reports every element behind a menu as
129
+ * `lost` — a BLOCK over an app where nothing changed, manufactured entirely by how the two
130
+ * contracts were captured. It is the same class as `run`'s source mismatch, and it is worse:
131
+ * a deep contract can hold more elements behind interaction than in front of it, so the
132
+ * false verdict is not a handful of rows but most of the page.
133
+ *
134
+ * `null` when both sides agree (including both absent — two shallow contracts).
135
+ */
136
+ depthMismatch: {
137
+ baseline: number;
138
+ current: number;
139
+ } | null;
125
140
  }
126
141
  /**
127
142
  * A broken binding whose element survived is repairable — FIX. Everything else is a
@@ -152,6 +167,16 @@ export interface DirReport {
152
167
  totals: Report['counts'];
153
168
  /** Pairs left out of the verdict: same capture on both sides, so incapable of showing drift. */
154
169
  staleExcluded?: string[];
170
+ /**
171
+ * The two sides were explored to different depths (`map --deep`) — so much of this verdict is
172
+ * probably an artefact of how the contracts were captured rather than drift in the app.
173
+ * Carried on the aggregate so `--json` and the report state it, not only the terminal.
174
+ */
175
+ depthMismatch?: {
176
+ baseline: number;
177
+ current: number;
178
+ why: string;
179
+ };
155
180
  /**
156
181
  * Renamed labels the suite locates by name, de-duped across pages — a shared
157
182
  * header renames once, not once per page that carries it.
package/dist/cli/diff.js CHANGED
@@ -365,6 +365,11 @@ async function runDiff(args) {
365
365
  refuseToCompare('same-capture', sameCaptureMessage(baseline.capturedAt), 'Change the app and run `ia-qa-heal map` again, so the two sides are different moments.', json);
366
366
  return;
367
367
  }
368
+ // Before the verdict, never after: a depth mismatch does not colour a few rows, it can invent
369
+ // the entire BLOCK. Same check as `diff --dir` and the MCP — one shared function, three doors.
370
+ const depths = (0, aom_1.depthMismatch)(baseline, current);
371
+ if (depths && !json)
372
+ console.error(`\n⚠️ ${(0, aom_1.depthMismatchMessage)(depths)}`);
368
373
  const usage = (0, ingest_1.loadUsage)();
369
374
  const report = attachBaselineHrefs((0, nameMask_1.diffWithMasks)(baseline, current, (0, nameMask_1.loadNameMasks)(), usage), baseline);
370
375
  // Same rule as the directory walk: the resolutions sit next to the mappings they
@@ -547,7 +552,7 @@ async function runDirDiff(baselineDir, currentDir, opts) {
547
552
  console.error(`❌ "${currentDir}" is not a directory.`);
548
553
  process.exit(2);
549
554
  }
550
- const { reports, stale, systemic } = collectDirDiff(baselineDir, currentDir);
555
+ const { reports, stale, systemic, depthMismatch: depths } = collectDirDiff(baselineDir, currentDir);
551
556
  if (reports.length === 0 && stale.length > 0) {
552
557
  refuseToCompare('same-capture', `❌ Nothing to compare: ${stale.length === 1 ? 'the only pair is' : `all ${stale.length} pairs are`} ` +
553
558
  `the same capture on both sides.\n\n` +
@@ -562,10 +567,23 @@ async function runDirDiff(baselineDir, currentDir, opts) {
562
567
  refuseToCompare('no-pairs', `❌ No matching mapping pairs found between ${rel(baselineDir)} and ${rel(currentDir)}.`, 'Mapping files are paired by filename — run `ia-qa-heal map` so both directories hold the same pages.', opts.json);
563
568
  return;
564
569
  }
570
+ // Printed BEFORE the verdict, not after it. A depth mismatch does not colour a few rows —
571
+ // it can invent the entire BLOCK, because everything the deep side found behind a menu is
572
+ // simply absent from the shallow side and reads as `lost`. Someone who meets the verdict
573
+ // first will start hand-repairing drift that never happened.
574
+ if (depths) {
575
+ console.error(`\n⚠️ ${(0, aom_1.depthMismatchMessage)(depths)}\n` +
576
+ ` \`ia-qa-heal map --deep\` before diffing, or re-baseline without it.`);
577
+ }
565
578
  // Aggregate verdict
566
579
  const aggregate = aggregateReports(reports);
567
580
  if (stale.length > 0)
568
581
  aggregate.staleExcluded = stale;
582
+ // Onto the aggregate, not only the terminal: `--json` is what a pipeline reads, and a CI that
583
+ // sees BLOCK without knowing the two sides were captured differently is the same blind spot
584
+ // the MCP had. Every consumer of a verdict gets the caveat attached to it.
585
+ if (depths)
586
+ aggregate.depthMismatch = { ...depths, why: (0, aom_1.depthMismatchMessage)(depths) };
569
587
  if (systemic.groups.length > 0)
570
588
  aggregate.systemic = systemic;
571
589
  Object.assign(aggregate, (0, nextStep_1.nextStepFor)(aggregate.verdict, {
@@ -665,6 +683,10 @@ function collectDirDiff(baselineDir, currentDir) {
665
683
  // never as a match.
666
684
  let baselineState;
667
685
  let currentState;
686
+ // Exploration depth, settled the same way and for the same reason. Absent means the loaded
687
+ // state only, which is depth 0 — a real value here, not unknown: every contract written
688
+ // before `--deep` existed describes exactly one state, and that is knowable.
689
+ let deepest = null;
668
690
  // Kept out of the verdict rather than counted as `ok` — N ok that were never
669
691
  // compared is exactly the false green this guard exists to stop.
670
692
  const stale = [];
@@ -675,6 +697,7 @@ function collectDirDiff(baselineDir, currentDir) {
675
697
  const assess = (baseline, current, baselineFile, currentFile, page) => {
676
698
  baselineState ??= baseline.captureState;
677
699
  currentState ??= current.captureState;
700
+ deepest ??= (0, aom_1.depthMismatch)(baseline, current);
678
701
  const report = attachBaselineHrefs((0, nameMask_1.diffWithMasks)(baseline, current, masks, usage), baseline);
679
702
  report.nameDrift = (0, nameDrift_1.assessNameDrift)(report.rows, current.elements, usage);
680
703
  report.verdict = (0, nameDrift_1.escalateVerdict)(report.verdict, report.nameDrift);
@@ -734,7 +757,12 @@ function collectDirDiff(baselineDir, currentDir) {
734
757
  // gets exactly the reports it got before.
735
758
  const systemic = (0, systemicDelta_1.assessSystemic)(reports, baselineState, currentState);
736
759
  reports = (0, systemicDelta_1.applySystemic)(reports, systemic);
737
- return { reports, stale, systemic };
760
+ return {
761
+ reports,
762
+ stale,
763
+ systemic,
764
+ depthMismatch: deepest,
765
+ };
738
766
  }
739
767
  function aggregateReports(reports) {
740
768
  const layoutReports = reports.filter((r) => r.isLayout);