@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.
- package/LICENSE +202 -0
- package/README.md +283 -0
- package/bin/webnav +18 -0
- package/dist/agent/loop.d.ts +59 -0
- package/dist/agent/loop.js +267 -0
- package/dist/agent/server.d.ts +60 -0
- package/dist/agent/server.js +254 -0
- package/dist/cli-help.d.ts +3 -0
- package/dist/cli-help.js +111 -0
- package/dist/cli-spec.d.ts +24 -0
- package/dist/cli-spec.js +460 -0
- package/dist/cli.d.ts +208 -0
- package/dist/cli.js +2558 -0
- package/dist/contract.d.ts +20 -0
- package/dist/contract.js +15 -0
- package/dist/creds.d.ts +46 -0
- package/dist/creds.js +126 -0
- package/dist/dashboard/server.d.ts +137 -0
- package/dist/dashboard/server.js +395 -0
- package/dist/dashboard/shell.d.ts +1 -0
- package/dist/dashboard/shell.js +1343 -0
- package/dist/explorer/analyse.d.ts +25 -0
- package/dist/explorer/analyse.js +39 -0
- package/dist/explorer/diff.d.ts +11 -0
- package/dist/explorer/diff.js +22 -0
- package/dist/explorer/draft.d.ts +81 -0
- package/dist/explorer/draft.js +1658 -0
- package/dist/explorer/fingerprint-page.d.ts +11 -0
- package/dist/explorer/fingerprint-page.js +19 -0
- package/dist/explorer/fingerprint.d.ts +14 -0
- package/dist/explorer/fingerprint.js +18 -0
- package/dist/explorer/infer.d.ts +85 -0
- package/dist/explorer/infer.js +370 -0
- package/dist/explorer/pattern-propose.d.ts +27 -0
- package/dist/explorer/pattern-propose.js +81 -0
- package/dist/explorer/patterns.d.ts +49 -0
- package/dist/explorer/patterns.js +290 -0
- package/dist/explorer/shadow.d.ts +3 -0
- package/dist/explorer/shadow.js +95 -0
- package/dist/graph/coverage.d.ts +33 -0
- package/dist/graph/coverage.js +155 -0
- package/dist/graph/edit.d.ts +47 -0
- package/dist/graph/edit.js +211 -0
- package/dist/graph/frontier.d.ts +24 -0
- package/dist/graph/frontier.js +95 -0
- package/dist/graph/seed.d.ts +23 -0
- package/dist/graph/seed.js +29 -0
- package/dist/graph/show.d.ts +8 -0
- package/dist/graph/show.js +6 -0
- package/dist/graph/teach.d.ts +8 -0
- package/dist/graph/teach.js +10 -0
- package/dist/hosted.d.ts +36 -0
- package/dist/hosted.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/mapstore/record.d.ts +128 -0
- package/dist/mapstore/record.js +224 -0
- package/dist/mapstore/schema.sql +47 -0
- package/dist/mapstore/store.d.ts +108 -0
- package/dist/mapstore/store.js +399 -0
- package/dist/mapstore/types.d.ts +87 -0
- package/dist/mapstore/types.js +29 -0
- package/dist/mcp/server.d.ts +46 -0
- package/dist/mcp/server.js +163 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +33 -0
- package/dist/playwright/adapter.d.ts +62 -0
- package/dist/playwright/adapter.js +145 -0
- package/dist/playwright/ax-adapter.d.ts +33 -0
- package/dist/playwright/ax-adapter.js +76 -0
- package/dist/playwright/capture.d.ts +2 -0
- package/dist/playwright/capture.js +10 -0
- package/dist/playwright/fingerprint.d.ts +40 -0
- package/dist/playwright/fingerprint.js +139 -0
- package/dist/playwright/profile-lock.d.ts +15 -0
- package/dist/playwright/profile-lock.js +51 -0
- package/dist/playwright/sessions.d.ts +72 -0
- package/dist/playwright/sessions.js +229 -0
- package/dist/playwright/snapshot.d.ts +10 -0
- package/dist/playwright/snapshot.js +52 -0
- package/dist/playwright/throttle.d.ts +14 -0
- package/dist/playwright/throttle.js +76 -0
- package/dist/playwright/video.d.ts +13 -0
- package/dist/playwright/video.js +32 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +3 -0
- package/dist/recorder/agent-session.d.ts +59 -0
- package/dist/recorder/agent-session.js +230 -0
- package/dist/recorder/capture-loop.d.ts +19 -0
- package/dist/recorder/capture-loop.js +40 -0
- package/dist/recorder/coverage.d.ts +22 -0
- package/dist/recorder/coverage.js +49 -0
- package/dist/recorder/hover-probe.d.ts +35 -0
- package/dist/recorder/hover-probe.js +100 -0
- package/dist/recorder/ingest.d.ts +39 -0
- package/dist/recorder/ingest.js +138 -0
- package/dist/recorder/live-record.d.ts +41 -0
- package/dist/recorder/live-record.js +255 -0
- package/dist/recorder/live.d.ts +53 -0
- package/dist/recorder/live.js +400 -0
- package/dist/recorder/probe.d.ts +14 -0
- package/dist/recorder/probe.js +33 -0
- package/dist/recorder/replay.d.ts +60 -0
- package/dist/recorder/replay.js +401 -0
- package/dist/recorder/review.d.ts +73 -0
- package/dist/recorder/review.js +171 -0
- package/dist/recorder/snapshot-dom.d.ts +7 -0
- package/dist/recorder/snapshot-dom.js +25 -0
- package/dist/router/auth-status.d.ts +20 -0
- package/dist/router/auth-status.js +55 -0
- package/dist/router/browse.d.ts +108 -0
- package/dist/router/browse.js +214 -0
- package/dist/router/catalog.d.ts +18 -0
- package/dist/router/catalog.js +6 -0
- package/dist/router/extract-content.d.ts +13 -0
- package/dist/router/extract-content.js +87 -0
- package/dist/router/live-extension-browser.d.ts +35 -0
- package/dist/router/live-extension-browser.js +175 -0
- package/dist/router/path.d.ts +7 -0
- package/dist/router/path.js +55 -0
- package/dist/router/read.d.ts +26 -0
- package/dist/router/read.js +30 -0
- package/dist/router/readiness.d.ts +9 -0
- package/dist/router/readiness.js +55 -0
- package/dist/router/replay.d.ts +14 -0
- package/dist/router/replay.js +23 -0
- package/dist/router/resolve.d.ts +21 -0
- package/dist/router/resolve.js +33 -0
- package/dist/router/search-live.d.ts +21 -0
- package/dist/router/search-live.js +98 -0
- package/dist/router/search-providers.d.ts +5 -0
- package/dist/router/search-providers.js +12 -0
- package/dist/router/search.d.ts +6 -0
- package/dist/router/search.js +62 -0
- package/dist/router/suite.d.ts +69 -0
- package/dist/router/suite.js +226 -0
- package/dist/router/tokens.d.ts +13 -0
- package/dist/router/tokens.js +26 -0
- package/dist/router/walk-live.d.ts +57 -0
- package/dist/router/walk-live.js +308 -0
- package/dist/router/walk-session.d.ts +49 -0
- package/dist/router/walk-session.js +90 -0
- package/dist/router/walk.d.ts +48 -0
- package/dist/router/walk.js +384 -0
- 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
|
+
}
|