@dikshanty94/webnav 0.2.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.
Files changed (145) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +283 -0
  3. package/bin/webnav +18 -0
  4. package/dist/agent/loop.d.ts +59 -0
  5. package/dist/agent/loop.js +267 -0
  6. package/dist/agent/server.d.ts +60 -0
  7. package/dist/agent/server.js +254 -0
  8. package/dist/cli-help.d.ts +3 -0
  9. package/dist/cli-help.js +111 -0
  10. package/dist/cli-spec.d.ts +24 -0
  11. package/dist/cli-spec.js +460 -0
  12. package/dist/cli.d.ts +208 -0
  13. package/dist/cli.js +2558 -0
  14. package/dist/contract.d.ts +20 -0
  15. package/dist/contract.js +15 -0
  16. package/dist/creds.d.ts +46 -0
  17. package/dist/creds.js +126 -0
  18. package/dist/dashboard/server.d.ts +137 -0
  19. package/dist/dashboard/server.js +395 -0
  20. package/dist/dashboard/shell.d.ts +1 -0
  21. package/dist/dashboard/shell.js +1343 -0
  22. package/dist/explorer/analyse.d.ts +25 -0
  23. package/dist/explorer/analyse.js +39 -0
  24. package/dist/explorer/diff.d.ts +11 -0
  25. package/dist/explorer/diff.js +22 -0
  26. package/dist/explorer/draft.d.ts +81 -0
  27. package/dist/explorer/draft.js +1658 -0
  28. package/dist/explorer/fingerprint-page.d.ts +11 -0
  29. package/dist/explorer/fingerprint-page.js +19 -0
  30. package/dist/explorer/fingerprint.d.ts +14 -0
  31. package/dist/explorer/fingerprint.js +18 -0
  32. package/dist/explorer/infer.d.ts +85 -0
  33. package/dist/explorer/infer.js +370 -0
  34. package/dist/explorer/pattern-propose.d.ts +27 -0
  35. package/dist/explorer/pattern-propose.js +81 -0
  36. package/dist/explorer/patterns.d.ts +49 -0
  37. package/dist/explorer/patterns.js +290 -0
  38. package/dist/explorer/shadow.d.ts +3 -0
  39. package/dist/explorer/shadow.js +95 -0
  40. package/dist/graph/coverage.d.ts +33 -0
  41. package/dist/graph/coverage.js +155 -0
  42. package/dist/graph/edit.d.ts +47 -0
  43. package/dist/graph/edit.js +211 -0
  44. package/dist/graph/frontier.d.ts +24 -0
  45. package/dist/graph/frontier.js +95 -0
  46. package/dist/graph/seed.d.ts +23 -0
  47. package/dist/graph/seed.js +29 -0
  48. package/dist/graph/show.d.ts +8 -0
  49. package/dist/graph/show.js +6 -0
  50. package/dist/graph/teach.d.ts +8 -0
  51. package/dist/graph/teach.js +10 -0
  52. package/dist/hosted.d.ts +36 -0
  53. package/dist/hosted.js +69 -0
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.js +4 -0
  56. package/dist/mapstore/record.d.ts +128 -0
  57. package/dist/mapstore/record.js +224 -0
  58. package/dist/mapstore/schema.sql +47 -0
  59. package/dist/mapstore/store.d.ts +108 -0
  60. package/dist/mapstore/store.js +399 -0
  61. package/dist/mapstore/types.d.ts +87 -0
  62. package/dist/mapstore/types.js +29 -0
  63. package/dist/mcp/server.d.ts +46 -0
  64. package/dist/mcp/server.js +163 -0
  65. package/dist/paths.d.ts +10 -0
  66. package/dist/paths.js +33 -0
  67. package/dist/playwright/adapter.d.ts +62 -0
  68. package/dist/playwright/adapter.js +145 -0
  69. package/dist/playwright/ax-adapter.d.ts +33 -0
  70. package/dist/playwright/ax-adapter.js +76 -0
  71. package/dist/playwright/capture.d.ts +2 -0
  72. package/dist/playwright/capture.js +10 -0
  73. package/dist/playwright/fingerprint.d.ts +40 -0
  74. package/dist/playwright/fingerprint.js +139 -0
  75. package/dist/playwright/profile-lock.d.ts +15 -0
  76. package/dist/playwright/profile-lock.js +51 -0
  77. package/dist/playwright/sessions.d.ts +72 -0
  78. package/dist/playwright/sessions.js +229 -0
  79. package/dist/playwright/snapshot.d.ts +10 -0
  80. package/dist/playwright/snapshot.js +52 -0
  81. package/dist/playwright/throttle.d.ts +14 -0
  82. package/dist/playwright/throttle.js +76 -0
  83. package/dist/playwright/video.d.ts +13 -0
  84. package/dist/playwright/video.js +32 -0
  85. package/dist/protocol.d.ts +49 -0
  86. package/dist/protocol.js +3 -0
  87. package/dist/recorder/agent-session.d.ts +59 -0
  88. package/dist/recorder/agent-session.js +230 -0
  89. package/dist/recorder/capture-loop.d.ts +19 -0
  90. package/dist/recorder/capture-loop.js +40 -0
  91. package/dist/recorder/coverage.d.ts +22 -0
  92. package/dist/recorder/coverage.js +49 -0
  93. package/dist/recorder/hover-probe.d.ts +35 -0
  94. package/dist/recorder/hover-probe.js +100 -0
  95. package/dist/recorder/ingest.d.ts +39 -0
  96. package/dist/recorder/ingest.js +138 -0
  97. package/dist/recorder/live-record.d.ts +41 -0
  98. package/dist/recorder/live-record.js +255 -0
  99. package/dist/recorder/live.d.ts +53 -0
  100. package/dist/recorder/live.js +400 -0
  101. package/dist/recorder/probe.d.ts +14 -0
  102. package/dist/recorder/probe.js +33 -0
  103. package/dist/recorder/replay.d.ts +60 -0
  104. package/dist/recorder/replay.js +401 -0
  105. package/dist/recorder/review.d.ts +73 -0
  106. package/dist/recorder/review.js +171 -0
  107. package/dist/recorder/snapshot-dom.d.ts +7 -0
  108. package/dist/recorder/snapshot-dom.js +25 -0
  109. package/dist/router/auth-status.d.ts +20 -0
  110. package/dist/router/auth-status.js +55 -0
  111. package/dist/router/browse.d.ts +108 -0
  112. package/dist/router/browse.js +214 -0
  113. package/dist/router/catalog.d.ts +18 -0
  114. package/dist/router/catalog.js +6 -0
  115. package/dist/router/extract-content.d.ts +13 -0
  116. package/dist/router/extract-content.js +87 -0
  117. package/dist/router/live-extension-browser.d.ts +35 -0
  118. package/dist/router/live-extension-browser.js +175 -0
  119. package/dist/router/path.d.ts +7 -0
  120. package/dist/router/path.js +55 -0
  121. package/dist/router/read.d.ts +26 -0
  122. package/dist/router/read.js +30 -0
  123. package/dist/router/readiness.d.ts +9 -0
  124. package/dist/router/readiness.js +55 -0
  125. package/dist/router/replay.d.ts +14 -0
  126. package/dist/router/replay.js +23 -0
  127. package/dist/router/resolve.d.ts +21 -0
  128. package/dist/router/resolve.js +33 -0
  129. package/dist/router/search-live.d.ts +21 -0
  130. package/dist/router/search-live.js +98 -0
  131. package/dist/router/search-providers.d.ts +5 -0
  132. package/dist/router/search-providers.js +12 -0
  133. package/dist/router/search.d.ts +6 -0
  134. package/dist/router/search.js +62 -0
  135. package/dist/router/suite.d.ts +69 -0
  136. package/dist/router/suite.js +226 -0
  137. package/dist/router/tokens.d.ts +13 -0
  138. package/dist/router/tokens.js +26 -0
  139. package/dist/router/walk-live.d.ts +57 -0
  140. package/dist/router/walk-live.js +308 -0
  141. package/dist/router/walk-session.d.ts +49 -0
  142. package/dist/router/walk-session.js +90 -0
  143. package/dist/router/walk.d.ts +48 -0
  144. package/dist/router/walk.js +384 -0
  145. package/package.json +59 -0
@@ -0,0 +1,1658 @@
1
+ import { parseSnapshot } from '../playwright/snapshot.js';
2
+ import { matchState } from './fingerprint.js';
3
+ import { resolveByFingerprint } from '../playwright/fingerprint.js';
4
+ import { makeState } from '../mapstore/types.js';
5
+ import { extractShadow } from './shadow.js';
6
+ import { inferUrlModel, proposeTemplates, faceOf, jaccard, containment, controlFace, templateCore, extractShell, insideOverlay, mainScope, subtreeFolds, isOpaqueSeg, CONTROL_ROLES } from './infer.js';
7
+ import { classifyReadiness } from '../router/readiness.js';
8
+ import { loadPatternPacks, packDetectsOverlay, packValueNames } from './patterns.js';
9
+ // Roles that count as a real, resolvable child of a revealed overlay (ARIA role + name only;
10
+ // NEVER an inferred purpose — review-bounded #5a). A revealed node outside this set is dropped,
11
+ // not guessed.
12
+ // menuitemcheckbox/menuitemradio: stateful menu controls carry a real accessible name and are
13
+ // durable repertoire (a walk can resolve+fire them) — kept. `option` is DELIBERATELY absent:
14
+ // options are value domain (read live at walk time), never stored as children (rules 2+3, X1).
15
+ const REVEAL_CHILD_ROLES = new Set(['button', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'link', 'tab', 'checkbox', 'combobox', 'textbox']);
16
+ // Conservative commit-word match on a DECLARED label — surfaces a CANDIDATE for the agent to
17
+ // classify; it is a string match, not a judgment (commit is never auto-set true, #2/#5a).
18
+ // (live finding 2026-07-07: a human recording fired saucedemo's "Finish" — the order-placing
19
+ // button — and it drafted as a PLAIN navigate a walk would auto-fire, violating #2. finish/
20
+ // purchase/buy/send added; still only a CANDIDATE flag the agent classifies, #5a.)
21
+ export const COMMIT_WORDS = /\b(delete|remove|save|submit|confirm|place\s*order|pay|apply|finish|purchase|buy|send)\b/i;
22
+ const MAX_UNKNOWN_EVIDENCE = 1500; // a report entry must stay pasteable into a pack fixture
23
+ const MAX_UNKNOWNS = 20; // total report cap — the loop looks at a handful at a time
24
+ // Collection roles whose dominance (>COLLECTION_DOMINANCE of named added nodes) marks an added
25
+ // subtree as a data-grid REPAINT, not an overlay candidate (review F2). Documented tunable.
26
+ const COLLECTION_ROLES = new Set(['row', 'gridcell', 'columnheader', 'cell']);
27
+ const COLLECTION_DOMINANCE = 0.5;
28
+ // Evidence = the first ~25 named lines of the given nodes (their raw snapshot text), joined and
29
+ // capped to MAX_UNKNOWN_EVIDENCE chars. Never the whole page — just the relevant subtree/cluster.
30
+ const evidenceOf = (nodes) => {
31
+ const lines = nodes.filter((n) => n.name && n.name.trim()).slice(0, 25).map((n) => n.raw || `${n.role} "${n.name}"`);
32
+ const joined = lines.join('\n');
33
+ return joined.length > MAX_UNKNOWN_EVIDENCE ? joined.slice(0, MAX_UNKNOWN_EVIDENCE) : joined;
34
+ };
35
+ // candidate fingerprint tokens for a page, most-distinctive first: headings, then
36
+ // buttons/textboxes/links with names. (role:name; the vocabulary matchState matches on.)
37
+ // Role priority for fingerprint candidates, MOST page-identifying first. `tab` is second
38
+ // (after heading) because a page's tab-set is structural + durable — e.g. a report builder
39
+ // with no heading is still uniquely identified by tab:Table + tab:Charts, without falling
40
+ // back to data-specific buttons (a particular report's "OS Remove"). `tablist` groups tabs;
41
+ // its children carry the names.
42
+ const TOKEN_ROLES = ['heading', 'tab', 'button', 'textbox', 'link', 'checkbox', 'combobox'];
43
+ function candidateTokens(nodes) {
44
+ const seen = new Set();
45
+ const out = [];
46
+ // headings first (most page-identifying), then the rest in role priority, doc order within.
47
+ for (const role of TOKEN_ROLES) {
48
+ for (const n of nodes) {
49
+ if (n.role !== role || !n.name || !n.name.trim())
50
+ continue;
51
+ if (isDataLiteral(n.name))
52
+ continue; // identity must never rest on a bare date/number (#5)
53
+ const tok = `${role}:${n.name}`;
54
+ if (!seen.has(tok)) {
55
+ seen.add(tok);
56
+ out.push(tok);
57
+ }
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ // On a PARAMETERIZED page (URL template carries `{param}`), a big `heading:` is often the
63
+ // instance's TITLE (a user's name, a report's name) — durable enough to survive templateCore
64
+ // only because we saw one instance, but it is really per-instance DATA. So demote headings to
65
+ // the END of the candidate order: prefer any non-heading structural token (a tab, a button) for
66
+ // identity, and fall back to a heading only as a last resort. Non-param pages keep heading-first.
67
+ function candidateTokensFor(coreNodes, isParam) {
68
+ const cands = candidateTokens(coreNodes);
69
+ if (!isParam)
70
+ return cands;
71
+ const headings = cands.filter((t) => t.startsWith('heading:'));
72
+ const rest = cands.filter((t) => !t.startsWith('heading:'));
73
+ return [...rest, ...headings];
74
+ }
75
+ const slug = (s) => s.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase().slice(0, 40) || 'state';
76
+ // A landing is an ERROR page if a heading announces not-found/error. Generic (no site
77
+ // specifics); a real page whose heading legitimately contains "error" (rare) is a tolerable
78
+ // false positive — it surfaces as needs-fix for the agent, never a silent drop.
79
+ const ERROR_HEADING = /\b(page not found|not found|404|something went wrong|no access|access denied|unauthorized)\b/i;
80
+ function isErrorLanding(nodes) {
81
+ return nodes.some((n) => n.role === 'heading' && n.name != null && ERROR_HEADING.test(n.name));
82
+ }
83
+ // A NON-SETTLED render (axis 1 settledness): a WAI-ARIA live-region role (`status`/`alert`/
84
+ // `progressbar`) announcing an in-flight load ("Running query…", "Loading", "Please wait"). Such
85
+ // a landing was captured before its data arrived — its face is missing the settled repertoire AND
86
+ // carries transient loading chrome (a `status` region + its Cancel button) that the settled face
87
+ // lacks, so plain containment can't see it as a partial render (live finding: a mid-query report
88
+ // landing's face was only 0.88-contained in its settled sibling because of exactly these transient
89
+ // tokens → it polluted the merged state's core, dropping the settled tab identity). Site-agnostic
90
+ // (loading PHRASES, like ERROR_HEADING / the interstitial patterns).
91
+ const LOADING_LIVE_REGION = /\b(running|loading|fetching|processing|please wait|this may take a moment|in progress)\b/i;
92
+ function isLoadingRender(nodes) {
93
+ return nodes.some((n) => (n.role === 'status' || n.role === 'alert' || n.role === 'progressbar')
94
+ && n.name != null && LOADING_LIVE_REGION.test(n.name));
95
+ }
96
+ // map an interactive ARIA role to its in-page affordance kind (strict, no layout inference):
97
+ // fillable fields → input; everything else interactive → mutate. (link-to-known-state is handled
98
+ // by the cross-link mesh, not here.)
99
+ const INPUT_ROLES = new Set(['textbox', 'combobox', 'checkbox', 'searchbox', 'spinbutton']);
100
+ function childKind(role) { return INPUT_ROLES.has(role) ? 'input' : 'mutate'; }
101
+ // Was the recorded action's target inside an overlay? A NAME alone is ambiguous when the page
102
+ // also carries a same-named node outside the overlay (a page heading "Search" shadowing the
103
+ // picker's textbox "Search" — the first-match gate read the heading and leaked the overlay
104
+ // control). So: match ROLE+NAME, and gate if ANY match is inside an overlay — the FROM snapshot
105
+ // has the overlay OPEN, so the recorded interaction most plausibly targeted the overlay instance.
106
+ // The flip side is harmless: a legit page-level twin re-emerges via interior synthesis from
107
+ // coreNodes, so preferring the overlay member never loses a real page affordance.
108
+ function clickedInOverlay(nodes, action) {
109
+ if (!action.name)
110
+ return false;
111
+ return nodes.some((n, i) => n.role === action.role && n.name === action.name && insideOverlay(nodes, i));
112
+ }
113
+ function host(url) { try {
114
+ return new URL(url).host;
115
+ }
116
+ catch {
117
+ return null;
118
+ } }
119
+ // Enumerated VALUE DOMAIN among an OVERLAY's added nodes (axis 2): ≥3 same-role SAME-DEPTH nodes
120
+ // with DISTINCT names are the overlay's choice list (a picker's dimension/metric checkboxes) — data
121
+ // to read live at walk time, never persisted. RETAINED (deviation from plan Task 2(b), see report):
122
+ // subtreeFolds is PARENT-scoped and signature-matched, so it does NOT subsume this when a picker
123
+ // groups its values under HETEROGENEOUS per-category wrappers (each value one level down, under a
124
+ // category heading) — no ≥3 uniform siblings share a parent, so subtreeFolds leaves the whole value
125
+ // domain leaking as reveal children (a bare-data-value regression). The flat depth-based fold
126
+ // catches them and is SAFE ONLY inside an overlay — a PAGE toolbar legitimately carries many
127
+ // distinct same-depth buttons, so page interior synthesis must NEVER use this (it uses subtreeFolds).
128
+ // Kept minimal + overlay-scoped exactly as before.
129
+ // ponytail: this exists — FOLLOW-UP to delete it: give subtreeFolds a CONTAINER-SCOPE grouping pass
130
+ // that folds a value domain split across heterogeneous per-category wrappers (each value one level
131
+ // down under its own category heading) by matching the shared LEAF signature across DIFFERENT
132
+ // parents within one container. When that lands, this flat depth-based fallback is subsumed and
133
+ // enumeratedNames can be deleted (the last remaining non-subtreeFolds fold path).
134
+ function enumeratedNames(added) {
135
+ const groups = new Map();
136
+ for (const n of added) {
137
+ if (!n.name || !n.name.trim())
138
+ continue;
139
+ const k = `${n.role}|${n.depth}`;
140
+ (groups.get(k) ?? groups.set(k, new Set()).get(k)).add(n.name);
141
+ }
142
+ const out = new Set();
143
+ for (const names of groups.values())
144
+ if (names.size >= 3)
145
+ for (const nm of names)
146
+ out.add(nm);
147
+ return out;
148
+ }
149
+ // The DOMINANT control role among a fold's member nodes (most-frequent CONTROL_ROLES role), for
150
+ // childKind. null → the fold carries no control node (pure link/heading/text fold).
151
+ function dominantControlRole(nodes, fold) {
152
+ const counts = new Map();
153
+ for (const i of fold.memberIndices) {
154
+ const r = nodes[i].role;
155
+ if (CONTROL_ROLES.has(r))
156
+ counts.set(r, (counts.get(r) ?? 0) + 1);
157
+ }
158
+ let best = null, n = 0;
159
+ for (const [r, c] of counts)
160
+ if (c > n) {
161
+ best = r;
162
+ n = c;
163
+ }
164
+ return best;
165
+ }
166
+ // First control-role node WITH a name inside a fold's members (doc order) — the emission-site
167
+ // label fallback when a fold's own label is a bare role (e.g. 'generic'), per the reviewer guard.
168
+ function firstControlNameInFold(nodes, fold) {
169
+ for (const i of fold.memberIndices) {
170
+ const nm = nodes[i].name;
171
+ if (CONTROL_ROLES.has(nodes[i].role) && nm && nm.trim())
172
+ return nm.trim();
173
+ }
174
+ return null;
175
+ }
176
+ // Which subtreeFolds outputs draft.ts treats as real TEMPLATES to gate/emit on — the emission-site
177
+ // discipline that keeps subtreeFolds' broad structural folding from stripping genuinely-distinct
178
+ // affordances (the reviewer guard: never fold routing/real controls away):
179
+ // • WIDGET (unitSize ≥ 2) — repeated cards/rows; their instance titles are data (gate always,
180
+ // even a pure-link widget: its titles mustn't anchor identity, its links stay mesh edges).
181
+ // • LEAF fold — must carry a CONTROL. subtreeFolds folds ANY same-role leaf siblings, including
182
+ // three plain section HEADINGS or a run of nav LINKS (their non-control names abstract to the
183
+ // bare role, so they share a signature). Those are a page's own identity/routing, NOT a repeated
184
+ // control template — folding them would strip an identifying heading (state lost) or a real link.
185
+ // So a leaf fold is a template only when its units are CONTROLS (button/field/tab). AND among
186
+ // control leaves, an ABSTRACTED fold groups by pure L2 shape, which also catches an ordinary
187
+ // toolbar of distinct-purpose buttons (Search/Filter/Sort → all `button()`) — not a template.
188
+ // The param-slot signal that marks a real value family ("OS Remove"/"Revenue Remove", "<X>
189
+ // dimension") is a shared TRAILING WORD → a real-word label; a role-fallback label (no shared
190
+ // word, label === a member role) is a plain toolbar → NOT a template. A NAMED control fold
191
+ // (IDENTICAL labels: Expand drilldown ×25) is always a genuine per-row/chip repeat.
192
+ const isTemplateFold = (fold, nodes) => {
193
+ if (fold.unitSize >= 2)
194
+ return true;
195
+ if (dominantControlRole(nodes, fold) === null)
196
+ return false; // leaf headings/links → not a control template
197
+ if (fold.level === 'named')
198
+ return true; // identical control labels = genuine repeat
199
+ const memberRoles = new Set(fold.memberIndices.map((i) => nodes[i].role));
200
+ return !memberRoles.has(fold.label); // abstracted: real shared-word label = template; role fallback = plain toolbar
201
+ };
202
+ // The names + folds contributed by a node list's TEMPLATE folds: `gatedNames` (de-valued residue
203
+ // that must not anchor identity or synthesize as its own affordance — a picker's enumerated choice
204
+ // list, per-row chips, per-card instance titles) and `emit` (the folds that yield ONE informational
205
+ // affordance each). Subsumes the old foldRepeats.foldedNames + enumeratedNames. Keyed by NAME
206
+ // because callers filter node lists by name.
207
+ function templateFolds(nodes) {
208
+ const { folds } = subtreeFolds(nodes);
209
+ const kept = folds.filter((f) => isTemplateFold(f, nodes));
210
+ const gatedNames = new Set();
211
+ for (const f of kept)
212
+ for (const i of f.memberIndices) {
213
+ const nm = nodes[i].name;
214
+ if (nm && nm.trim())
215
+ gatedNames.add(nm);
216
+ }
217
+ return { emit: kept, gatedNames };
218
+ }
219
+ // HUSK TRIPWIRE (reviewer-mandated addition to Task 2, extension-loop plan): a pack can only ever
220
+ // SHRINK what's stored (packs' whole safety contract), but an over-broad value-domain trigger can
221
+ // shrink a page down to nothing — excising the page's OWN real repertoire, not just genuine value
222
+ // data (the "husk" failure the built-in overlay-shape guard already fixed once for the CORE
223
+ // detection; packs are agent-authored data and need the same backstop). Before applying a pack's
224
+ // exclusions to a page, check what fraction of the page's OWN would-be affordance names (its core
225
+ // nodes — the same universe interior-synthesis reads) the pack would excise; a pack trying to take
226
+ // >50% (documented tunable) is DISABLED for that page — loudly (an unknowns 'pack-tripwire' entry),
227
+ // never silently. A pack that trips on every page it matches is effectively quarantined page-by-
228
+ // page, which is exactly the visible, non-silent failure mode this exists to guarantee.
229
+ const TRIPWIRE_FRACTION = 0.5;
230
+ /** Split `packs` into [safe, tripped] for ONE page, given the page's own would-be affordance-name
231
+ * universe (its NAMED core nodes — the same universe interior-synthesis reads). A value-domain
232
+ * pack whose own excised-name fraction of that universe exceeds TRIPWIRE_FRACTION is tripped;
233
+ * every other pack (incl. overlay-open — it never excises names) is always safe. Pure; called
234
+ * once per page and the result reused across that page's hook call sites. */
235
+ function splitTrippedPacks(packs, pageNodes) {
236
+ const total = new Set(pageNodes.filter((n) => n.name && n.name.trim()).map((n) => n.name)).size;
237
+ const safe = [];
238
+ const tripped = [];
239
+ for (const pack of packs) {
240
+ if (pack.type !== 'value-domain' || total === 0) {
241
+ safe.push(pack);
242
+ continue;
243
+ }
244
+ const excised = packValueNames([pack], pageNodes).size;
245
+ if (excised / total > TRIPWIRE_FRACTION)
246
+ tripped.push({ pack, excised, total });
247
+ else
248
+ safe.push(pack);
249
+ }
250
+ return { safe, tripped };
251
+ }
252
+ // IDENTITY-FACE NORMALIZATION (subtree-templates §Global Constraints — dispose predicate + SPA
253
+ // split ONLY, never templateCore/stored faces/fingerprints). Two personalized dashboards are ONE
254
+ // template: their per-widget instance titles (`heading:Demo User`, `heading:Sales Dashboard`)
255
+ // are DATA that drags full-face jaccard apart, while the repeated widget SHAPE is the identity.
256
+ // So for identity comparison we: (a) DROP every token a folded TEMPLATE member node contributed
257
+ // (the instance titles/per-row values that differ dashboard-to-dashboard), and (b) add ONE
258
+ // `widget:<sig>` presence token per DISTINCT kept-fold sig — presence not count, so a 3-widget
259
+ // and a 5-widget dashboard of the same template don't split on count. Two pages sharing the same
260
+ // widget SIGS then read as the same face; a list page (row-sig folds) vs a viewer page (widget-sig
261
+ // folds) carry DIFFERENT `widget:*` tokens → still split. `widget:*` is never fingerprint material
262
+ // (candidateTokens only emits real ARIA role:name), so this cannot leak into a stored face.
263
+ function normFace(nodes) {
264
+ const { emit } = templateFolds(nodes);
265
+ if (!emit.length)
266
+ return faceOf(nodes);
267
+ const dropped = new Set();
268
+ const sigs = new Set();
269
+ for (const f of emit) {
270
+ sigs.add(f.sig);
271
+ for (const i of f.memberIndices)
272
+ dropped.add(i);
273
+ }
274
+ const out = new Set();
275
+ nodes.forEach((n, i) => { if (!dropped.has(i) && n.name && n.name.trim())
276
+ out.add(`${n.role}:${n.name}`); });
277
+ for (const sig of sigs)
278
+ out.add(`widget:${sig}`);
279
+ return out;
280
+ }
281
+ // A control whose accessible NAME is nothing but a bare data literal — a date (`09 Jul 2026`,
282
+ // `2026-07-09`) or a lone number/currency — is per-instance DATA, never durable structure (the
283
+ // value changes every day/instance; the design explicitly REFUSES to store dates). This is a
284
+ // universal content-TYPE prior (like the ERROR_HEADING phrases), NOT a site-specific token — it
285
+ // names no product, matches no app-ism. Conservative: only an ENTIRE-name match (a real control
286
+ // like "Due 09 Jul 2026" or "Delete" is untouched), so it never eats a labelled affordance.
287
+ const DATA_LITERAL = /^(?:\d{1,2}[ /-]\w{3,9}[ /-]\d{2,4}|\d{4}-\d{2}-\d{2}|[£$€]?\s?\d[\d,.]*%?)$/;
288
+ const isDataLiteral = (name) => DATA_LITERAL.test(name.trim());
289
+ // A label DOMINATED by a date-RANGE value — a date-range picker OPENER whose accessible name is
290
+ // its CURRENT range (`09 Jul 2026 - 10 Jul 2026 UTC`, `Last 7 Days (CD) : 02 Jul 2026 - 08 Jul
291
+ // 2026UTC`). Same content-TYPE prior as DATA_LITERAL, extended to the COMPOSITE shape the anchored
292
+ // bare-literal gate can't see: DATA_LITERAL is `^…$` on a lone date so it never eats `"Due 09 Jul
293
+ // 2026"`, but a preset-prefix + range + TZ-suffix label isn't a bare literal and slipped through.
294
+ // The durable affordance is "open the date-range picker"; its stored label must be a stable control
295
+ // name, never the value — and this control's name IS the value (no other durable name), so we
296
+ // REFUSE it (same posture as DATA_LITERAL). Gated on a two-date RANGE (`<date> - <date>`), NOT a
297
+ // single date: a lone date is DATA_LITERAL's job and a range is the unmistakable signal of a
298
+ // current-value picker label, so a real one-off control mentioning one date is untouched.
299
+ const DATE_TOKEN = String.raw `\d{1,2}\s+\w{3,9}\s+\d{2,4}|\d{4}-\d{2}-\d{2}`;
300
+ const DATE_RANGE = new RegExp(`(?:${DATE_TOKEN})\\s*[-–]\\s*(?:${DATE_TOKEN})`, 'i');
301
+ const isValueLabel = (name) => isDataLiteral(name) || DATE_RANGE.test(name.trim());
302
+ // isOpaqueSeg (the sanctioned URL-SHAPE prior: opaque instance id vs meaningful module word) is
303
+ // imported from infer.ts — the canonical home of the word-vs-opaque distinction, shared by the
304
+ // base-inference param detection and the template dispose so they can never diverge.
305
+ // A key whose TAIL segment is opaque is PROBABLY a parameterized instance page. Used ONLY to demote
306
+ // a heading to "probably instance data" — never as silent truth. A page that formed a real {param}
307
+ // template already reads as param; this catches the LONE opaque-id instance (e.g. `/dashboard/8001`)
308
+ // that never grouped into a template.
309
+ function looksParameterizedKey(key) {
310
+ const segs = key.split('/').filter(Boolean).filter((s) => s !== '{param}');
311
+ const tail = segs[segs.length - 1];
312
+ return !!tail && isOpaqueSeg(tail);
313
+ }
314
+ // Label from the MEANINGFUL (non-opaque, non-{param}) tail segments (≤2) of a template/key path,
315
+ // so a state reads `report-list` / `report` / `dashboard`, never a raw record id (`7001-c8c6…`).
316
+ // Opaque id segments (`7001`, a uuid, `{param}`) are dropped — they carry no human meaning and
317
+ // two instances of the same page (`/report/7001/{hash}` variants) then collide on the SAME clean
318
+ // label, so the name-collision resolver distinguishes them by a structural heading/tab. `/` → home.
319
+ function labelFromKey(key) {
320
+ const segs = key.split('/').filter(Boolean).filter((s) => s !== '{param}' && !isOpaqueSeg(s));
321
+ return slug(segs.slice(-2).join('-') || 'home');
322
+ }
323
+ // The first DURABLE STRUCTURAL token of `core` that no `others` face carries — used to name an
324
+ // SPA split or break a label collision. A `heading:` is preferred (a view's own title reads best,
325
+ // e.g. `report-flat`); a `tab:` is the fallback (two vizzes of one report differ only by their tab
326
+ // set — `tab:Flat` distinguishes them where no heading does). Returns the bare name; null if
327
+ // nothing structural distinguishes (→ the caller sends the pair to needsFix, never a wrong merge).
328
+ // On a PARAM page (URL template carries `{param}`, or a lone opaque-id tail) a big `heading:` is
329
+ // often the INSTANCE TITLE — the logged-in user's name, a specific dashboard/report name
330
+ // (`heading:Demo User`, `heading:Sales Dashboard`). Same instance-data prior
331
+ // `candidateTokensFor` uses to DEMOTE (not refuse) param-page headings: prefer a durable STRUCTURAL
332
+ // `tab:` (the view's tab-set is structural — two report/dashboard VIEWS differ by tabs), and fall
333
+ // back to a heading only when no tab distinguishes (two genuinely different page-TYPES at opaque-id
334
+ // URLs — `heading:Grid List` vs `heading:Grid Viewer` — have no tabs and MUST keep their title).
335
+ // A non-param page keeps heading-first (a section page's own `h1` title is its best discriminator).
336
+ function distinguishingHeading(core, others, isParam = false) {
337
+ const prefixes = isParam ? ['tab:', 'heading:'] : ['heading:', 'tab:'];
338
+ for (const prefix of prefixes) {
339
+ for (const t of core) {
340
+ if (!t.startsWith(prefix))
341
+ continue;
342
+ if (others.every((o) => !o.has(t)))
343
+ return t.slice(prefix.length);
344
+ }
345
+ }
346
+ return null;
347
+ }
348
+ // Two faces are "the same page" when jaccard ≥ 0.5 OR containment ≥ 0.9. The containment arm
349
+ // joins a PARTIAL RENDER (a subset face — the page captured before its data grid arrived) to its
350
+ // full sibling, which jaccard alone splits (live finding: 199- vs 33-token landings of ONE list
351
+ // page → jaccard 0.17, containment 1.0; the split lost the page to needsFix). A genuinely
352
+ // different page is neither jaccard-close nor contained. The containment arm requires the
353
+ // SMALLER face to carry ≥8 tokens of evidence: a near-empty chrome-only face is "contained" in
354
+ // anything by coincidence (real case: a 1-token page merged into an unrelated one under a false
355
+ // /{param} template), while a real partial render always has a rendered skeleton (observed: 10+
356
+ // non-shell tokens). 8 matches classifyReadiness's minNodes. All thresholds documented tunables.
357
+ const sameFace = (a, b) => jaccard(a, b) >= 0.5 || (Math.min(a.size, b.size) >= 8 && containment(a, b) >= 0.9);
358
+ // CONTROL arm (live finding): two INSTANCES of one opaque-id template share their CONTROL skeleton
359
+ // even when instance data (product names, prices, related items) drags full-face jaccard under the
360
+ // 0.5 bar (measured: full faces 0.45-0.49, control faces IDENTICAL). Two control faces are "the
361
+ // same template's skeleton" under the min-4 gate + jaccard/containment bars — the smaller must
362
+ // carry ≥4 tokens (a list page and a viewer sharing one Search button must not merge).
363
+ // Callers pass the RAW (pre-normFace) control face: normFace folds a rendered data-grid's per-row
364
+ // controls into ONE `widget:*` sig, so a SETTLED landing (grid folded → 0 chip controls) and a
365
+ // MID-QUERY landing of the SAME instance (grid not yet rendered → chips still flat controls) had
366
+ // DIVERGENT control faces (live finding: raw control containment 0.92 vs normFace'd 0.50 → the
367
+ // dispose wrongly SPLIT one report instance into a subset-fp pair of states that made the walk
368
+ // `ambiguous` forever). The control skeleton is a template's render-depth-STABLE identity, so
369
+ // compare it on raw faces. Used by BOTH the dispose (cross-key merge) and the opaque-template
370
+ // SPA-split (which must not re-split a merged instance's mid-query + settled landings).
371
+ const sameControlFaces = (ca, cb) => Math.min(ca.size, cb.size) >= 4 && (jaccard(ca, cb) >= 0.6 || containment(ca, cb) >= 0.9);
372
+ // NON-CONTRADICTION arm (user norm: instances of one opaque-param template are never separate
373
+ // nodes). Applied ONLY between two members of an OPAQUE-param template group (the word-vs-opaque
374
+ // segment gate keeps /dashboard/list etc. out — this is the caller's responsibility). Two thin,
375
+ // icon-heavy dashboards (/dashboard/8001 vs /dashboard/8002) share the template but each renders
376
+ // ~0 named controls, so neither the full-face (jaccard≥0.5) nor the control arm (ctl≥4) can fire —
377
+ // yet they are the SAME page shape and must merge. They merge when their shell-subtracted identity
378
+ // faces are NON-CONTRADICTORY:
379
+ // • the smaller is largely CONTAINED in the larger (containment ≥ 0.7, tunable), OR
380
+ // • the smaller is below the CONTROL-EVIDENCE gate (< CTL_GATE controls — the same ≥4 bar
381
+ // sameControlFaces uses; too few controls to assert a rival page-type structure) AND the two
382
+ // still OVERLAP (containment ≥ NONCONTRA_OVERLAP). The control-gate is what distinguishes "too
383
+ // thin to contradict" from "genuinely different": two dashboards (1 date-button / 0 controls,
384
+ // sharing their `widget:<sig>` + a heading → containment 0.5) merge; two report VIZZES under
385
+ // one /report/{id} template each carry ≥3-4 real controls (Table/Charts/Export vs Flat/Search/
386
+ // Download) sharing only a title (containment ~0.2) → they CONTRADICT and split by tab; a LIST
387
+ // vs a WIDGET viewer carry disjoint `widget:<sig>` tokens (containment 0) → also split.
388
+ // The merge rests on the URL-template prior, not observed agreement, so the caller marks it
389
+ // PROVISIONAL — a later contradicting landing re-splits it naturally on rebuild.
390
+ const NONCONTRA_CONTAINMENT = 0.7; // broad ⊆-compatibility bar (documented tunable)
391
+ const NONCONTRA_OVERLAP = 0.5; // thin-arm overlap floor — must share real structure, not disjoint (tunable)
392
+ // ponytail: 0.5 floor calibrated to the ONE observed real case (two live dashboards at containment
393
+ // exactly 0.50); a thin instance pair sharing slightly less will split — retune only on a real counterexample.
394
+ const CTL_GATE = 4; // control-evidence gate (mirrors sameControlFaces' min-4)
395
+ const nonContradictoryFaces = (a, b) => {
396
+ const [sm, lg] = a.size <= b.size ? [a, b] : [b, a];
397
+ const c = containment(sm, lg);
398
+ if (c >= NONCONTRA_CONTAINMENT)
399
+ return true; // smaller broadly ⊆ larger
400
+ // thin arm: too few controls to assert a distinct page-type, and not a disjoint (contradicting) page.
401
+ return controlFace(sm).size < CTL_GATE && controlFace(lg).size < CTL_GATE && c >= NONCONTRA_OVERLAP;
402
+ };
403
+ const unionOf = (fs) => { const u = new Set(); for (const f of fs)
404
+ for (const t of f)
405
+ u.add(t); return u; };
406
+ // Single-link clustering of faces under a same-page predicate (union-find). Returns clusters as
407
+ // index groups. One key with structurally-distinct landings splits into >1 cluster (SPA views at
408
+ // one URL); same-structure repeat visits — and partial renders of one page — stay in one cluster.
409
+ // The predicate defaults to `sameFace`; a template-merged key passes the SAME predicate its
410
+ // dispose used (incl. the control arm), else the split instantly undoes the dispose's merge.
411
+ // Union-find over indices 0..n-1 under a pairwise `same(i,j)` predicate. Index-based (not
412
+ // face-based) so an opaque-template split can consult BOTH a landing's normFace'd identity face
413
+ // AND its RAW control face by position (the render-depth-stable control skeleton — see the SPA
414
+ // split for why normFace'd controls re-split a mid-query + settled pair).
415
+ function clusterFacesByIndex(n, same) {
416
+ const parent = Array.from({ length: n }, (_, i) => i);
417
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
418
+ for (let i = 0; i < n; i++)
419
+ for (let j = i + 1; j < n; j++) {
420
+ if (same(i, j))
421
+ parent[find(i)] = find(j);
422
+ }
423
+ const groups = new Map();
424
+ for (let i = 0; i < n; i++)
425
+ (groups.get(find(i)) ?? groups.set(find(i), []).get(find(i))).push(i);
426
+ return [...groups.values()];
427
+ }
428
+ /**
429
+ * Build a draft graph from recorded action-effects. Observation-based identity (2026-07-10):
430
+ * 1. KEY: infer the site's URL model from every observed url; alias a pre-redirect ghost
431
+ * (requestedUrl ≠ settled toUrl) to the settled key so the ghost merges. `key(url)` =
432
+ * follow-alias(keyOf(url)) — Task 8's mesh reuses it.
433
+ * 2. LANDINGS: a page's identity landings = navigated toSnapshots (+ the first effect's
434
+ * fromSnapshot) that classifyReadiness==='ready'. Non-nav toSnapshots feed affordances
435
+ * only (Task 9), not identity.
436
+ * 3. PROPOSE/DISPOSE: proposeTemplates over the keys; a group merges iff every member's
437
+ * first-landing face is jaccard≥0.5 with the group's first member — same-structure param
438
+ * URLs collapse, a list-vs-detail pair does not.
439
+ * 4. SPA SPLIT: within one key with ≥2 landings, cluster faces at jaccard≥0.5; >1 cluster →
440
+ * split states named by a distinguishing heading (else needsFix).
441
+ * 5. CORE: templateCore(faces) minus shell (shell arrives Task 8); coreNodes = first landing
442
+ * filtered to core tokens.
443
+ * 6. NAME: labelFromKey(template ?? key); collisions broken by a distinguishing core heading
444
+ * (else both to needsFix). NO numeric suffixes.
445
+ * Then (unchanged, some re-pointed in Tasks 8–10): uniqueness fingerprint, affordance synthesis,
446
+ * hierarchy, self-verify, needsFix assembly.
447
+ */
448
+ // Pattern packs extend structure coverage as DATA (2026-07-12-extension-loop.md): they can only
449
+ // make the engine MORE conservative about what it stores (gate an overlay's values / detect an
450
+ // undeclared overlay), never mint an affordance/edge. Loaded ONCE per draft run. `packs` is
451
+ // injectable so tests stay pure (no fs); default = the two shipped dirs via loadPatternPacks.
452
+ export function draftFromEffects(effects, packs = loadPatternPacks()) {
453
+ // The map's HOST = where the recording started (the operator's site). A landing that SETTLED
454
+ // on another host is a blocked door (SSO wall, CDN interstitial) — Finding 7 / doors posture:
455
+ // detect + escalate (needsFix), never a state of THIS site's map, never a site rule.
456
+ const mapHost = host(effects[0]?.fromUrl ?? '');
457
+ const foreignHosts = new Map(); // foreign host → first observed URL there
458
+ // ── 1. KEY: URL model + alias map (rule 1) ──
459
+ const allUrls = [];
460
+ effects.forEach((e, i) => {
461
+ if (i === 0 && e.fromUrl)
462
+ allUrls.push(e.fromUrl);
463
+ if (e.toUrl)
464
+ allUrls.push(e.toUrl);
465
+ if (e.requestedUrl)
466
+ allUrls.push(e.requestedUrl);
467
+ });
468
+ // foreign-host URLs must not dilute the base inference (enough interstitial hits would push
469
+ // the real tenant prefix under the 80% bar and break every key, incl. the ghost merge).
470
+ const model = inferUrlModel(mapHost ? allUrls.filter((u) => host(u) === mapHost) : allUrls);
471
+ const alias = new Map(); // requested key → settled key (pre-redirect ghost)
472
+ for (const e of effects) {
473
+ if (!e.navigated || !e.requestedUrl)
474
+ continue;
475
+ // an alias is only valid WITHIN the map's host: a navigate that requested a page of this
476
+ // site but SETTLED on a foreign host hit a blocked door (SSO wall) — aliasing the legit key
477
+ // to the wall's key would pull every real landing of that page onto the foreign key (live
478
+ // finding: /dashboard/list aliased onto a Cloudflare /cdn-cgi/access/login key, so the real
479
+ // dashboard-list state vanished and a wall-named ghost carried its landings).
480
+ if (mapHost && (host(e.requestedUrl) !== mapHost || host(e.toUrl) !== mapHost))
481
+ continue;
482
+ const rk = model.keyOf(e.requestedUrl), sk = model.keyOf(e.toUrl);
483
+ if (rk !== sk)
484
+ alias.set(rk, sk);
485
+ }
486
+ const key = (url) => { const k = model.keyOf(url); return alias.get(k) ?? k; };
487
+ // ── 2. LANDINGS: gather ready landing snapshots per key (rule 2) ──
488
+ // landingsByKey: identity landings (navigated + ready toSnapshots, + the FIRST effect's
489
+ // fromSnapshot = the entry). A page with NO ready landing never becomes a state (rule 2).
490
+ // urlVotes → urlByKey: each READY landing votes for its full URL; the key's urlPattern is
491
+ // chosen after the scan (most-observed rule below). Non-nav toSnapshots are NOT landings —
492
+ // they feed affordance synthesis (Task 9), read from the raw effect there, not from this map.
493
+ const ready = (snap) => classifyReadiness(snap) === 'ready';
494
+ const landingsByKey = new Map();
495
+ const urlVotes = new Map(); // key → settled landing URL → count
496
+ const pushLanding = (url, snap, nameHints) => {
497
+ if (!ready(snap))
498
+ return;
499
+ // FOREIGN-HOST gate (Finding 7): a ready landing on another host is a blocked door — record
500
+ // it once per host for needsFix and refuse it as a landing (no page → no state, no request,
501
+ // and the from-page's navigate edge to it never materializes: labelOf finds no page).
502
+ const h = host(url);
503
+ if (mapHost && h && h !== mapHost) {
504
+ if (!foreignHosts.has(h))
505
+ foreignHosts.set(h, url);
506
+ return;
507
+ }
508
+ const k = key(url);
509
+ const nodes = parseSnapshot(snap);
510
+ // X6: patch NAMELESS nodes with their observed name-probe hint (tooltip/aria/title read from
511
+ // the live DOM at capture) BEFORE the nodes flow into landingsByKey — the ONE seam, so all
512
+ // five downstream name gates see the observed name. A hint only fills an empty name; a node
513
+ // that already has a name is never touched.
514
+ if (nameHints) {
515
+ for (const n of nodes) {
516
+ if (n.ref && !(n.name ?? '').trim() && nameHints[n.ref])
517
+ n.name = nameHints[n.ref];
518
+ }
519
+ }
520
+ (landingsByKey.get(k) ?? landingsByKey.set(k, []).get(k)).push(nodes);
521
+ const votes = urlVotes.get(k) ?? urlVotes.set(k, new Map()).get(k);
522
+ votes.set(url, (votes.get(url) ?? 0) + 1);
523
+ };
524
+ effects.forEach((e, i) => {
525
+ // per-session ENTRY landing (Task 15 review finding): the CLI concatenates sessions and each
526
+ // session's seq restarts at 0, so a session boundary is OBSERVABLE as a seq reset (seq ≤ the
527
+ // previous effect's; verified on the real 5-session data — strictly increasing within a
528
+ // session, 0 at each start). A session's first effect enters ON a real page whose only
529
+ // observation may be its fromSnapshot (a session recorded entirely on one page never
530
+ // navigates TO it) — the old i===0-only rule dropped that page for every session but the
531
+ // first, so all its recorded actions lost their fromLabel and were silently discarded (the
532
+ // report-builder husk: 46 real actions dropped). Seed the entry landing at EVERY boundary,
533
+ // through the same readiness-gated pushLanding path.
534
+ const sessionStart = i === 0 || e.seq <= effects[i - 1].seq;
535
+ if (sessionStart && e.fromSnapshot)
536
+ pushLanding(e.fromUrl, e.fromSnapshot);
537
+ // nameHints are captured on the SETTLED landing (toSnapshot) → only applied there.
538
+ if (e.navigated && e.toSnapshot)
539
+ pushLanding(e.toUrl, e.toSnapshot, e.nameHints);
540
+ });
541
+ // ── urlPattern per key (axis 1 settledness — live finding): "first observed URL wins" let
542
+ // SESSION ORDERING store a pre-redirect GHOST as a state's urlPattern. An SPA landing can be
543
+ // captured on its pre-redirect URL (the router hasn't inserted the tenant segment yet):
544
+ // /v3/report/list vs /v3/9999/report/list — keyOf's conditional base-skip keys BOTH to
545
+ // /report/list, and whichever session was concatenated first won (observed: ghost 3 votes,
546
+ // tenant-full 7, ghost first in CLI order → every walk opening the state's URL 404'd).
547
+ // Rule: a key's urlPattern = its MOST-OBSERVED settled landing URL, never an alias-SOURCE URL
548
+ // (a URL whose own raw key was aliased away is a pre-redirect ghost by definition; skipped
549
+ // unless the key has nothing else); ties → longest pathname (the tenant-full variant), then
550
+ // first observed (Map insertion order + stable sort).
551
+ const urlByKey = new Map();
552
+ const pathLen = (u) => { try {
553
+ return new URL(u).pathname.length;
554
+ }
555
+ catch {
556
+ return u.length;
557
+ } };
558
+ for (const [k, votes] of urlVotes) {
559
+ const entries = [...votes.entries()];
560
+ const settled = entries.filter(([u]) => !alias.has(model.keyOf(u)));
561
+ const pool = settled.length ? settled : entries;
562
+ pool.sort((a, b) => b[1] - a[1] || pathLen(b[0]) - pathLen(a[0]));
563
+ urlByKey.set(k, pool[0][0]);
564
+ }
565
+ // ── SHELL (axis 3), computed PRE-MERGE from the distinct pages' first-landing faces ──
566
+ // The shared chrome = tokens on ≥80% of DISTINCT pages (extractShell; ≥4-page gate). One face
567
+ // per page (its first landing), NOT per landing — else a page visited many times would drown
568
+ // out the cross-page signal. Computed here (before template merge) on purpose: the dispose
569
+ // check and the SPA split both compare shell-SUBTRACTED faces (design doc: structural
570
+ // similarity runs on non-shell nodes), so single-segment chrome-heavy pages don't over-merge
571
+ // under /{param} on their identical sidebars.
572
+ const observedKeys = [...landingsByKey.keys()];
573
+ const shell = extractShell(observedKeys.map((k) => faceOf(landingsByKey.get(k)[0])));
574
+ const minusShell = (f) => new Set([...f].filter((t) => !shell.has(t)));
575
+ // ── 3. PROPOSE/DISPOSE templates (rule 3) ──
576
+ // Group keys by proposeTemplates; a group MERGES only when every member's first-landing face,
577
+ // WITH SHELL SUBTRACTED, is structurally close (jaccard≥0.5) to the group's first member. On
578
+ // shell-subtracted faces so shared chrome can't inflate the similarity (the over-merge fix).
579
+ // Merged members map to a single canonical key (the template); non-merged keys keep their key.
580
+ // normFace (not faceOf): the dispose comparison runs on identity-normalized faces so two
581
+ // instances of one template (personalized dashboards) merge on their widget SIGS, not split on
582
+ // per-widget instance titles. Shell subtraction still applies on top.
583
+ // X3 (OQ1): scope the IDENTITY face to the declared `main` subtree before normFace — an ancillary
584
+ // complementary rail OUTSIDE `main` (per-page-varying, so not shell) must not pollute identity.
585
+ // No `main` declared → mainScope is a no-op (whole-face behavior unchanged). Shell subtraction
586
+ // still applies on top. Identity ONLY: stored faces / core / shadow / affordances read the union.
587
+ const firstFace = (k) => minusShell(normFace(mainScope(landingsByKey.get(k)[0])));
588
+ // The RAW (pre-normFace) control skeleton of a key's first landing — render-depth-stable, so the
589
+ // dispose's opaque-param control arm compares this (not controlFace(faceFor), whose grid-folding
590
+ // diverges between a settled and a mid-query landing of the SAME instance). See sameControlFaces.
591
+ const firstControlFace = (k) => controlFace(minusShell(faceOf(mainScope(landingsByKey.get(k)[0]))));
592
+ const canonical = new Map(); // member key → its merged template key
593
+ const templateForKey = new Map(); // canonical key → its template string
594
+ const opaqueTemplates = new Set(); // templates whose varying seg is an opaque id
595
+ const priorMergedTemplates = new Set(); // templates a member joined ONLY via the URL-
596
+ // template non-contradiction prior (not observed
597
+ // structural agreement) → mark the state provisional
598
+ const faceFor = new Map(); // key (original OR template) → anchor face
599
+ const ctlFor = new Map(); // key (original OR template) → RAW control face
600
+ for (const k of observedKeys) {
601
+ faceFor.set(k, firstFace(k));
602
+ ctlFor.set(k, firstControlFace(k));
603
+ }
604
+ for (const g of proposeTemplates(observedKeys)) {
605
+ const members = g.keys.filter((k) => landingsByKey.has(k) && !canonical.has(k));
606
+ if (members.length < 2)
607
+ continue;
608
+ // The CONTROL arm applies only to INSTANCE-shaped templates: every member's varying segment
609
+ // is an opaque id (digits / long hex — the sanctioned URL-shape prior). A varying WORD
610
+ // segment names a SECTION, not an instance: /report/list vs /dashboard/list share their
611
+ // data-grid controls, and control-merging them fused three different sections into one
612
+ // false /{param}/list state (live finding).
613
+ const opaqueParams = g.keys.every((k) => isOpaqueSeg(k.split('/').filter(Boolean)[g.paramPos] ?? ''));
614
+ const anchor = faceFor.get(members[0]), anchorCtl = ctlFor.get(members[0]);
615
+ // Non-contradiction arm (opaque-param groups ONLY): a thin/icon-only instance shares the
616
+ // template but can't clear the full-face or control gates — merge it when its face doesn't
617
+ // CONTRADICT the anchor (see nonContradictoryFaces). Rests on the URL prior → provisional.
618
+ const priorMatch = (k) => opaqueParams && nonContradictoryFaces(faceFor.get(k), anchor);
619
+ const merged = members.filter((k) => sameFace(faceFor.get(k), anchor)
620
+ || (opaqueParams && sameControlFaces(ctlFor.get(k), anchorCtl)) || priorMatch(k));
621
+ if (merged.length < 2)
622
+ continue; // dispose: not structurally one page
623
+ const observedAgree = (k) => sameFace(faceFor.get(k), anchor)
624
+ || (opaqueParams && sameControlFaces(ctlFor.get(k), anchorCtl));
625
+ if (merged.some((k) => !observedAgree(k)))
626
+ priorMergedTemplates.add(g.template);
627
+ for (const m of merged) {
628
+ canonical.set(m, g.template);
629
+ templateForKey.set(g.template, g.template);
630
+ }
631
+ // a template's face = the UNION of its members' faces (the template's whole OBSERVED
632
+ // repertoire). A single anchor face is a fragile representative: one report's anchor was its
633
+ // FULL builder view, a sibling report's anchor its SPARSE list view — cross-template jaccard
634
+ // 0.07 even though each template had a member view nearly identical to the other's (0.81).
635
+ // The union lets the fixpoint's containment arms see that shared member evidence.
636
+ faceFor.set(g.template, unionOf(merged.map((m) => faceFor.get(m))));
637
+ ctlFor.set(g.template, unionOf(merged.map((m) => ctlFor.get(m))));
638
+ if (opaqueParams)
639
+ opaqueTemplates.add(g.template);
640
+ }
641
+ // ── 3b. FIXPOINT over canonicalized keys (Finding 8): single-position proposal cannot merge
642
+ // MULTI-param URLs (/report/7002/f9e43 vs /report/7001/bd5a differ at TWO positions → no
643
+ // proposal → one sibling state per report id: instance explosion). After the positional pass,
644
+ // repeat a pairwise unification over the CANONICAL keys with `{param}` as a wildcard: two keys
645
+ // equal everywhere but ONE position (a {param} matches anything) merge under the SAME dispose
646
+ // arms — full-face OR (both differing segs opaque AND control-face). Bounded by the max
647
+ // segment count (each pass abstracts ≥1 position); over-merge is no easier than the positional
648
+ // pass since the arms and the word-segment gate are identical.
649
+ const resolveCanon = (k) => { let c = k; while (canonical.has(c))
650
+ c = canonical.get(c); return c; };
651
+ const segsOfKey = (k) => k.split('/').filter(Boolean);
652
+ const maxPasses = Math.max(0, ...observedKeys.map((k) => segsOfKey(k).length));
653
+ for (let pass = 0; pass < maxPasses; pass++) {
654
+ const current = [...new Set(observedKeys.map(resolveCanon))];
655
+ let mergedAny = false;
656
+ for (let i = 0; i < current.length; i++)
657
+ for (let j = i + 1; j < current.length; j++) {
658
+ const a = current[i], b = current[j];
659
+ if (canonical.has(a) || canonical.has(b))
660
+ continue; // re-merged earlier this pass
661
+ const sa = segsOfKey(a), sb = segsOfKey(b);
662
+ if (sa.length !== sb.length || !sa.length)
663
+ continue;
664
+ let diff = -1, ok = true; // {param}-wildcard unification
665
+ for (let x = 0; x < sa.length && ok; x++) {
666
+ if (sa[x] === sb[x] || sa[x] === '{param}' || sb[x] === '{param}')
667
+ continue;
668
+ if (diff >= 0)
669
+ ok = false;
670
+ else
671
+ diff = x;
672
+ }
673
+ if (!ok || diff < 0)
674
+ continue;
675
+ const bothOpaque = isOpaqueSeg(sa[diff]) && isOpaqueSeg(sb[diff]);
676
+ const fa = faceFor.get(a), fb = faceFor.get(b);
677
+ const ca = ctlFor.get(a) ?? controlFace(fa), cb = ctlFor.get(b) ?? controlFace(fb);
678
+ const observedAgree = sameFace(fa, fb) || (bothOpaque && sameControlFaces(ca, cb));
679
+ // Non-contradiction arm (opaque siblings only): merge two icon-only instances that share the
680
+ // template but each render too little to clear the full-face/control gates, unless their
681
+ // faces genuinely contradict. This is where /dashboard/8001 (rich icons) meets /dashboard/8002
682
+ // (thin) — the fixpoint compares canonical keys to EACH OTHER, so instances that never matched
683
+ // the list anchor still find each other here. Rests on the URL prior → provisional.
684
+ const priorMerge = bothOpaque && !observedAgree && nonContradictoryFaces(fa, fb);
685
+ if (!(observedAgree || priorMerge))
686
+ continue;
687
+ const t = '/' + sa.map((s, x) => (x === diff || s === '{param}' || sb[x] === '{param}') ? '{param}' : s).join('/');
688
+ if (t !== a)
689
+ canonical.set(a, t); // never a self-loop
690
+ if (t !== b)
691
+ canonical.set(b, t);
692
+ faceFor.set(t, unionOf([faceFor.get(t) ?? new Set(), fa, fb])); // union repertoire
693
+ ctlFor.set(t, unionOf([ctlFor.get(t) ?? new Set(), ca, cb])); // raw control skeleton
694
+ templateForKey.set(t, t);
695
+ if (bothOpaque || opaqueTemplates.has(a) || opaqueTemplates.has(b))
696
+ opaqueTemplates.add(t);
697
+ if (priorMerge || priorMergedTemplates.has(a) || priorMergedTemplates.has(b))
698
+ priorMergedTemplates.add(t);
699
+ mergedAny = true;
700
+ }
701
+ if (!mergedAny)
702
+ break;
703
+ }
704
+ const canonKey = resolveCanon;
705
+ // fold each observed key's landings/url into its canonical key.
706
+ const byCanon = new Map();
707
+ for (const k of observedKeys) {
708
+ const ck = canonKey(k);
709
+ const entry = byCanon.get(ck) ?? { landings: [], url: urlByKey.get(k), template: templateForKey.get(ck) ?? null };
710
+ entry.landings.push(...landingsByKey.get(k));
711
+ byCanon.set(ck, entry);
712
+ }
713
+ // ── 4+5+6. per canonical key → PageInfo(s): SPA split, core, naming ──
714
+ const pages = [];
715
+ const splitNeedsFix = [];
716
+ const mergeNodes = (into, add) => {
717
+ const seen = new Set(into.map((n) => `${n.role}:${n.name}`));
718
+ for (const n of add) {
719
+ const t = `${n.role}:${n.name}`;
720
+ if (!seen.has(t)) {
721
+ seen.add(t);
722
+ into.push(n);
723
+ }
724
+ }
725
+ };
726
+ const makePage = (k, url, template, landings, labelBase) => {
727
+ const faces = landings.map(faceOf);
728
+ // PARTIAL-RENDER exclusion (axis 1 settledness): a landing whose face is a (near-)strict
729
+ // subset of a sibling's (containment ≥ 0.95, strictly smaller) was captured before the page
730
+ // finished rendering — absence-due-to-non-render is NOT evidence of absence, so it must not
731
+ // feed templateCore (it would intersect the core down to the sparse subset: the husk class).
732
+ // Excluded from the CORE computation only — it stays a landing/face for identity checks. If
733
+ // only one full landing remains, templateCore's seen-once rule marks the state provisional:
734
+ // honest (we truly saw the full page once).
735
+ const isPartial = (i) => faces.some((g, j) => j !== i && faces[i].size < g.size && containment(faces[i], g) >= 0.95);
736
+ // A LOADING render (a "Running query…" live region) is non-settled — exclude it from CORE when
737
+ // a SETTLED (non-loading) sibling landing exists (else its transient chrome + missing data
738
+ // intersects the core down, losing the settled identity). If EVERY landing is loading, keep
739
+ // them all (honest: we only ever saw the page mid-load — templateCore then marks it provisional).
740
+ const loadingIdx = landings.map((l, i) => [l, i]).filter(([l]) => isLoadingRender(l)).map(([, i]) => i);
741
+ const settledExists = loadingIdx.length < landings.length;
742
+ const coreIdx = faces.map((_, i) => i).filter((i) => !isPartial(i) && !(settledExists && loadingIdx.includes(i)));
743
+ // rule 5: durable face = templateCore(full faces) minus shell — the site chrome lives on
744
+ // `_shell`, not on each page's core (else every state carries the whole sidebar).
745
+ const { tokens, provisional: coreProvisional } = templateCore(coreIdx.map((i) => faces[i]));
746
+ // An opaque-template state whose members joined via the non-contradiction PRIOR (not observed
747
+ // structural agreement) is provisional on that prior: a later contradicting landing re-splits it
748
+ // naturally on rebuild. This note takes precedence over templateCore's seen-once note.
749
+ const priorMerged = priorMergedTemplates.has(k);
750
+ const provisional = priorMerged
751
+ ? 'merged on URL-template prior — record more visits to confirm shared structure'
752
+ : coreProvisional;
753
+ const core = minusShell(tokens);
754
+ const nodes = [];
755
+ for (const l of landings)
756
+ mergeNodes(nodes, l); // union → full repertoire for affordances
757
+ // coreNodes from the first FULL landing — a partial landings[0] would filter the full core
758
+ // down to its own sparse subset of nodes.
759
+ const coreNodes = landings[coreIdx[0]].filter((n) => n.name && n.name.trim() && core.has(`${n.role}:${n.name}`));
760
+ // shadowNodes: the same landing keeping core-named nodes AND unnamed structural containers —
761
+ // extractShadow anchors columns on the (nameless) `table` node and walks depths; a named-only
762
+ // view has no containers, so its collections came out empty on every site.
763
+ const shadowNodes = landings[coreIdx[0]].filter((n) => !n.name || !n.name.trim() || core.has(`${n.role}:${n.name}`));
764
+ return { key: k, url, template, label: labelBase, landings, faces, core, coreNodes, provisional, priorMerged, nodes, shadowNodes };
765
+ };
766
+ for (const [k, { landings, url, template }] of byCanon) {
767
+ const labelBase = labelFromKey(template ?? k);
768
+ // rule 4: SPA split — single-link cluster the landing faces, on SHELL-SUBTRACTED faces (same
769
+ // as dispose): the shared chrome is on every SPA view, so leaving it in would falsely
770
+ // collapse structurally-distinct views into one cluster. A key merged under an OPAQUE-id
771
+ // template clusters with the dispose's OWN predicate (control arm included): its landings are
772
+ // different INSTANCES whose full faces differ by data — re-clustering them with the stricter
773
+ // same-URL predicate would instantly undo the merge and name states by instance headings.
774
+ // X3 (OQ1): same main-scoping as the dispose face — the SPA-split runs on the identity face,
775
+ // so a rail outside `main` must not force (or suppress) a split. No-op when no `main` declared.
776
+ const identFaces = landings.map((l) => minusShell(normFace(mainScope(l))));
777
+ // For an opaque-id template, the control arm must read the RAW control face of each landing
778
+ // (same render-depth-stability fix as the dispose): a mid-query + a settled landing of one
779
+ // instance would otherwise re-split on their normFace'd (grid-folded) controls, undoing the
780
+ // dispose merge. Index into the raw control faces by cluster position.
781
+ const rawCtl = landings.map((l) => controlFace(minusShell(faceOf(mainScope(l)))));
782
+ // A template merged via the NON-CONTRADICTION prior (thin instances that agree on neither the
783
+ // full-face nor the control arm) must cluster with the SAME prior arm here — else the SPA-split
784
+ // re-splits the very instances the dispose just merged (the two thin dashboards land in separate
785
+ // clusters, find no distinguishing heading, and drop to needsFix — the merge is undone). Same
786
+ // principle as the control arm above: re-clustering with a STRICTER predicate undoes the merge.
787
+ const pred = priorMergedTemplates.has(k)
788
+ ? (ia, ib) => sameFace(identFaces[ia], identFaces[ib]) || sameControlFaces(rawCtl[ia], rawCtl[ib]) || nonContradictoryFaces(identFaces[ia], identFaces[ib])
789
+ : opaqueTemplates.has(k)
790
+ ? (ia, ib) => sameFace(identFaces[ia], identFaces[ib]) || sameControlFaces(rawCtl[ia], rawCtl[ib])
791
+ : (ia, ib) => sameFace(identFaces[ia], identFaces[ib]);
792
+ const clusters = clusterFacesByIndex(identFaces.length, pred);
793
+ // A cluster whose landings are ALL error pages is a transient / pre-redirect capture, NOT a
794
+ // real second state at this key (axis 1: a non-settled URL is an alias, never a state). On old
795
+ // data with no `requestedUrl`, the pre-redirect ghost was snapshotted as its own 'ready' 404
796
+ // (a bare `/x/list` → "Page not found", which base-inference merges into the settled tenant
797
+ // `/1234/x/list`). Hold error clusters out as needsFix; they must NOT force an SPA split or
798
+ // suffix the healthy sibling's name. What's left is the REAL cluster(s).
799
+ const errorCluster = (idxs) => idxs.every((i) => isErrorLanding(landings[i]));
800
+ const realClusters = clusters.filter((idxs) => !errorCluster(idxs));
801
+ for (const idxs of clusters)
802
+ if (errorCluster(idxs))
803
+ splitNeedsFix.push({ label: labelBase, url, reason: 'error page (heading matches not-found/error)' });
804
+ // ≤1 REAL cluster → not an SPA split: the single page at labelBase (no suffix), landings = its
805
+ // real cluster (or, if every cluster was an error, all landings — the whole page is degenerate,
806
+ // partition-good-vs-degenerate below holds it out honestly).
807
+ if (realClusters.length <= 1) {
808
+ const keep = realClusters.length === 1 ? realClusters[0].map((i) => landings[i]) : landings;
809
+ pages.push(makePage(k, url, template, keep, labelBase));
810
+ continue;
811
+ }
812
+ // ≥2 real clusters at one key → CANDIDATE SPA split. The discriminator that names a split state
813
+ // must be STRUCTURAL (a page-TITLE heading / tab), NEVER instance CONTENT. A cluster core computed
814
+ // with faceOf carries two kinds of data heading distinguishingHeading would wrongly pick:
815
+ // • FOLDED template-member names — a repeated data-item card's title (an announcement item,
816
+ // `heading:Renamed Dimensions and Metrics`); dropped via templateFolds.gatedNames.
817
+ // • DEEP (level ≥ 2) content headings — a help-article / section / item title nested in the page
818
+ // body (`heading:Interface User Guide [level=3]`). The page's OWN title is the level-1 `h1`
819
+ // (`Help Center`, `Announcements`); a level ≥ 2 heading is content, never a page discriminator.
820
+ // Compute each cluster's core with BOTH excluded (the same de-valued residue normFace drops for the
821
+ // folded case): what's left is structure only. If nothing structural then distinguishes the
822
+ // clusters, they are instances of ONE page → merge (below), never split-and-name-by-content.
823
+ const deepHeadingNames = (nodes) => {
824
+ const out = new Set();
825
+ for (const n of nodes)
826
+ if (n.role === 'heading' && n.name && /\[level=([2-9]|\d\d+)\]/.test(n.raw))
827
+ out.add(n.name);
828
+ return out;
829
+ };
830
+ const clusterCore = (idxs) => {
831
+ const core = templateCore(idxs.map((i) => faceOf(landings[i]))).tokens;
832
+ const excluded = new Set();
833
+ for (const i of idxs) {
834
+ for (const nm of templateFolds(landings[i]).gatedNames)
835
+ excluded.add(nm);
836
+ for (const nm of deepHeadingNames(landings[i]))
837
+ excluded.add(nm);
838
+ }
839
+ return new Set([...core].filter((t) => !excluded.has(t.slice(t.indexOf(':') + 1))));
840
+ };
841
+ const clusterCores = realClusters.map(clusterCore);
842
+ // On a param key, the discriminator must be a tab, not a heading (headings are instance titles).
843
+ const keyIsParam = (!!template && template.includes('{param}')) || looksParameterizedKey(k);
844
+ const distinctPerCluster = realClusters.map((_, ci) => distinguishingHeading(clusterCores[ci], clusterCores.filter((_, j) => j !== ci), keyIsParam));
845
+ // If NO cluster has a structural discriminator, the clusters differ ONLY by instance data — they
846
+ // are landings of ONE template (a sparse pre-data render + a settled render), so MERGE them into
847
+ // one page (templateCore then marks it provisional), never split-and-name-by-content nor drop the
848
+ // odd one to needsFix. (A GENUINE SPA split — Owned vs Shared, list vs viewer — keeps its distinct
849
+ // structural headings/tabs after data exclusion, so at least one cluster is distinguishable and the
850
+ // split proceeds below.)
851
+ if (distinctPerCluster.every((d) => d === null)) {
852
+ pages.push(makePage(k, url, template, realClusters.flat().map((i) => landings[i]), labelBase));
853
+ continue;
854
+ }
855
+ // ≥1 cluster is structurally distinct → genuine SPA split, each named by a STRUCTURAL heading
856
+ // unique to its cluster. A cluster with no such heading (its identity is instance data) is held
857
+ // out — it cannot be told apart from its siblings by structure.
858
+ realClusters.forEach((idxs, ci) => {
859
+ const distinct = distinctPerCluster[ci];
860
+ const clusterLandings = idxs.map((i) => landings[i]);
861
+ if (!distinct) {
862
+ splitNeedsFix.push({ label: labelBase, url, reason: 'same-url state with no distinguishing heading' });
863
+ return;
864
+ }
865
+ pages.push(makePage(k, url, template, clusterLandings, `${labelBase}-${slug(distinct)}`));
866
+ });
867
+ }
868
+ // ── 6b. NAME collisions: append a distinguishing core heading; still colliding → both needsFix ──
869
+ // An ERROR-page landing (a pre-redirect "Page not found" ghost that base-inference keyed onto a
870
+ // real page's label) is held out as degenerate BELOW (pass A) — but that detection ran AFTER this
871
+ // collision pass, so a ghost polluted the label namespace: it collided with the HEALTHY state of
872
+ // the same label, forcing that healthy state to take a disambiguating `-heading` suffix (real:
873
+ // `report-list` → `report-list-reports` because a `/v3/report/list` 404 ghost also labeled
874
+ // `report-list`), or pushing a genuine state to `name collision unresolved`. Detect error landings
875
+ // HERE and exclude them from the collision namespace — they never compete for a healthy state's name.
876
+ const nameNeedsFix = new Map();
877
+ const byLabelGroups = new Map();
878
+ for (const p of pages) {
879
+ if (isErrorLanding(p.landings[0]))
880
+ continue; // error ghost — held out below, not a name rival
881
+ (byLabelGroups.get(p.label) ?? byLabelGroups.set(p.label, []).get(p.label)).push(p);
882
+ }
883
+ for (const [, group] of byLabelGroups) {
884
+ if (group.length < 2)
885
+ continue;
886
+ for (const p of group) {
887
+ // param page → tab-only discriminator (a heading here is instance data: `dashboard-demouser`,
888
+ // `report-<report-name>`). Same isParam test the fingerprint pool uses.
889
+ const pIsParam = (!!p.template && p.template.includes('{param}')) || looksParameterizedKey(p.key);
890
+ const distinct = distinguishingHeading(p.core, group.filter((q) => q !== p).map((q) => q.core), pIsParam);
891
+ if (distinct)
892
+ p.label = `${p.label}-${slug(distinct)}`;
893
+ }
894
+ // re-check: any still-duplicate label → all its members go to needsFix (name collision unresolved).
895
+ const still = new Map();
896
+ for (const p of group)
897
+ (still.get(p.label) ?? still.set(p.label, []).get(p.label)).push(p);
898
+ for (const [, dupes] of still)
899
+ if (dupes.length > 1)
900
+ for (const p of dupes)
901
+ nameNeedsFix.set(p, 'name collision unresolved');
902
+ }
903
+ const pageList = pages.filter((p) => !nameNeedsFix.has(p));
904
+ // effect → its TO page (primary page for the settled key). SPA-split attribution is Task 9's
905
+ // job; affordance synthesis targets the FIRST page for a key (the common non-split case).
906
+ const pageForKey = new Map();
907
+ for (const p of pageList)
908
+ if (!pageForKey.has(p.key))
909
+ pageForKey.set(p.key, p);
910
+ // URL → the PAGE key: alias-resolved AND canonicalized. A page that merged into a {param}
911
+ // template is registered under the TEMPLATE key (p.key), so every lookup from a raw URL must
912
+ // apply canonKey too — without it, every recorded action FROM a merged member URL resolved no
913
+ // label and was silently dropped (the report-builder husk's second cause, latent until the
914
+ // per-session entry landings made the /report/7001/{param} merge actually happen).
915
+ const fromPageKey = (url) => canonKey(key(url));
916
+ const pageKeyForEffectLanding = new Map();
917
+ effects.forEach((e, i) => { if (e.navigated && e.toSnapshot && ready(e.toSnapshot))
918
+ pageKeyForEffectLanding.set(i, fromPageKey(e.toUrl)); });
919
+ const labelOf = (k) => (k ? pageForKey.get(k)?.label ?? null : null);
920
+ // A page's HUMAN display name: its first core `heading:` token (document order — Set keeps
921
+ // faceOf's insertion order). Used when a navigate affordance has NO recovered element name —
922
+ // labeling it with the target's state SLUG leaks a technical identifier into the user-facing
923
+ // repertoire (real map: 3/15 navigate affordances read 'report-list'/'dashboard-category').
924
+ const displayNameOf = (label) => {
925
+ const p = pageList.find((q) => q.label === label);
926
+ for (const t of p?.core ?? [])
927
+ if (t.startsWith('heading:'))
928
+ return t.slice('heading:'.length);
929
+ return null;
930
+ };
931
+ // ── 2. partition GOOD vs DEGENERATE, then fingerprint the good set against ITSELF ──
932
+ // A degenerate landing (404/error, or no distinctive content) is held OUT of `states` and
933
+ // out of the fingerprint exclusivity computation — otherwise its all-chrome fingerprint
934
+ // makes every real page read 'ambiguous'. It's reported in `needsFix` for the agent (#5a).
935
+ const degenerate = new Map(); // page index → reason
936
+ // pass A: error pages are degenerate up front (regardless of fingerprint). Read the raw first
937
+ // landing (an error heading is announced on the page itself, not necessarily in the durable core).
938
+ pageList.forEach((p, i) => { if (isErrorLanding(p.landings[0]))
939
+ degenerate.set(i, `error page (heading matches not-found/error)`); });
940
+ // A page's fingerprint = the MINIMAL prefix of its CORE candidate tokens (Task 10) that no
941
+ // OTHER GOOD page's LANDING FACE fully satisfies. Fingerprint from the CORE, not the union or
942
+ // a single landing: a token that VARIED across this page's landings is data (dropped by
943
+ // templateCore) and must never anchor identity. Exclusivity is checked against every OTHER
944
+ // page's landing faces — an fp is exclusive only when NO face of any other good page contains
945
+ // all its tokens (so a walk can't land on a sibling and match this state too). Purely pairwise
946
+ // (does NOT depend on other stubs' in-progress fingerprints — that ordering bug produced 30-token
947
+ // fingerprints).
948
+ const goodIdx = (i) => !degenerate.has(i);
949
+ const stubs = pageList.map((p) => makeState({
950
+ id: 'd:' + p.label, nodeId: 'd', semanticName: p.label, urlPattern: p.url, role: 'detail', fingerprint: [],
951
+ }));
952
+ // ── unknowns (extension-loop report, Task 2): collected inline as the pipeline runs, assembled
953
+ // + capped at the end. Source (a) + (e) below; sources (b)-(d) assembled after their producing
954
+ // stage runs (needsFix, self-verify).
955
+ const unknowns = [];
956
+ // HUSK TRIPWIRE (source e): per-page safe-pack list, computed ONCE per page from its own core
957
+ // nodes (the would-be affordance-name universe) and reused at every hook call site for that page
958
+ // (fp pool / overlay-added clicks / interior synthesis) — one decision per (page, pack), not one
959
+ // per hook call, so a pack is never safe at one call site and tripped at another on the SAME page.
960
+ const packsByPage = new Map();
961
+ for (const p of pageList) {
962
+ const { safe, tripped } = splitTrippedPacks(packs, p.coreNodes);
963
+ packsByPage.set(p.label, safe);
964
+ for (const t of tripped) {
965
+ unknowns.push({
966
+ kind: 'pack-tripwire', evidence: evidenceOf(p.coreNodes),
967
+ context: `${t.pack.name} would excise ${t.excised}/${t.total} affordance names on ${p.label} — disabled for this page`,
968
+ extensionPoint: 'core-design',
969
+ });
970
+ }
971
+ }
972
+ const packsFor = (label) => packsByPage.get(label) ?? packs;
973
+ for (let pi = 0; pi < pageList.length; pi++) {
974
+ if (degenerate.has(pi))
975
+ continue; // error pages: no fingerprint attempt
976
+ const p = pageList[pi];
977
+ // param = a formed {param} template OR a lone opaque-id-tail key (the sanctioned URL-shape
978
+ // prior) — either way the big heading is "probably instance data", handled provisionally below.
979
+ const isParam = (!!p.template && p.template.includes('{param}')) || looksParameterizedKey(p.key);
980
+ // A FOLDED member's name (axis 5: `<X> Remove` metric chips, row `Delete`s, a repeated widget
981
+ // card's instance title) is value-bound DATA — it must never anchor identity (else `report`'s
982
+ // fp rests on `OS Remove`/`eCPM Remove`, the specific metrics of one instance). Drop the folded
983
+ // names from the fp candidates. templateFolds subsumes the Task-15 foldRepeats exclusion.
984
+ // X3 (OQ1): the fingerprint candidate POOL scopes to the declared `main` subtree — a rail
985
+ // token outside `main` can't anchor identity when `main` exists (it would fingerprint the page
986
+ // on chrome). No `main` declared → mainScope is a no-op (whole coreNodes, unchanged). Scopes
987
+ // ONLY the fp pool here; p.coreNodes itself (shadow, other reads) is untouched.
988
+ const fpPool = mainScope(p.coreNodes);
989
+ // HOOK 2c (value-domain pack, `landing` context): pack-marked value names also can't anchor
990
+ // identity — join the folded-name exclusion for the fingerprint candidate pool (same discipline
991
+ // as the interior-synthesis exclusion; only ever shrinks the pool).
992
+ const fpFolded = new Set([...templateFolds(fpPool).gatedNames, ...packValueNames(packsFor(p.label), fpPool)]);
993
+ const fpNodes = fpFolded.size ? fpPool.filter((n) => !(n.name && fpFolded.has(n.name))) : fpPool;
994
+ const cands = candidateTokensFor(fpNodes, isParam);
995
+ // pass B (empty core): a good page whose durable core carries NO candidate token — its only
996
+ // content is shared shell chrome (a blank/empty landing) — has no distinctive identity. Held
997
+ // out with a clear reason (matches upstream: identity is drawn from the core, and an empty
998
+ // core is genuinely indistinguishable from every other logged-in page).
999
+ if (!cands.length) {
1000
+ degenerate.set(pi, `no distinctive content — only shared sidebar chrome (blank/empty or unresolved landing)`);
1001
+ continue;
1002
+ }
1003
+ const fp = [];
1004
+ let exclusive = false;
1005
+ for (const tok of cands) {
1006
+ fp.push(tok);
1007
+ // exclusive = NO face of any OTHER GOOD page contains EVERY token in fp so far.
1008
+ exclusive = pageList.every((q, qi) => qi === pi || !goodIdx(qi) || q.faces.every((f) => !fp.every((t) => f.has(t))));
1009
+ if (exclusive)
1010
+ break;
1011
+ }
1012
+ // a page that NEVER became exclusive (its core content is shared with a sibling) is degenerate.
1013
+ if (!exclusive) {
1014
+ degenerate.set(pi, `no distinctive content — only shared sidebar chrome (blank/empty or unresolved landing)`);
1015
+ continue;
1016
+ }
1017
+ // rule 1 (axis 4): on a {param} page whose identity rests ONLY on heading token(s), the
1018
+ // discriminator is CROSS-INSTANCE VARIANCE, split by how many instances we saw:
1019
+ // • ≥2 instances (provisional=null): the heading REPEATED across instances → it's the
1020
+ // page-type TITLE (structural), not per-instance data. Keep it as the fingerprint. (The
1021
+ // `/employee/{param}` "Employee Profile" case — confirmed structural by repetition.)
1022
+ // • 1 instance (provisional set): cannot separate the heading from data — it may be the
1023
+ // instance's own name (a user, a report → the `heading:Demo User` failure). No durable
1024
+ // identity YET: hold out as needsFix with the record-next ask, never a silent instance-data
1025
+ // fingerprint. A param page with a STRUCTURAL token (tab/button) already kept it above.
1026
+ const headingOnly = fp.length && fp.every((t) => t.startsWith('heading:'));
1027
+ // A PRIOR-MERGED template already has ≥2 instances whose SHARED heading survived templateCore —
1028
+ // that repetition is exactly the "structural, not instance data" signal the 1-instance holdout
1029
+ // below is missing. It's provisional-BY-PRIOR (multi-instance), not provisional-by-seen-once, so
1030
+ // it must NOT be held out: keep the shared heading as identity, keep the prior note.
1031
+ if (isParam && headingOnly && p.provisional && !p.priorMerged) {
1032
+ degenerate.set(pi, 'identity rests only on a heading that is likely instance data — record a different {param} instance to separate structure from data');
1033
+ continue;
1034
+ }
1035
+ stubs[pi].fingerprint = fp;
1036
+ // ≥2-instance heading-only identity is structural-but-still-worth-flagging: surface the
1037
+ // record-next note (kept, not held out). Matches the pre-rewrite provisional-warning contract.
1038
+ // A prior-merged state already carries its own (prior) note — don't overwrite it.
1039
+ if (isParam && headingOnly && !p.provisional) {
1040
+ const note = 'identity rests on a heading that may be instance data — record a different {param} instance';
1041
+ p.provisional = note;
1042
+ }
1043
+ }
1044
+ // ── 3+4. affordances per FROM page from the recorded transitions ──
1045
+ const affById = new Map(); // page label → its affordances
1046
+ // One repertoire row per DISTINCT control: recorded ids are per-click (`aff_N_…`), so a
1047
+ // re-clicked control must dedup by control identity (kind+label+to), not id — else every
1048
+ // repeat click duplicates the affordance (the builder had `Remove` ×6, `Share` ×2). A
1049
+ // re-observed REVEAL may expose children an earlier click didn't → union children by label;
1050
+ // a later navigate that gained `needs` (login inputs) keeps them.
1051
+ const pushAff = (label, a) => {
1052
+ const list = affById.get(label) ?? [];
1053
+ affById.set(label, list);
1054
+ const twin = list.find((x) => x.kind === a.kind && x.label === a.label && x.to === a.to);
1055
+ if (!twin) {
1056
+ list.push(a);
1057
+ return list;
1058
+ }
1059
+ if (a.children?.length) {
1060
+ const have = new Set((twin.children ?? []).map((c) => c.label));
1061
+ twin.children = [...(twin.children ?? []), ...a.children.filter((c) => !have.has(c.label))];
1062
+ }
1063
+ if (a.needs && !twin.needs) {
1064
+ twin.needs = a.needs;
1065
+ twin.acceptsInput = a.acceptsInput;
1066
+ }
1067
+ return list;
1068
+ };
1069
+ let affSeq = 0;
1070
+ // TRANSIENT-OVERLAY TRACKING (X1, rule 1): the DECLARED-overlay gate (clickedInOverlay) misses
1071
+ // an UNDECLARED role-less portal (AntD Popover/Bootstrap dropdown appended at document end with
1072
+ // no dialog/menu/listbox ancestor) — but the effect-diff SAW that subtree get added when the
1073
+ // opener was clicked. So per PAGE, in session order, we track the tokens an opener ADDED and
1074
+ // haven't been REMOVED: a later click whose role:name is in that set targeted the overlay, and
1075
+ // is gated exactly like clickedInOverlay (it's the OR-arm; declared containers stay the stronger
1076
+ // signal). Cleared on navigation AWAY (the overlay context is gone). Simple per-page Set.
1077
+ const transientByPage = new Map();
1078
+ const transientOf = (k) => transientByPage.get(k) ?? transientByPage.set(k, new Set()).get(k);
1079
+ effects.forEach((e, i) => {
1080
+ // session boundary (the same seq-reset signal the landing pass reads): a NEW session is a fresh
1081
+ // browser — no overlay survives it, so stale transient tokens must not gate the new session.
1082
+ if (i > 0 && e.seq <= effects[i - 1].seq)
1083
+ transientByPage.clear();
1084
+ if (!e.fromSnapshot)
1085
+ return;
1086
+ const fromKey = fromPageKey(e.fromUrl);
1087
+ const fromLabel = labelOf(fromKey);
1088
+ if (!fromLabel)
1089
+ return;
1090
+ const fromNodes = parseSnapshot(e.fromSnapshot);
1091
+ const transient = transientOf(fromKey);
1092
+ // SHELL GATE (axis 3): a recorded action ON a shell node (a click on `Dark Mode`/`Close
1093
+ // sidebar`/`O Overview Merged Change`, a nav via `Help Center`/`Announcements`) is a SHELL
1094
+ // affordance — it lives ONCE on `_shell` (synthesized below), NOT duplicated onto whatever
1095
+ // page it happened to be clicked from. Same principle the cross-link mesh already applies to
1096
+ // shell links; without it the chrome leaks onto every page as a broken page affordance.
1097
+ if (e.action?.name && shell.has(`${e.action.role}:${e.action.name}`))
1098
+ return;
1099
+ // ── non-navigating recorded action → in-page affordance (Layer 1) ──
1100
+ if (!e.navigated && e.action) {
1101
+ // OVERLAY GATE (rules 1+5): a recorded action on a node that was INSIDE an overlay on the
1102
+ // page-as-clicked (its FROM snapshot has it nested under a dialog/menu/listbox) is NOT a page
1103
+ // affordance — its structure already lives as the OPENER's `children`. Emitting it too would
1104
+ // duplicate every picked value (a chosen dimension, a typed search term) as page structure —
1105
+ // exactly the DATA-VALUE leak this task removes. Test on the FROM snapshot (the page as it
1106
+ // was when clicked), parsed above — never landings.
1107
+ // X1 OR-arm: OR the action's role:name is in the page's TRANSIENT-overlay set (a subtree a
1108
+ // prior opener added and nothing has removed) — catches role-less portals insideOverlay misses.
1109
+ // CHURN EXCLUSION (shared with the overlay-shape guard below): a token in BOTH removed and
1110
+ // added is a re-render of existing page content (a sort/tab re-paints the same rows), NOT
1111
+ // overlay content — it must neither enter the transient set nor count as an overlay opening.
1112
+ const removedToks = new Set((e.diff?.removed ?? []).map((n) => `${n.role}:${n.name}`));
1113
+ const syncTransient = () => {
1114
+ for (const t of removedToks)
1115
+ transient.delete(t);
1116
+ for (const n of e.diff?.added ?? []) {
1117
+ if (n.name && n.name.trim() && !removedToks.has(`${n.role}:${n.name}`))
1118
+ transient.add(`${n.role}:${n.name}`);
1119
+ }
1120
+ };
1121
+ const actionTok = e.action.name ? `${e.action.role}:${e.action.name}` : null;
1122
+ if (clickedInOverlay(fromNodes, e.action) || (actionTok && transient.has(actionTok))) {
1123
+ // even when gated, this click's OWN diff may open a nested overlay / dismiss the current one —
1124
+ // keep the transient set current so the following clicks gate correctly.
1125
+ syncTransient();
1126
+ return;
1127
+ }
1128
+ // NON-gated opener/mutate: it stays a page affordance (below), but its diff SUBTREE joins the
1129
+ // transient set so the value clicks that FOLLOW it gate as overlay content (X1). Removed tokens
1130
+ // (a dismissed overlay) leave the set. Done here (post-gate, pre-processing) so an opener never
1131
+ // gates ITSELF — its added children only affect the SUBSEQUENT effects.
1132
+ syncTransient();
1133
+ // a `use type` on a textbox → an input affordance (login or any field).
1134
+ if (e.action.role === 'textbox' && e.action.name) {
1135
+ pushAff(fromLabel, { id: `inp_${slug(e.action.name)}`, label: e.action.name, kind: 'input',
1136
+ elementFp: { role: 'textbox', name: e.action.name, near: null } });
1137
+ return;
1138
+ }
1139
+ // ROW-FOLD (rowfold, rule 5/6): a recorded click whose element role is a COLLECTION role
1140
+ // (`row`/`gridcell`/`cell`) is a per-row INSTANCE interaction — its accessible name is the
1141
+ // row's instance data (a list item's title + date + owner), never durable structure. The
1142
+ // interior-synthesis fold (templateFolds/subtreeFolds) never reaches a recorded click, so N
1143
+ // such clicks would otherwise store N instance-labeled affordances. Fold to ONE scope:'row'
1144
+ // template with a STRUCTURAL label (the collection role) + no per-instance fp (a folded
1145
+ // template has no single durable coordinate; a row-mutate never routes). pushAff dedups by
1146
+ // (kind+label+to), so every row click on a page collapses into this one template.
1147
+ if (COLLECTION_ROLES.has(e.action.role)) {
1148
+ pushAff(fromLabel, { id: `aff_${affSeq++}_${e.action.role}`, label: e.action.role,
1149
+ kind: 'mutate', scope: 'row', elementFp: null });
1150
+ return;
1151
+ }
1152
+ // any other click: REVEAL if it exposed new ARIA-named nodes (an overlay/menu opened),
1153
+ // else MUTATE (an in-place change — sort/filter/search). Carries the recovered elementFp.
1154
+ if (e.action.name) {
1155
+ const fp = e.action.elementFp ?? { role: e.action.role, name: e.action.name, near: null };
1156
+ // reveal children are DE-VALUED (rule 2), two overlay-scoped folds:
1157
+ // • templateFolds(addedNodes) — repeated sibling SUBTREES (category widgets, `<X> Remove`
1158
+ // chips) whose instance content is data;
1159
+ // • enumeratedNames — the flat ≥3 same-role/depth value domain subtreeFolds can't reach
1160
+ // because it's split across heterogeneous per-category wrappers (see helper note).
1161
+ // Together they drop the picker's dimension/metric choice list; the overlay's own controls
1162
+ // (Apply/Cancel/Close/Search/tabs) don't repeat, so they survive.
1163
+ const addedNodes = e.diff?.added ?? [];
1164
+ const { emit: addedFolds, gatedNames: foldedNames } = templateFolds(addedNodes);
1165
+ const valueDomain = enumeratedNames(addedNodes);
1166
+ // COLLECTION-REPAINT guard (review F2 + final-review fixes): a data-grid repaint (sort/
1167
+ // refresh/filter-tab re-renders rows) adds a large named subtree that is NOT an overlay —
1168
+ // its named nodes are dominated by collection roles (row/gridcell/columnheader/cell) AND it
1169
+ // announces declared grid structure (`row`/`columnheader`; measured on real data: repaints
1170
+ // are 72-74% collection WITH rows+headers, a date-picker's day-cell grid has ZERO rows/
1171
+ // headers). >50% dominance = documented tunable. This guard is SHARED by ALL diff-based
1172
+ // pack evaluation AND the unknowns report: a repaint diff never reaches a pack — the core's
1173
+ // own detection rejected exactly this shape (fold/straggler exclusions). Letting packs see
1174
+ // repaints flipped real Refresh-list clicks mutate→reveal (overlay-open hook) and excised a
1175
+ // genuine reveal's stored children when its overlay happened to re-render a grid alongside
1176
+ // its controls (value-domain hook) — both final whole-branch review findings.
1177
+ const namedAdded = addedNodes.filter((n) => n.name && n.name.trim());
1178
+ const collectionNamed = namedAdded.filter((n) => COLLECTION_ROLES.has(n.role)).length;
1179
+ const hasGridStructure = namedAdded.some((n) => n.role === 'row' || n.role === 'columnheader');
1180
+ const gridRepaint = namedAdded.length > 0 && hasGridStructure
1181
+ && collectionNamed / namedAdded.length > COLLECTION_DOMINANCE;
1182
+ // HOOK 2a (value-domain pack, `overlay` context): nodes inside a firing value-domain trigger
1183
+ // over the overlay's added subtree are value data — excluded from reveal children exactly
1184
+ // like the built-in value folds. Extends the excluded set; never adds children. Repaint
1185
+ // diffs are gated by the SHARED guard above: a grid re-rendering inside a genuine reveal
1186
+ // (rows+headers alongside the overlay's controls) must not excise those controls.
1187
+ const packValues = gridRepaint ? new Set() : packValueNames(packsFor(fromLabel), addedNodes);
1188
+ const children = addedNodes
1189
+ .filter((n) => n.role && n.name && REVEAL_CHILD_ROLES.has(n.role) && !foldedNames.has(n.name) && !valueDomain.has(n.name) && !packValues.has(n.name))
1190
+ .map((n) => ({ id: `aff_${affSeq++}_${slug(n.name)}`, label: n.name,
1191
+ kind: childKind(n.role), elementFp: { role: n.role, name: n.name, near: null },
1192
+ ...(COMMIT_WORDS.test(n.name) ? { needsClassification: true } : {}) }));
1193
+ // CLASSIFY BY BEHAVIOR (rule 2) + OVERLAY-SHAPE GUARD (review fix): an effect OPENED an
1194
+ // overlay iff its diff added ≥1 named INTERACTIVE node that is genuinely NEW — meaning:
1195
+ // • role ∈ the DETECTION set (REVEAL_CHILD_ROLES ∪ CONTROL_ROLES ∪ `option`),
1196
+ // • role:name NOT also in diff.removed (churn: a sort re-paints the same controls), and
1197
+ // • name NOT gated by a repeated-SUBTREE fold (a filter-tab re-render swaps in DIFFERENT
1198
+ // rows — new link tokens, but they arrive as ≥2 same-shape row subtrees = re-rendered
1199
+ // collection DATA, not overlay controls; live finding on the tab-switch re-render).
1200
+ // Any-named-node alone misclassified real actions: tab re-render → reveal (wrong, mutate);
1201
+ // click-shows-tooltip (text-only added) → reveal (wrong, mutate). A genuine portal/dialog
1202
+ // adds NEW unfolded controls → reveal, even when every child folds out as value domain
1203
+ // (children legitimately `[]`, the option-only portal — enumeratedNames values still DETECT:
1204
+ // an added value list is definitely an overlay, so valueDomain is NOT excluded here).
1205
+ // DETECTION set ≠ STORAGE set: `option` counts for DETECTION but options are never STORED
1206
+ // as children (REVEAL_CHILD_ROLES excludes them — value domain, read live at walk time).
1207
+ const overlayControl = (role) => REVEAL_CHILD_ROLES.has(role) || CONTROL_ROLES.has(role) || role === 'option';
1208
+ // STRAGGLER-UNIT exclusion (live finding): a real grid has per-row shape variance (one row
1209
+ // carries an extra badge cell), so one row can MISS the widget fold that caught its 14
1210
+ // siblings — its interior link then reads as a "new control" and flips the re-render to
1211
+ // reveal. When the added diff DID fold repeated units (unitSize ≥2), a control nested under
1212
+ // another node of the same unit-root role (`row`) is collection data too, fold or no fold.
1213
+ const foldRootRoles = new Set(addedFolds.filter((f) => f.unitSize >= 2).map((f) => addedNodes[f.memberIndices[0]].role));
1214
+ const underFoldRootRole = (idx) => {
1215
+ let cur = addedNodes[idx].depth;
1216
+ for (let j = idx - 1; j >= 0; j--) {
1217
+ if (addedNodes[j].depth < cur) {
1218
+ if (foldRootRoles.has(addedNodes[j].role))
1219
+ return true;
1220
+ cur = addedNodes[j].depth;
1221
+ }
1222
+ }
1223
+ return false;
1224
+ };
1225
+ // HOOK 1 (overlay-open pack): the built-in detection above is the strict 4-exclusion scan.
1226
+ // ONLY when it DECLINES — and the diff is NOT a collection repaint — do packs get a say: a
1227
+ // matching `overlay-open` entry flips detection true for an UNDECLARED role-less portal the
1228
+ // strict scan can't see (a div-soup overlay whose added subtree carries no interactive/attr
1229
+ // signal). Detection only: the transient attribution set stays maximal (built above, NOT
1230
+ // pack-influenced), so gated value clicks are unaffected. Evaluated on diff.added (the
1231
+ // pack's `diff.added` context) — the subtree the opener revealed.
1232
+ const openedOverlay = addedNodes.some((n, idx) => n.name && n.name.trim()
1233
+ && overlayControl(n.role) && !removedToks.has(`${n.role}:${n.name}`) && !foldedNames.has(n.name)
1234
+ && !(foldRootRoles.size > 0 && underFoldRootRole(idx)))
1235
+ || (!gridRepaint && packDetectsOverlay(packsFor(fromLabel), addedNodes));
1236
+ // UNKNOWNS source (a) — undetected-overlay: BOTH the built-in scan AND every loaded pack
1237
+ // declined, yet the diff added a substantial (≥5) named subtree that stayed `mutate`. This
1238
+ // is the honest "detection declined" gap `dev pattern-propose` (Task 3) turns into a new
1239
+ // `overlay-open` pack entry. addedNodes.length gate (not just named-count) keeps this to
1240
+ // real substantial subtrees, matching the plan's "≥N-node named added-diffs" language.
1241
+ // Repaints are excluded by the SAME gridRepaint guard above: reporting one would actively
1242
+ // suggest an overlay-open pack where NONE is needed (the mutate classification was
1243
+ // CORRECT). REFINEMENT (evidence-gated, real data): dominance alone also caught the genuine
1244
+ // date-picker (its day-cell calendar is 31 gridcells = 56% of its subtree), which the
1245
+ // review's own test contract says must STAY reported — hence the row/columnheader arm.
1246
+ if (!openedOverlay && namedAdded.length >= 5 && !gridRepaint) {
1247
+ unknowns.push({
1248
+ kind: 'undetected-overlay', evidence: evidenceOf(addedNodes),
1249
+ context: `${fromLabel}: clicking "${e.action.name}" added ${namedAdded.length} named nodes but neither the built-in overlay detection nor any pattern pack recognized it as an overlay — stayed 'mutate'`,
1250
+ extensionPoint: 'overlay-open',
1251
+ });
1252
+ }
1253
+ const aff = openedOverlay
1254
+ ? { id: `aff_${affSeq++}_${slug(e.action.name)}`, label: e.action.name, kind: 'reveal', elementFp: fp, children }
1255
+ : { id: `aff_${affSeq++}_${slug(e.action.name)}`, label: e.action.name, kind: 'mutate', elementFp: fp };
1256
+ if (COMMIT_WORDS.test(e.action.name))
1257
+ aff.needsClassification = true;
1258
+ pushAff(fromLabel, aff);
1259
+ }
1260
+ return;
1261
+ }
1262
+ // a navigation → a navigate affordance to the landing page.
1263
+ if (e.navigated) {
1264
+ transient.clear(); // X1: navigating AWAY from the from-page discards its overlay context.
1265
+ const toLabel = labelOf(pageKeyForEffectLanding.get(i) ?? '');
1266
+ if (!toLabel)
1267
+ return;
1268
+ let fp = e.action?.elementFp ?? null;
1269
+ // action:null (bare `use navigate`) → scan the FROM page's links for the one to toUrl.
1270
+ if (!fp) {
1271
+ const link = fromNodes.find((n) => n.role === 'link' && n.name && n.url && sameTarget(n.url, e.toUrl));
1272
+ if (link)
1273
+ fp = { role: 'link', name: link.name, near: null };
1274
+ }
1275
+ const id = `aff_${affSeq++}_${toLabel}`;
1276
+ // No recovered element name → take the TARGET page's display name (first core heading), the
1277
+ // slug only as last resort (a slug label is a technical identifier on a user-facing surface).
1278
+ const aff = { id, label: fp?.name ?? displayNameOf(toLabel) ?? toLabel, kind: 'navigate', to: toLabel };
1279
+ if (fp)
1280
+ aff.elementFp = fp;
1281
+ // a NAVIGATING commit-word ("Finish", "Place Order") must be flagged too — only the
1282
+ // mutate branch checked, so a recorded commit drafted as a plain navigate a walk
1283
+ // would auto-fire (#2). Candidate flag only; the agent classifies (#5a).
1284
+ if (fp?.name && COMMIT_WORDS.test(fp.name))
1285
+ aff.needsClassification = true;
1286
+ // login: if this from-page accumulated input affordances, this navigate consumes them.
1287
+ const inputs = (affById.get(fromLabel) ?? []).filter((x) => x.kind === 'input');
1288
+ if (inputs.length) {
1289
+ aff.needs = inputs.map((x) => x.id);
1290
+ aff.acceptsInput = 'credentials';
1291
+ }
1292
+ pushAff(fromLabel, aff);
1293
+ }
1294
+ });
1295
+ // ── 3b. cross-link mesh: a page's OWN declared links to OTHER known pages become navigate
1296
+ // affordances too — NOT just the links that were clicked. One forward walk-through captures
1297
+ // every module's landing snapshot, which carries the full sidebar (Dashboard/other modules);
1298
+ // synthesizing those edges means modules aren't dead-ends and the agent never hand-authors
1299
+ // (error-prone) back-edges. Each is a {role:link,name} fp the resolver handles.
1300
+ // Match a link's href through key() — base-strip + ALIAS — NOT raw sameTarget: a sidebar link
1301
+ // often points at the PRE-REDIRECT ghost url (`/auth/login`) while the page actually SETTLED at
1302
+ // the aliased key (`/dashboard/index`). sameTarget on the raw href would find no page and drop
1303
+ // the edge (the redirect-mismatch mesh gap). Links live on landing nodes (`p.nodes` = union of the
1304
+ // page's ready landings — mutation-after snapshots are never landings, so never here).
1305
+ // SKIP shell links: a `link:X` in the shell is a from-anywhere edge that lives on `_shell`, not
1306
+ // duplicated onto every page state.
1307
+ const labelByKey = new Map(); // canonical page key → its label (alias-resolved)
1308
+ for (const p of pageList)
1309
+ if (!labelByKey.has(p.key))
1310
+ labelByKey.set(p.key, p.label);
1311
+ for (const p of pageList) {
1312
+ for (const n of p.nodes) {
1313
+ if (n.role !== 'link' || !n.name || !n.url)
1314
+ continue;
1315
+ if (shell.has(`link:${n.name}`))
1316
+ continue; // shell link → lives on _shell
1317
+ const targetLabel = labelByKey.get(fromPageKey(n.url)); // alias- AND canonical-aware
1318
+ if (!targetLabel || targetLabel === p.label)
1319
+ continue; // unknown target / self
1320
+ const have = affById.get(p.label) ?? [];
1321
+ if (have.some((a) => a.to === targetLabel))
1322
+ continue; // already have this edge
1323
+ pushAff(p.label, { id: `aff_${affSeq++}_${targetLabel}`, label: n.name, kind: 'navigate',
1324
+ to: targetLabel, elementFp: { role: 'link', name: n.name, near: null } });
1325
+ }
1326
+ }
1327
+ // ── 3c. interior-synthesis (Layer 1): a page's OWN declared interactive elements become
1328
+ // input/mutate affordances even when never clicked — same philosophy as the cross-link mesh
1329
+ // (declared-but-unclicked structure is real). STRICT ARIA: a fillable role → input, any other
1330
+ // named button → mutate. NO layout/proximity inference (#5a-bounded). Skip elements already
1331
+ // captured (by a recorded action or as a navigate link), and links (the mesh owns those).
1332
+ // Reads the page's CORE nodes only (Task 9), NOT the union of every landing — a value that
1333
+ // appeared in one visit but is not in the durable core (a per-row datum, a one-off chip) must
1334
+ // never synthesize as structure (the union leaked exactly these). resolveByFingerprint is still
1335
+ // checked against the same coreNodes so an ambiguous control (11 identical icon glyphs) is dropped.
1336
+ // FOLD FIRST (subtree induction): repeated sibling SUBTREES in the core fold into typed templates
1337
+ // (≥3 "<X> Remove" chips, ≥2 identical chart-widget cards). Emit ONE informational affordance per
1338
+ // fold whose units carry CONTROLS (buttons/fields — the material a walk might act on): label =
1339
+ // the fold's label (falling back to the unit's first control name when it's a bare role), scope =
1340
+ // 'widget' for a ≥2-node unit else 'row', elementFp null (a folded template has no single durable
1341
+ // coordinate; mutates never route, so no resolution is needed). A PURE link/heading fold (nav
1342
+ // cards, headings) emits NOTHING — its links already live as mesh edges (distinct destinations =
1343
+ // real routes, NEVER a repeated template to fold away) and its headings are fingerprint material.
1344
+ // The folded member nodes are skipped in the per-node loop below either way (by name).
1345
+ for (const p of pageList) {
1346
+ const { emit, gatedNames: baseFolded } = templateFolds(p.coreNodes);
1347
+ // HOOK 2b (value-domain pack, `landing` context): a value-domain trigger over the page's core
1348
+ // nodes marks its matched subtree as value data — those names join the excluded set so they
1349
+ // never synthesize as an interior affordance / never anchor identity (same gate as templateFolds'
1350
+ // folded members). Only ever SHRINKS what's stored.
1351
+ const foldedNames = new Set([...baseFolded, ...packValueNames(packsFor(p.label), p.coreNodes)]);
1352
+ for (const fold of emit) {
1353
+ const controlRole = dominantControlRole(p.coreNodes, fold);
1354
+ if (!controlRole)
1355
+ continue; // widget whose only content is links → no affordance (mesh owns the links)
1356
+ // label fallback: when the fold's own label is a bare ROLE token (no control name / no shared
1357
+ // word — e.g. 'generic'/'heading'/'button'), prefer the unit's first control NAME if any.
1358
+ const roleFallback = new Set(fold.memberIndices.map((i) => p.coreNodes[i].role)).has(fold.label);
1359
+ const label = roleFallback ? (firstControlNameInFold(p.coreNodes, fold) ?? fold.label) : fold.label;
1360
+ const aff = { id: `aff_${affSeq++}_${slug(label)}`, label,
1361
+ kind: childKind(controlRole), scope: fold.unitSize >= 2 ? 'widget' : 'row', elementFp: null };
1362
+ if (COMMIT_WORDS.test(label))
1363
+ aff.needsClassification = true; // "Remove"/"Delete" → agent classifies (#2/#5a)
1364
+ pushAff(p.label, aff);
1365
+ }
1366
+ for (const n of p.coreNodes) {
1367
+ if (!n.name || !n.name.trim())
1368
+ continue;
1369
+ if (foldedNames.has(n.name))
1370
+ continue; // a folded member value → not its own affordance
1371
+ if (n.role === 'link' || n.role === 'heading')
1372
+ continue; // links → mesh; headings → fingerprint
1373
+ // declared interactive controls: fillable fields → input, button → mutate, AND a SORTABLE
1374
+ // `columnheader` — one bearing `aria-sort` IS the declared sort control (matrix row 44); it's
1375
+ // mutate (childKind: not an INPUT_ROLE). Declared-evidence-gated: a PLAIN columnheader (no
1376
+ // aria-sort) is a STATIC table header (row 43) whose name is column DATA — it belongs to the
1377
+ // shadow (collections.columns), NEVER an affordance (else a report's selected metrics leak
1378
+ // as page controls — the data-value pollution class the structure-inference design refuses).
1379
+ const isSortableHeader = n.role === 'columnheader' && /\[aria-sort/.test(n.raw);
1380
+ if (!INPUT_ROLES.has(n.role) && n.role !== 'button' && !isSortableHeader)
1381
+ continue;
1382
+ const have = affById.get(p.label) ?? [];
1383
+ if (have.some((a) => a.label === n.name && (a.kind === 'input' || a.kind === 'mutate' || a.kind === 'reveal')))
1384
+ continue;
1385
+ const fp = { role: n.role, name: n.name, near: null };
1386
+ if (resolveByFingerprint(fp, p.coreNodes) === null)
1387
+ continue; // not a reliable coordinate → skip (no guess)
1388
+ const aff = { id: `aff_${affSeq++}_${slug(n.name)}`, label: n.name, kind: childKind(n.role), elementFp: fp };
1389
+ if (COMMIT_WORDS.test(n.name))
1390
+ aff.needsClassification = true;
1391
+ pushAff(p.label, aff);
1392
+ }
1393
+ }
1394
+ // ── assemble GOOD draft states only (degenerate ones go to needsFix, below) ──
1395
+ // Labels of degenerate pages, so navigate affordances pointing AT them are dropped (a good
1396
+ // state must not carry a dead edge to a held-out page).
1397
+ const degenLabels = new Set([...degenerate.keys()].map((i) => pageList[i].label));
1398
+ const states = [];
1399
+ pageList.forEach((p, pi) => {
1400
+ if (degenerate.has(pi))
1401
+ return; // held out → needsFix
1402
+ const shadow = extractShadow(p.shadowNodes); // rule 2: durable core + structural containers, not the union
1403
+ // extractShadow no longer emits subTabs (site-specific container lookup deleted, rule 2), so
1404
+ // the check drops it — a freshly-extracted shadow only carries collections/filters/createsEntity.
1405
+ const hasShadow = (shadow.collections?.length || shadow.filters?.length || shadow.createsEntity);
1406
+ const affordances = (affById.get(p.label) ?? [])
1407
+ // drop a navigate to a held-out (degenerate) page — no dead edges.
1408
+ .filter((a) => !(a.kind === 'navigate' && a.to && degenLabels.has(a.to)))
1409
+ // UNRESOLVABLE-COORDINATE GATE (#3: store only DURABLE fingerprints): an INTERIOR affordance
1410
+ // (mutate/input) whose recovered elementFp has NO usable name is not a reliable coordinate —
1411
+ // a walk can't deterministically re-find `combobox` (no name) among many, and in practice
1412
+ // these are the personalized chart widgets the recorder couldn't pin (a dashboard's `Country`
1413
+ // dimension, `Bar Chart Vz`, `Toggle Right Panel` — all null-name fps = per-instance data).
1414
+ // Exempt: navigate (target in union/shell, verified separately), row/widget folds (elementFp:
1415
+ // null by design), and a REVEAL that exposed named children (its opener may be unnamed but the
1416
+ // overlay it opened IS real declared structure).
1417
+ .filter((a) => a.kind === 'navigate' || !!a.scope || !a.elementFp
1418
+ || (a.kind === 'reveal' && !!a.children?.length)
1419
+ || (!!a.elementFp.name && a.elementFp.name.trim() !== ''))
1420
+ // VALUE-LABEL GATE: a control named purely by a date/number OR dominated by a date-RANGE
1421
+ // value (a date-range picker opener carrying its current range) is instance data (#5 refuses
1422
+ // dates), even when it resolves — refuse it as a stored affordance (its reveal children too).
1423
+ .filter((a) => !isValueLabel(a.label))
1424
+ .map((a) => (a.children ? { ...a, children: a.children.filter((c) => !isValueLabel(c.label)) } : a));
1425
+ states.push({
1426
+ label: p.label, urlPattern: p.url, fingerprint: stubs[pi].fingerprint, affordances,
1427
+ ...(p.template ? { template: p.template } : {}),
1428
+ ...(hasShadow ? { declaredShadow: shadow } : {}),
1429
+ ...(p.provisional ? { provisional: p.provisional } : {}),
1430
+ });
1431
+ });
1432
+ // ── 5. self-verify the GOOD states: flag any non-unique fingerprint / unresolvable affordance ──
1433
+ // (matchState against the good stubs only — degenerate stubs have empty fingerprints and are
1434
+ // excluded from `states`, so they can't cause a spurious ambiguous here.)
1435
+ // Verify against the page's CORE nodes (Task 10) — the same view synthesis draws affordances
1436
+ // and fingerprints from. Verifying the fp against a UNION landing that also carried a transient
1437
+ // twin, or an affordance against the union while it was synthesized from core, produced spurious
1438
+ // _warnings (a prior reviewer flagged the union/core mismatch); coreNodes makes them consistent.
1439
+ const goodStubs = pageList.map((p, pi) => stubs[pi]).filter((_, pi) => !degenerate.has(pi));
1440
+ for (const st of states) {
1441
+ const pi = pageList.findIndex((p) => p.label === st.label);
1442
+ const m = matchState(pageList[pi].coreNodes, goodStubs);
1443
+ if (!(m.status === 'matched' && m.state.id === stubs[pi].id)) {
1444
+ st._warning = `fingerprint not unique (matchState: ${m.status}) — curate`;
1445
+ }
1446
+ for (const a of st.affordances) {
1447
+ if (!a.elementFp)
1448
+ continue;
1449
+ // Resolve against the SAME node set the affordance was synthesized from (consistency with
1450
+ // synthesis — the prior union/core mismatch produced spurious _warnings): interior
1451
+ // input/mutate/reveal come from the CORE (and are already gated on resolving there), so verify
1452
+ // on coreNodes; a NAVIGATE's link lives in the full landing/shell (subtracted from core), so
1453
+ // verify it against the union `nodes`.
1454
+ const against = a.kind === 'navigate' ? pageList[pi].nodes : pageList[pi].coreNodes;
1455
+ if (resolveByFingerprint(a.elementFp, against) === null) {
1456
+ st._warning = (st._warning ? st._warning + '; ' : '') + `affordance "${a.label}" won't resolve on this page`;
1457
+ }
1458
+ }
1459
+ }
1460
+ // ── HIERARCHY (role + parentState), from the OBSERVED nav structure ──
1461
+ // A website is a tree: sections (reachable from the shared shell/sidebar) → detail pages
1462
+ // (drilled INTO from a section). We tell them apart WITHOUT URL/frequency guessing: a SHELL
1463
+ // link is global nav; any other (content) link is a drill-down. So:
1464
+ // • shell-linked page → a SECTION (top-level, no parent).
1465
+ // • reached only via a content link → a DETAIL, parent = the page that content link is ON.
1466
+ // isSidebarLink now reads the SHELL directly (a link:X on the site chrome) rather than re-
1467
+ // counting ≥60%-of-pages presence — same signal, one source of truth. `logo`/`home` stay a
1468
+ // hard-coded chrome heuristic (they're global nav even when a small site's shell didn't fire).
1469
+ const stateLabels = new Set(states.map((s) => s.label));
1470
+ const isSidebarLink = (label) => shell.has(`link:${label}`) || /\b(logo|home)\b/i.test(label);
1471
+ // A page reached from the SHELL is a top-level section — regardless of what a content link on
1472
+ // some OTHER page happens to be LABELED. isSidebarLink only recognizes a drill-in whose own
1473
+ // action label matches a shell token, but a sidebar target is often ALSO linked from a page's
1474
+ // body under a different label (real: download-list → report-list labeled "report-list", not
1475
+ // the sidebar's "Reports") — that content edge would wrongly parent the section. The durable
1476
+ // signal is the shell's OWN navigate targets: any state the `_shell` links to is global nav.
1477
+ // Resolve them with the SAME link→label resolution the shell-state block uses (below), so the
1478
+ // two agree by construction. A shell target NEVER receives a parent (stays a section).
1479
+ const shellTargetLabels = new Set();
1480
+ for (const p of pageList)
1481
+ for (const n of p.nodes) {
1482
+ if (n.role !== 'link' || !n.name || !n.url)
1483
+ continue;
1484
+ if (!shell.has(`link:${n.name}`))
1485
+ continue;
1486
+ const target = labelByKey.get(fromPageKey(n.url));
1487
+ if (target && stateLabels.has(target))
1488
+ shellTargetLabels.add(target);
1489
+ }
1490
+ // For each state, find a CONTENT (non-sidebar) navigate edge that lands on it → that's its parent.
1491
+ const parentOf = new Map();
1492
+ for (const s of states) {
1493
+ for (const a of s.affordances) {
1494
+ if (a.kind === 'navigate' && a.to && stateLabels.has(a.to) && a.to !== s.label
1495
+ && !isSidebarLink(a.label) && !shellTargetLabels.has(a.to)) {
1496
+ if (!parentOf.has(a.to))
1497
+ parentOf.set(a.to, s.label); // first content drill-in wins
1498
+ }
1499
+ }
1500
+ }
1501
+ for (const s of states) {
1502
+ const parent = parentOf.get(s.label) ?? null;
1503
+ s.parentState = parent;
1504
+ s.role = parent ? 'detail' : 'section'; // has a content parent → detail; else a top-level section
1505
+ }
1506
+ // pageStates: the real pages (hierarchy + receipt + walk example see ONLY these). `_shell` is a
1507
+ // site-level record, not a page — appended to `states` below but never a hierarchy node / entry.
1508
+ const pageStates = [...states];
1509
+ // ── SHELL STATE (axis 3, rule 2): the shared chrome, stored ONCE. Emitted only when shell is
1510
+ // non-empty (the ≥4-page gate already guards this). Its affordances = the shell's declared
1511
+ // interactive tokens: a shell `link:X` whose observed href keys (alias-aware) to a known page →
1512
+ // a navigate to that page (a from-anywhere edge); shell buttons/inputs → mutate/input by role.
1513
+ // We look up a representative SnapNode per shell token from the page landings to recover the
1514
+ // link href (for alias resolution) and preserve the exact role.
1515
+ if (shell.size) {
1516
+ const repNode = new Map(); // shell token → a representative node (for url/role)
1517
+ for (const p of pageList)
1518
+ for (const n of p.nodes) {
1519
+ if (!n.name)
1520
+ continue;
1521
+ const tok = `${n.role}:${n.name}`;
1522
+ if (shell.has(tok) && !repNode.has(tok))
1523
+ repNode.set(tok, n);
1524
+ }
1525
+ const shellAff = [];
1526
+ const goodLabels = new Set(pageStates.map((s) => s.label));
1527
+ for (const tok of shell) {
1528
+ const n = repNode.get(tok);
1529
+ if (!n || !n.name)
1530
+ continue;
1531
+ if (n.role === 'link') {
1532
+ // navigate only if the href resolves (alias-aware) to a GOOD page state.
1533
+ const targetLabel = n.url ? labelByKey.get(fromPageKey(n.url)) : undefined;
1534
+ if (!targetLabel || !goodLabels.has(targetLabel))
1535
+ continue; // dead/unknown shell link → drop
1536
+ shellAff.push({ id: `sh_${affSeq++}_${targetLabel}`, label: n.name, kind: 'navigate',
1537
+ to: targetLabel, elementFp: { role: 'link', name: n.name, near: null } });
1538
+ }
1539
+ else if (INPUT_ROLES.has(n.role) || n.role === 'button') {
1540
+ const aff = { id: `sh_${affSeq++}_${slug(n.name)}`, label: n.name,
1541
+ kind: childKind(n.role), elementFp: { role: n.role, name: n.name, near: null } };
1542
+ if (COMMIT_WORDS.test(n.name))
1543
+ aff.needsClassification = true; // flag, agent classifies (#2/#5a)
1544
+ shellAff.push(aff);
1545
+ }
1546
+ }
1547
+ const firstUrl = pageStates[0]?.urlPattern ?? '';
1548
+ const originOf = (u) => { try {
1549
+ return new URL(u).origin;
1550
+ }
1551
+ catch {
1552
+ return u;
1553
+ } };
1554
+ states.push({ label: '_shell', role: 'shell', urlPattern: originOf(firstUrl), fingerprint: [], affordances: shellAff });
1555
+ }
1556
+ // needsFix (the agent's "your move"): held-out degenerate landings + identity failures — an
1557
+ // SPA cluster with no distinguishing heading (rule 4), a name collision that stayed unresolved
1558
+ // (rule 6). Each carries its reason. Unique by (label, reason): several lost clusters of ONE
1559
+ // key are one problem, not one row per cluster.
1560
+ const nfSeen = new Set();
1561
+ const needsFix = [
1562
+ ...[...degenerate.entries()].map(([i, reason]) => ({
1563
+ label: pageList[i].label, urlPattern: pageList[i].url, reason, affordances: (affById.get(pageList[i].label) ?? []).length,
1564
+ })),
1565
+ ...splitNeedsFix.map((s) => ({ label: s.label, urlPattern: s.url, reason: s.reason, affordances: 0 })),
1566
+ ...[...nameNeedsFix.entries()].map(([p, reason]) => ({ label: p.label, urlPattern: p.url, reason, affordances: 0 })),
1567
+ ...[...foreignHosts.entries()].map(([h, url]) => ({
1568
+ label: h, urlPattern: url, reason: `foreign-host interstitial (blocked door at ${h})`, affordances: 0
1569
+ })),
1570
+ ].filter((n) => { const k = `${n.label}|${n.reason}`; if (nfSeen.has(k))
1571
+ return false; nfSeen.add(k); return true; });
1572
+ // requests: provisional states (seen once) surfaced as a record-next ask (rule 2 + core).
1573
+ // `_shell` is never provisional (no `provisional` field) so it's naturally excluded.
1574
+ const requests = pageStates.filter((s) => s.provisional).map((s) => `${s.label}: ${s.provisional}`);
1575
+ // UNKNOWNS source (b) + (d): every needsFix row also surfaces as an unknowns entry (one place
1576
+ // for the extension loop to look), EXCEPT it is NOT double-kinded — a same-URL SPA cluster with
1577
+ // no distinguishing heading is the more SPECIFIC `ambiguous-cluster` kind (never the generic
1578
+ // `degenerate-landing`), extensionPoint `core-design` either way (a pack cannot mint a heading
1579
+ // or reroute identity — this is a core-design gap, not a value-domain/overlay-open one).
1580
+ const AMBIGUOUS_REASON = 'same-url state with no distinguishing heading';
1581
+ const degenByLabel = new Map(pageList.map((p, i) => [p.label, p]));
1582
+ for (const nf of needsFix) {
1583
+ const p = degenByLabel.get(nf.label); // a real held-out PageInfo carries its landing for evidence
1584
+ unknowns.push({
1585
+ kind: nf.reason === AMBIGUOUS_REASON ? 'ambiguous-cluster' : 'degenerate-landing',
1586
+ evidence: p ? evidenceOf(p.landings[0]) : '',
1587
+ context: `${nf.label} (${nf.urlPattern}): ${nf.reason}`,
1588
+ extensionPoint: 'core-design',
1589
+ });
1590
+ }
1591
+ // UNKNOWNS source (c) — unresolved-affordance: a state whose self-verify _warning flagged a
1592
+ // non-unique fingerprint or an affordance that won't resolve. Evidence = the page's own core
1593
+ // nodes (the same view self-verify checked against).
1594
+ for (const st of states) {
1595
+ if (!st._warning)
1596
+ continue;
1597
+ const pi = pageList.findIndex((p) => p.label === st.label);
1598
+ unknowns.push({
1599
+ kind: 'unresolved-affordance', evidence: pi >= 0 ? evidenceOf(pageList[pi].coreNodes) : '',
1600
+ context: `${st.label}: ${st._warning}`,
1601
+ extensionPoint: 'core-design',
1602
+ });
1603
+ }
1604
+ // cap the report (source e's pack-tripwire entries were pushed earlier, inline with the pack
1605
+ // application) — pasteable, not a full dump. Truncation is COUNTED, never silent.
1606
+ // KIND-FAIR cap (review F1): a flat slice let one noisy kind (a broad pack tripping on 20+
1607
+ // pages, pushed first) crowd out every other kind's entries. Round-robin one entry per kind per
1608
+ // pass (kinds in first-appearance order, pipeline order within a kind) until the cap.
1609
+ const byKind = new Map();
1610
+ for (const u of unknowns)
1611
+ (byKind.get(u.kind) ?? byKind.set(u.kind, []).get(u.kind)).push(u);
1612
+ const queues = [...byKind.values()];
1613
+ const cappedUnknowns = [];
1614
+ for (let round = 0; cappedUnknowns.length < MAX_UNKNOWNS; round++) {
1615
+ let took = false;
1616
+ for (const q of queues) {
1617
+ if (round >= q.length)
1618
+ continue;
1619
+ cappedUnknowns.push(q[round]);
1620
+ took = true;
1621
+ if (cappedUnknowns.length >= MAX_UNKNOWNS)
1622
+ break;
1623
+ }
1624
+ if (!took)
1625
+ break; // every kind exhausted
1626
+ }
1627
+ const unknownsTruncated = unknowns.length - cappedUnknowns.length;
1628
+ // entry / walkExample / receipt.states see ONLY page states — `_shell` is site chrome, not a
1629
+ // navigable page in the tree.
1630
+ const entry = pageStates.length ? pageStates[0].label : null;
1631
+ return {
1632
+ states, edges: [],
1633
+ ...(needsFix.length ? { needsFix } : {}),
1634
+ ...(cappedUnknowns.length ? { unknowns: cappedUnknowns } : {}),
1635
+ ...(unknownsTruncated > 0 ? { unknownsTruncated } : {}),
1636
+ receipt: {
1637
+ entry,
1638
+ states: pageStates.map((s) => s.label),
1639
+ walkExample: entry && pageStates.length > 1
1640
+ ? `webnav walk --start ${host(pageList[0].url)}:${entry} --goal ${host(pageList[0].url)}:${pageStates[pageStates.length - 1].label} --headless`
1641
+ : null,
1642
+ requests,
1643
+ },
1644
+ };
1645
+ }
1646
+ /** Do two hrefs point at the same target? Exact, or same pathname (ignoring origin/hash). */
1647
+ function sameTarget(a, b) {
1648
+ if (a === b)
1649
+ return true;
1650
+ try {
1651
+ const ua = new URL(a, 'http://x');
1652
+ const ub = new URL(b, 'http://x');
1653
+ return ua.pathname === ub.pathname && ua.pathname !== '/';
1654
+ }
1655
+ catch {
1656
+ return false;
1657
+ }
1658
+ }