@dikshanty94/webnav 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +283 -0
  3. package/bin/webnav +18 -0
  4. package/dist/agent/loop.d.ts +59 -0
  5. package/dist/agent/loop.js +267 -0
  6. package/dist/agent/server.d.ts +60 -0
  7. package/dist/agent/server.js +254 -0
  8. package/dist/cli-help.d.ts +3 -0
  9. package/dist/cli-help.js +111 -0
  10. package/dist/cli-spec.d.ts +24 -0
  11. package/dist/cli-spec.js +460 -0
  12. package/dist/cli.d.ts +208 -0
  13. package/dist/cli.js +2558 -0
  14. package/dist/contract.d.ts +20 -0
  15. package/dist/contract.js +15 -0
  16. package/dist/creds.d.ts +46 -0
  17. package/dist/creds.js +126 -0
  18. package/dist/dashboard/server.d.ts +137 -0
  19. package/dist/dashboard/server.js +395 -0
  20. package/dist/dashboard/shell.d.ts +1 -0
  21. package/dist/dashboard/shell.js +1343 -0
  22. package/dist/explorer/analyse.d.ts +25 -0
  23. package/dist/explorer/analyse.js +39 -0
  24. package/dist/explorer/diff.d.ts +11 -0
  25. package/dist/explorer/diff.js +22 -0
  26. package/dist/explorer/draft.d.ts +81 -0
  27. package/dist/explorer/draft.js +1658 -0
  28. package/dist/explorer/fingerprint-page.d.ts +11 -0
  29. package/dist/explorer/fingerprint-page.js +19 -0
  30. package/dist/explorer/fingerprint.d.ts +14 -0
  31. package/dist/explorer/fingerprint.js +18 -0
  32. package/dist/explorer/infer.d.ts +85 -0
  33. package/dist/explorer/infer.js +370 -0
  34. package/dist/explorer/pattern-propose.d.ts +27 -0
  35. package/dist/explorer/pattern-propose.js +81 -0
  36. package/dist/explorer/patterns.d.ts +49 -0
  37. package/dist/explorer/patterns.js +290 -0
  38. package/dist/explorer/shadow.d.ts +3 -0
  39. package/dist/explorer/shadow.js +95 -0
  40. package/dist/graph/coverage.d.ts +33 -0
  41. package/dist/graph/coverage.js +155 -0
  42. package/dist/graph/edit.d.ts +47 -0
  43. package/dist/graph/edit.js +211 -0
  44. package/dist/graph/frontier.d.ts +24 -0
  45. package/dist/graph/frontier.js +95 -0
  46. package/dist/graph/seed.d.ts +23 -0
  47. package/dist/graph/seed.js +29 -0
  48. package/dist/graph/show.d.ts +8 -0
  49. package/dist/graph/show.js +6 -0
  50. package/dist/graph/teach.d.ts +8 -0
  51. package/dist/graph/teach.js +10 -0
  52. package/dist/hosted.d.ts +36 -0
  53. package/dist/hosted.js +69 -0
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.js +4 -0
  56. package/dist/mapstore/record.d.ts +128 -0
  57. package/dist/mapstore/record.js +224 -0
  58. package/dist/mapstore/schema.sql +47 -0
  59. package/dist/mapstore/store.d.ts +108 -0
  60. package/dist/mapstore/store.js +399 -0
  61. package/dist/mapstore/types.d.ts +87 -0
  62. package/dist/mapstore/types.js +29 -0
  63. package/dist/mcp/server.d.ts +46 -0
  64. package/dist/mcp/server.js +163 -0
  65. package/dist/paths.d.ts +10 -0
  66. package/dist/paths.js +33 -0
  67. package/dist/playwright/adapter.d.ts +62 -0
  68. package/dist/playwright/adapter.js +145 -0
  69. package/dist/playwright/ax-adapter.d.ts +33 -0
  70. package/dist/playwright/ax-adapter.js +76 -0
  71. package/dist/playwright/capture.d.ts +2 -0
  72. package/dist/playwright/capture.js +10 -0
  73. package/dist/playwright/fingerprint.d.ts +40 -0
  74. package/dist/playwright/fingerprint.js +139 -0
  75. package/dist/playwright/profile-lock.d.ts +15 -0
  76. package/dist/playwright/profile-lock.js +51 -0
  77. package/dist/playwright/sessions.d.ts +72 -0
  78. package/dist/playwright/sessions.js +229 -0
  79. package/dist/playwright/snapshot.d.ts +10 -0
  80. package/dist/playwright/snapshot.js +52 -0
  81. package/dist/playwright/throttle.d.ts +14 -0
  82. package/dist/playwright/throttle.js +76 -0
  83. package/dist/playwright/video.d.ts +13 -0
  84. package/dist/playwright/video.js +32 -0
  85. package/dist/protocol.d.ts +49 -0
  86. package/dist/protocol.js +3 -0
  87. package/dist/recorder/agent-session.d.ts +59 -0
  88. package/dist/recorder/agent-session.js +230 -0
  89. package/dist/recorder/capture-loop.d.ts +19 -0
  90. package/dist/recorder/capture-loop.js +40 -0
  91. package/dist/recorder/coverage.d.ts +22 -0
  92. package/dist/recorder/coverage.js +49 -0
  93. package/dist/recorder/hover-probe.d.ts +35 -0
  94. package/dist/recorder/hover-probe.js +100 -0
  95. package/dist/recorder/ingest.d.ts +39 -0
  96. package/dist/recorder/ingest.js +138 -0
  97. package/dist/recorder/live-record.d.ts +41 -0
  98. package/dist/recorder/live-record.js +255 -0
  99. package/dist/recorder/live.d.ts +53 -0
  100. package/dist/recorder/live.js +400 -0
  101. package/dist/recorder/probe.d.ts +14 -0
  102. package/dist/recorder/probe.js +33 -0
  103. package/dist/recorder/replay.d.ts +60 -0
  104. package/dist/recorder/replay.js +401 -0
  105. package/dist/recorder/review.d.ts +73 -0
  106. package/dist/recorder/review.js +171 -0
  107. package/dist/recorder/snapshot-dom.d.ts +7 -0
  108. package/dist/recorder/snapshot-dom.js +25 -0
  109. package/dist/router/auth-status.d.ts +20 -0
  110. package/dist/router/auth-status.js +55 -0
  111. package/dist/router/browse.d.ts +108 -0
  112. package/dist/router/browse.js +214 -0
  113. package/dist/router/catalog.d.ts +18 -0
  114. package/dist/router/catalog.js +6 -0
  115. package/dist/router/extract-content.d.ts +13 -0
  116. package/dist/router/extract-content.js +87 -0
  117. package/dist/router/live-extension-browser.d.ts +35 -0
  118. package/dist/router/live-extension-browser.js +175 -0
  119. package/dist/router/path.d.ts +7 -0
  120. package/dist/router/path.js +55 -0
  121. package/dist/router/read.d.ts +26 -0
  122. package/dist/router/read.js +30 -0
  123. package/dist/router/readiness.d.ts +9 -0
  124. package/dist/router/readiness.js +55 -0
  125. package/dist/router/replay.d.ts +14 -0
  126. package/dist/router/replay.js +23 -0
  127. package/dist/router/resolve.d.ts +21 -0
  128. package/dist/router/resolve.js +33 -0
  129. package/dist/router/search-live.d.ts +21 -0
  130. package/dist/router/search-live.js +98 -0
  131. package/dist/router/search-providers.d.ts +5 -0
  132. package/dist/router/search-providers.js +12 -0
  133. package/dist/router/search.d.ts +6 -0
  134. package/dist/router/search.js +62 -0
  135. package/dist/router/suite.d.ts +69 -0
  136. package/dist/router/suite.js +226 -0
  137. package/dist/router/tokens.d.ts +13 -0
  138. package/dist/router/tokens.js +26 -0
  139. package/dist/router/walk-live.d.ts +57 -0
  140. package/dist/router/walk-live.js +308 -0
  141. package/dist/router/walk-session.d.ts +49 -0
  142. package/dist/router/walk-session.js +90 -0
  143. package/dist/router/walk.d.ts +48 -0
  144. package/dist/router/walk.js +384 -0
  145. package/package.json +59 -0
@@ -0,0 +1,49 @@
1
+ import type { SnapNode } from '../playwright/snapshot.js';
2
+ export declare const ARIA_ROLES: ReadonlySet<string>;
3
+ export declare const ARIA_ATTRS: ReadonlySet<string>;
4
+ export declare const TRIGGER_CONTEXTS: readonly ["diff.added", "overlay", "landing"];
5
+ export type TriggerContext = (typeof TRIGGER_CONTEXTS)[number];
6
+ export declare const PACK_TYPES: readonly ["overlay-open", "value-domain"];
7
+ export type PackType = (typeof PACK_TYPES)[number];
8
+ export interface ContainsPredicate {
9
+ role: string;
10
+ min?: number;
11
+ attr?: string;
12
+ }
13
+ export interface Trigger {
14
+ context: TriggerContext;
15
+ root?: {
16
+ role: string;
17
+ attr?: string;
18
+ };
19
+ contains: ContainsPredicate[];
20
+ }
21
+ export interface Fixture {
22
+ snapshot: string;
23
+ expect: 'matched' | 'unmatched';
24
+ }
25
+ export interface PatternPack {
26
+ name: string;
27
+ version: number;
28
+ type: PackType;
29
+ trigger: Trigger;
30
+ fixture: Fixture;
31
+ evidence: string;
32
+ source?: string;
33
+ }
34
+ /** Validate ONE pack entry. Pure. Every failure is a specific, named reason (no generic "invalid").
35
+ * Order: shape → forbidden-value scan (roles/attrs) → fixture execution. */
36
+ export declare function lintPackEntry(raw: unknown): string[];
37
+ export declare function evaluateTrigger(trigger: Trigger, nodes: SnapNode[]): boolean;
38
+ /** Load every `*.json` pack entry from the given dirs (default = the two shipped dirs), sorted by
39
+ * filename for determinism. Each file holds ONE entry (object) OR an array of entries. Throws on the
40
+ * first invalid file/entry with a message listing the lint failures (loud, per the plan). */
41
+ export declare function loadPatternPacks(dirs?: string[]): PatternPack[];
42
+ /** Hook 1 (overlay-open): did ANY overlay-open pack trigger match these diff.added nodes? Called
43
+ * ONLY when the built-in openedOverlay detection declined — a match flips detection true. */
44
+ export declare function packDetectsOverlay(packs: PatternPack[], addedNodes: SnapNode[]): boolean;
45
+ /** Hook 2 (value-domain): the set of NODE NAMES a value-domain pack marks as value data within the
46
+ * given candidate nodes. A node joins the excluded set iff it sits inside a firing trigger's matched
47
+ * subtree (the value grid), so its name never becomes an affordance / never anchors a fingerprint.
48
+ * Returns names (callers filter by name, matching enumeratedNames/templateFolds.gatedNames). */
49
+ export declare function packValueNames(packs: PatternPack[], nodes: SnapNode[]): Set<string>;
@@ -0,0 +1,290 @@
1
+ // Declarative pattern packs (Phase 2 extension loop, 2026-07-11-future-proof-program.md §Phase 2 +
2
+ // 2026-07-12-extension-loop.md). An agent extends STRUCTURE coverage by adding a JSON data entry —
3
+ // NEVER by touching core code. Every safety property here is the whole point:
4
+ //
5
+ // • The core's effect set is CLOSED. A pack maps a STRUCTURAL trigger onto exactly one of TWO
6
+ // existing effects: `overlay-open` (feeds the openedOverlay detection) and `value-domain` (feeds
7
+ // the folded/value exclusion sets). Both effects only make the engine MORE CONSERVATIVE about
8
+ // what it STORES — a pack can gate storage, it can NEVER mint an affordance, edge, or state.
9
+ // • The trigger schema is STRUCTURAL-ONLY: ARIA roles, containment (root/contains + min counts),
10
+ // bracket-attribute presence, and an evaluation context. It has NO field for a hostname, URL, or
11
+ // free text, and lint REJECTS any string value that is not a known ARIA role or attribute name.
12
+ // That is what makes a site-rule impossible to express — a pack cannot say "on example.com".
13
+ // • Zero LLM at runtime; packs are pure data; loads are deterministic (sorted filenames).
14
+ // • A missing/invalid pack is a LOUD failure listing the lint reasons — never silently skipped.
15
+ //
16
+ // This module is PURE (no fs beyond the loader) and side-effect-free so draft.ts can inject packs
17
+ // for tests. loadPatternPacks reads the two shipped dirs by default.
18
+ import { readdirSync, readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { parseSnapshot } from '../playwright/snapshot.js';
22
+ // ── ALLOW-LISTS (documented constants — the ENTIRE structural vocabulary a trigger may name) ──
23
+ // A trigger string is legal iff it is one of these. Anything else (a hostname, a URL, a product
24
+ // word, a UI label) is a NAMED lint rejection. Keep these lists as the single source of truth;
25
+ // growing coverage = adding a role here + a pack entry, never a code branch.
26
+ // The WAI-ARIA role vocabulary our snapshots actually surface (playwright-cli accessibility tree).
27
+ // Superset of the roles the engine already switches on (CONTROL_ROLES, OVERLAY_ROLES,
28
+ // REVEAL_CHILD_ROLES, columnheader/table/main/option/…) plus the common landmark/structure roles
29
+ // from the WAI-ARIA Authoring Practices that a div-soup fixture is described in terms of. `generic`
30
+ // is IN on purpose: a role-less div-soup portal snapshots as `generic`, and matching it is exactly
31
+ // the coverage gap packs exist to close.
32
+ export const ARIA_ROLES = new Set([
33
+ // landmarks / document structure
34
+ 'main', 'navigation', 'banner', 'contentinfo', 'complementary', 'region', 'search', 'form',
35
+ 'article', 'document', 'application', 'group', 'generic', 'presentation', 'none',
36
+ // widgets / interactive
37
+ 'button', 'link', 'textbox', 'searchbox', 'combobox', 'spinbutton', 'checkbox', 'radio',
38
+ 'switch', 'slider', 'option', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'tab',
39
+ 'treeitem', 'listbox', 'menu', 'menubar', 'tablist', 'tree', 'radiogroup',
40
+ // overlays / dialogs / status
41
+ 'dialog', 'alertdialog', 'tooltip', 'alert', 'status', 'progressbar',
42
+ // collections / tabular / grid
43
+ 'list', 'listitem', 'table', 'row', 'rowgroup', 'cell', 'gridcell', 'columnheader',
44
+ 'rowheader', 'grid', 'treegrid', 'heading', 'img', 'figure', 'separator',
45
+ 'paragraph', 'blockquote', 'code', 'time', 'definition', 'term',
46
+ ]);
47
+ // ARIA state/property attribute NAMES a trigger may test for PRESENCE of (bracket attributes in a
48
+ // snapshot line, e.g. `[aria-sort=ascending]`). Names only — a trigger tests that the attribute is
49
+ // PRESENT, never its value (a value could smuggle in free text). The set mirrors the WAI-ARIA
50
+ // state/property vocabulary; `aria-sort` is the one the core already reads (sortable columnheader).
51
+ export const ARIA_ATTRS = new Set([
52
+ 'aria-sort', 'aria-expanded', 'aria-selected', 'aria-checked', 'aria-pressed',
53
+ 'aria-current', 'aria-haspopup', 'aria-modal', 'aria-disabled', 'aria-hidden',
54
+ 'aria-readonly', 'aria-required', 'aria-multiselectable', 'aria-level', 'aria-orientation',
55
+ ]);
56
+ // Evaluation CONTEXT: which node list the engine feeds a trigger. These are the two hook sites plus
57
+ // the interior-synthesis candidate set — closed enum, each a real hook in draft.ts.
58
+ export const TRIGGER_CONTEXTS = ['diff.added', 'overlay', 'landing'];
59
+ // The CLOSED effect set (mirrors program-spec §Phase 2). A pack `type` picks the effect its trigger
60
+ // fires. Adding a third would be a deliberate CORE increment, never a pack.
61
+ export const PACK_TYPES = ['overlay-open', 'value-domain'];
62
+ // ── LINT ──
63
+ // JSON-schema-style validation IN CODE (no new dep) + forbidden-value scan + fixture execution.
64
+ // Returns [] when clean; otherwise a list of NAMED reasons (the loud-failure message).
65
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
66
+ function isPlainObject(v) {
67
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
68
+ }
69
+ /** Validate ONE pack entry. Pure. Every failure is a specific, named reason (no generic "invalid").
70
+ * Order: shape → forbidden-value scan (roles/attrs) → fixture execution. */
71
+ export function lintPackEntry(raw) {
72
+ const errs = [];
73
+ if (!isPlainObject(raw))
74
+ return ['entry is not a JSON object'];
75
+ // ── shape ──
76
+ if (typeof raw.name !== 'string' || !SLUG_RE.test(raw.name))
77
+ errs.push(`"name" must be a kebab-case slug (got ${JSON.stringify(raw.name)})`);
78
+ if (typeof raw.version !== 'number' || !Number.isInteger(raw.version) || raw.version < 1)
79
+ errs.push(`"version" must be an integer ≥1 (got ${JSON.stringify(raw.version)})`);
80
+ if (typeof raw.type !== 'string' || !PACK_TYPES.includes(raw.type))
81
+ errs.push(`"type" must be one of ${PACK_TYPES.join(' | ')} (got ${JSON.stringify(raw.type)})`);
82
+ if (typeof raw.evidence !== 'string' || !raw.evidence.trim())
83
+ errs.push('"evidence" must be a non-empty one-line provenance string');
84
+ // ── trigger shape + forbidden-value scan ──
85
+ const trig = raw.trigger;
86
+ if (!isPlainObject(trig)) {
87
+ errs.push('"trigger" must be an object');
88
+ }
89
+ else {
90
+ if (typeof trig.context !== 'string' || !TRIGGER_CONTEXTS.includes(trig.context))
91
+ errs.push(`trigger.context must be one of ${TRIGGER_CONTEXTS.join(' | ')} (got ${JSON.stringify(trig.context)})`);
92
+ if (trig.root !== undefined) {
93
+ if (!isPlainObject(trig.root) || typeof trig.root.role !== 'string') {
94
+ errs.push('trigger.root, when present, must be { role, attr? }');
95
+ }
96
+ else {
97
+ if (!ARIA_ROLES.has(trig.root.role))
98
+ errs.push(`trigger.root.role ${JSON.stringify(trig.root.role)} is not a known ARIA role — hostnames/URLs/text are forbidden; only ARIA roles are allowed`);
99
+ if (trig.root.attr !== undefined && (typeof trig.root.attr !== 'string' || !ARIA_ATTRS.has(trig.root.attr)))
100
+ errs.push(`trigger.root.attr ${JSON.stringify(trig.root.attr)} is not a known ARIA attribute name`);
101
+ }
102
+ }
103
+ if (!Array.isArray(trig.contains) || trig.contains.length === 0) {
104
+ errs.push('trigger.contains must be a non-empty array of predicates');
105
+ }
106
+ else {
107
+ trig.contains.forEach((p, i) => {
108
+ if (!isPlainObject(p) || typeof p.role !== 'string') {
109
+ errs.push(`trigger.contains[${i}] must be { role, min?, attr? }`);
110
+ return;
111
+ }
112
+ if (!ARIA_ROLES.has(p.role))
113
+ errs.push(`trigger.contains[${i}].role ${JSON.stringify(p.role)} is not a known ARIA role — hostnames/URLs/text are forbidden; only ARIA roles are allowed`);
114
+ if (p.min !== undefined && (typeof p.min !== 'number' || !Number.isInteger(p.min) || p.min < 1))
115
+ errs.push(`trigger.contains[${i}].min must be an integer ≥1 (got ${JSON.stringify(p.min)})`);
116
+ if (p.attr !== undefined && (typeof p.attr !== 'string' || !ARIA_ATTRS.has(p.attr)))
117
+ errs.push(`trigger.contains[${i}].attr ${JSON.stringify(p.attr)} is not a known ARIA attribute name`);
118
+ });
119
+ }
120
+ }
121
+ // ── fixture shape ──
122
+ const fx = raw.fixture;
123
+ if (!isPlainObject(fx)) {
124
+ errs.push('"fixture" is required — { snapshot, expect }');
125
+ }
126
+ else {
127
+ if (typeof fx.snapshot !== 'string' || !fx.snapshot.trim())
128
+ errs.push('fixture.snapshot must be a non-empty snapshot literal');
129
+ if (fx.expect !== 'matched' && fx.expect !== 'unmatched')
130
+ errs.push('fixture.expect must be "matched" or "unmatched"');
131
+ }
132
+ // ── fixture EXECUTION (only when shape is otherwise clean, so we don't run a malformed trigger) ──
133
+ if (errs.length === 0) {
134
+ const pack = raw;
135
+ const nodes = parseSnapshot(fx.snapshot);
136
+ const got = evaluateTrigger(pack.trigger, nodes) ? 'matched' : 'unmatched';
137
+ if (got !== fx.expect)
138
+ errs.push(`fixture failed: trigger evaluated to "${got}" but fixture.expect is "${fx.expect}"`);
139
+ }
140
+ return errs;
141
+ }
142
+ // ── TRIGGER EVALUATOR ──
143
+ // Pure: given a trigger + the evidence nodes for its context, return match/no-match. Uses the SAME
144
+ // nearest-lower-depth containment idiom as infer.ts (insideOverlay / subtreeFolds' parent walk) so a
145
+ // `contains` predicate counts only nodes genuinely INSIDE the matched root subtree.
146
+ const BRACKET = (raw, attr) => new RegExp(`\\[${attr}(?:[=\\]])`).test(raw);
147
+ const attrOk = (n, attr) => attr === undefined || BRACKET(n.raw, attr);
148
+ /** Is node `desc` a descendant of node at index `rootIdx` in the flat depth-ordered list? A node is
149
+ * in the subtree iff it comes after rootIdx and every intervening depth stays > root depth (the
150
+ * moment depth drops back to ≤ root's, the subtree ended). Same nearest-lower-depth reasoning as
151
+ * insideOverlay, expressed forward. */
152
+ function subtreeEnd(nodes, rootIdx) {
153
+ const rootDepth = nodes[rootIdx].depth;
154
+ let end = rootIdx + 1;
155
+ while (end < nodes.length && nodes[end].depth > rootDepth)
156
+ end++;
157
+ return end; // exclusive
158
+ }
159
+ /** Count nodes matching a predicate within [lo, hi). */
160
+ function countMatches(nodes, lo, hi, p) {
161
+ let c = 0;
162
+ for (let i = lo; i < hi; i++)
163
+ if (nodes[i].role === p.role && attrOk(nodes[i], p.attr))
164
+ c++;
165
+ return c;
166
+ }
167
+ /** Do ALL contains-predicates hold inside [lo, hi)? */
168
+ function allContain(nodes, lo, hi, preds) {
169
+ return preds.every((p) => countMatches(nodes, lo, hi, p) >= (p.min ?? 1));
170
+ }
171
+ export function evaluateTrigger(trigger, nodes) {
172
+ if (!nodes.length)
173
+ return false;
174
+ const preds = trigger.contains;
175
+ // No root declared → the whole list is one root subtree.
176
+ if (!trigger.root)
177
+ return allContain(nodes, 0, nodes.length, preds);
178
+ // Root declared → try each node that matches the root role/attr; a match on ANY root subtree fires.
179
+ const { role, attr } = trigger.root;
180
+ for (let i = 0; i < nodes.length; i++) {
181
+ if (nodes[i].role !== role || !attrOk(nodes[i], attr))
182
+ continue;
183
+ const end = subtreeEnd(nodes, i);
184
+ // predicates count DESCENDANTS (exclude the root itself → i+1).
185
+ if (allContain(nodes, i + 1, end, preds))
186
+ return true;
187
+ }
188
+ return false;
189
+ }
190
+ // ── LOADER ──
191
+ // Deterministic (sorted filenames) load of the two shipped dirs. A file that isn't valid JSON, or an
192
+ // entry that fails lint, throws a LOUD error naming the file + reasons — never silently skipped.
193
+ // The shipped pack dirs, resolved relative to THIS module so they work from src (tsx) and dist.
194
+ // src/explorer/patterns.ts → ../../packs/patterns/{core,proposed}; dist/explorer → same relative
195
+ // (packs is not compiled, it ships as data). readdir tolerates a missing dir (returns []).
196
+ //
197
+ // TEST-HYGIENE GUARD (Task-1 review flag, Task 3): draftFromEffects's default param is
198
+ // `loadPatternPacks()` — every one of the ~100 existing draftFromEffects call-sites across the test
199
+ // suite that doesn't pass packs explicitly hits this default. Once a real core pack exists
200
+ // (date-picker-divsoup below), that default would silently start loading it into every unrelated
201
+ // fixture test, coupling them to core-pack content they were never written against. Fix centrally,
202
+ // here, not per call-site: under vitest (`process.env.VITEST`, set by the runner itself — no config
203
+ // needed) the default resolves to NO dirs (packs = []), so a test gets real packs ONLY when it asks
204
+ // for them (loadPatternPacks(dirs) with an explicit dir, or draftFromEffects(effects, packs) with an
205
+ // explicit pack array — both bypass this default entirely). Production (webnav CLI / MCP) is never
206
+ // running under vitest, so it always sees the real shipped dirs.
207
+ function defaultPackDirs() {
208
+ if (process.env.VITEST)
209
+ return [];
210
+ const here = dirname(fileURLToPath(import.meta.url));
211
+ const root = join(here, '..', '..'); // repo root from src/explorer OR dist/explorer
212
+ return [join(root, 'packs', 'patterns', 'core'), join(root, 'packs', 'patterns', 'proposed')];
213
+ }
214
+ /** Load every `*.json` pack entry from the given dirs (default = the two shipped dirs), sorted by
215
+ * filename for determinism. Each file holds ONE entry (object) OR an array of entries. Throws on the
216
+ * first invalid file/entry with a message listing the lint failures (loud, per the plan). */
217
+ export function loadPatternPacks(dirs = defaultPackDirs()) {
218
+ const out = [];
219
+ for (const dir of dirs) {
220
+ let files;
221
+ try {
222
+ files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
223
+ }
224
+ catch {
225
+ continue;
226
+ } // missing dir → nothing to load (not an error)
227
+ for (const file of files) {
228
+ const path = join(dir, file);
229
+ let parsed;
230
+ try {
231
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
232
+ }
233
+ catch (e) {
234
+ throw new Error(`pattern pack ${path}: not valid JSON — ${e.message}`);
235
+ }
236
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
237
+ entries.forEach((entry, i) => {
238
+ const reasons = lintPackEntry(entry);
239
+ if (reasons.length) {
240
+ const where = Array.isArray(parsed) ? `${path}[${i}]` : path;
241
+ throw new Error(`pattern pack ${where} failed lint:\n - ${reasons.join('\n - ')}`);
242
+ }
243
+ out.push({ ...entry, source: path });
244
+ });
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ // ── DRAFT-SIDE HELPERS (thin, so draft.ts wiring stays a one-liner per hook) ──
250
+ /** Hook 1 (overlay-open): did ANY overlay-open pack trigger match these diff.added nodes? Called
251
+ * ONLY when the built-in openedOverlay detection declined — a match flips detection true. */
252
+ export function packDetectsOverlay(packs, addedNodes) {
253
+ return packs.some((p) => p.type === 'overlay-open' && evaluateTrigger(p.trigger, addedNodes));
254
+ }
255
+ /** Hook 2 (value-domain): the set of NODE NAMES a value-domain pack marks as value data within the
256
+ * given candidate nodes. A node joins the excluded set iff it sits inside a firing trigger's matched
257
+ * subtree (the value grid), so its name never becomes an affordance / never anchors a fingerprint.
258
+ * Returns names (callers filter by name, matching enumeratedNames/templateFolds.gatedNames). */
259
+ export function packValueNames(packs, nodes) {
260
+ const names = new Set();
261
+ for (const p of packs) {
262
+ if (p.type !== 'value-domain')
263
+ continue;
264
+ for (const idx of matchedSubtreeIndices(p.trigger, nodes)) {
265
+ const nm = nodes[idx].name;
266
+ if (nm && nm.trim())
267
+ names.add(nm);
268
+ }
269
+ }
270
+ return names;
271
+ }
272
+ /** Indices of every node inside a firing trigger's matched root subtree (empty when it doesn't
273
+ * fire). Rootless trigger → the whole list. Root trigger → every matching root's subtree. */
274
+ function matchedSubtreeIndices(trigger, nodes) {
275
+ if (!nodes.length || !evaluateTrigger(trigger, nodes))
276
+ return [];
277
+ if (!trigger.root)
278
+ return nodes.map((_, i) => i);
279
+ const { role, attr } = trigger.root;
280
+ const acc = [];
281
+ for (let i = 0; i < nodes.length; i++) {
282
+ if (nodes[i].role !== role || !attrOk(nodes[i], attr))
283
+ continue;
284
+ const end = subtreeEnd(nodes, i);
285
+ if (allContain(nodes, i + 1, end, trigger.contains))
286
+ for (let j = i + 1; j < end; j++)
287
+ acc.push(j);
288
+ }
289
+ return acc;
290
+ }
@@ -0,0 +1,3 @@
1
+ import type { SnapNode } from '../playwright/snapshot.js';
2
+ import type { DeclaredShadow } from '../mapstore/types.js';
3
+ export declare function extractShadow(nodes: SnapNode[]): DeclaredShadow;
@@ -0,0 +1,95 @@
1
+ import { insideOverlay } from './infer.js';
2
+ // Layer 2 — extract a page's DECLARED domain shadow as EVIDENCE (#5a: webnav records what the
3
+ // page declares, it NEVER interprets). Everything here is a verbatim string read from the
4
+ // snapshot; webnav does not name an entity, assert a relationship, or guess a field's meaning.
5
+ // The calling agent reads the shadow and reconstructs the domain model itself.
6
+ //
7
+ // Discipline (the hard line, review-bounded): we extract ONLY durable STRUCTURE with a real ARIA
8
+ // role — `columnheader` for table columns, `heading` for a section title, real `textbox`/
9
+ // `combobox`/`checkbox`/`searchbox` for filters — and never a DATA value (a record count, an
10
+ // enumerated overlay option). We deliberately do NOT infer filters from a `generic`
11
+ // label paired with a `generic [cursor=pointer]` div (a common custom-dropdown pseudo-select
12
+ // pattern) — that would be LAYOUT inference, which #5a forbids. Skipping honest-but-unobservable
13
+ // structure beats guessing.
14
+ // Design: docs/superpowers/specs/2026-06-13-learning-the-core-design.md (Layer 2).
15
+ const FILTER_ROLE = {
16
+ textbox: 'text', searchbox: 'text', combobox: 'select', checkbox: 'checkbox',
17
+ };
18
+ /** Strip decorative icon-font glyphs (Unicode Private Use Areas) and collapse whitespace from a
19
+ * declared name. These are rendering artifacts (Font Awesome sort/icon codepoints some apps
20
+ * append to headers/buttons), NOT semantic text — removing them is the same class of cleanup as
21
+ * trimming a trailing space, never interpretation (#5a). Returns '' for a glyph-only name (e.g.
22
+ * the select-all checkbox column). */
23
+ const cleanText = (s) => (s ?? '').replace(/[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/gu, '').replace(/\s+/g, ' ').trim();
24
+ /** The nearest preceding `heading` node before index `idx` (cleaned name), or null. */
25
+ function precedingHeading(nodes, idx) {
26
+ for (let i = idx - 1; i >= 0; i--) {
27
+ if (nodes[i].role === 'heading') {
28
+ const h = cleanText(nodes[i].name);
29
+ if (h)
30
+ return h;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ // Read the shadow from a page's CORE nodes (Task 10) — the durable, data-separated structure.
36
+ // A record COUNT is NOT extracted: it is a DATA value (changes with the rows), not structure
37
+ // (rule 2, 2026-07-10). `recordCount` stays on DeclaredShadow only for legacy stored data; nothing
38
+ // populates it here. There is also NO sub-tab option: naming a site's topbar region by a literal
39
+ // container name was a site-specific heuristic (deleted 2026-07-10) — sub-tabs surface as ordinary
40
+ // declared links/tabs, never a hard-coded container lookup.
41
+ export function extractShadow(nodes) {
42
+ const shadow = {};
43
+ // ── collections: each `table` → its columnheaders (cleaned) + nearest heading ──
44
+ const collections = [];
45
+ nodes.forEach((n, i) => {
46
+ if (n.role !== 'table')
47
+ return;
48
+ const tableDepth = n.depth;
49
+ const columns = [];
50
+ // columnheaders are descendants of this table (until the next node at <= table depth).
51
+ for (let j = i + 1; j < nodes.length; j++) {
52
+ if (nodes[j].depth <= tableDepth)
53
+ break;
54
+ if (nodes[j].role !== 'columnheader')
55
+ continue;
56
+ const col = cleanText(nodes[j].name);
57
+ if (col)
58
+ columns.push(col); // drop the empty select-all/checkbox column
59
+ }
60
+ if (!columns.length)
61
+ return; // a table with no named headers carries no shadow
62
+ // recordCount is DATA (rule 2) — left null; the field survives only for legacy stored maps.
63
+ collections.push({ heading: precedingHeading(nodes, i), columns, recordCount: null });
64
+ });
65
+ if (collections.length)
66
+ shadow.collections = collections;
67
+ // ── filters: ONLY real-ARIA controls with an accessible name (no div-soup inference), and
68
+ // NEVER one INSIDE an overlay — a filter control nested under a dialog/listbox is the overlay's
69
+ // own control (a value-picker search box) or an enumerated value, i.e. DATA, not the page's
70
+ // declared filter surface (rule 2). insideOverlay gates it out.
71
+ const filters = [];
72
+ const seenField = new Set();
73
+ nodes.forEach((n, i) => {
74
+ const control = FILTER_ROLE[n.role];
75
+ if (!control)
76
+ return;
77
+ if (insideOverlay(nodes, i))
78
+ return; // overlay control / enumerated value → data
79
+ const field = cleanText(n.name);
80
+ if (!field || seenField.has(field))
81
+ return;
82
+ seenField.add(field);
83
+ filters.push({ field, control });
84
+ });
85
+ if (filters.length)
86
+ shadow.filters = filters;
87
+ // ── createsEntity: the `Add` button's nearest enclosing heading (the owned entity panel) ──
88
+ const addIdx = nodes.findIndex((n) => n.role === 'button' && /\badd\b/i.test(cleanText(n.name)));
89
+ if (addIdx >= 0) {
90
+ const h = precedingHeading(nodes, addIdx);
91
+ if (h)
92
+ shadow.createsEntity = h;
93
+ }
94
+ return shadow;
95
+ }
@@ -0,0 +1,33 @@
1
+ import type { State } from '../mapstore/types.js';
2
+ export interface StateCoverage {
3
+ id: string;
4
+ counts: Record<AffKind, number>;
5
+ outgoing: number;
6
+ unexplored: string[];
7
+ deadEnd: boolean;
8
+ }
9
+ type AffKind = 'navigate' | 'reveal' | 'mutate' | 'input';
10
+ export interface Coverage {
11
+ node: string;
12
+ entry: string | null;
13
+ states: StateCoverage[];
14
+ totals: Record<AffKind, number> & {
15
+ states: number;
16
+ edges: number;
17
+ unexplored: number;
18
+ };
19
+ orphans: string[];
20
+ deadEnds: string[];
21
+ }
22
+ /** Analyse a node's interior for completeness — counts, unexplored exits,
23
+ * dead-ends, orphans. Pure: takes the states, returns a report. */
24
+ export declare function analyseCoverage(node: string, states: State[]): Coverage;
25
+ /** A flat indented outline of the whole interior. Read top-to-bottom and check
26
+ * each state + its affordances against the real site. Unexplored exits get a
27
+ * trailing "?", dead-ends/orphans are flagged, and a coverage summary leads. */
28
+ export declare function toOutline(node: string, states: State[]): string;
29
+ /** A Mermaid `stateDiagram-v2` of the interior. Paste into GitHub markdown,
30
+ * mermaid.live, or any markdown viewer to render. Unexplored exits go to a
31
+ * shared `unexplored` sink; the entry state gets the `[*]` start marker. */
32
+ export declare function toMermaid(node: string, states: State[]): string;
33
+ export {};
@@ -0,0 +1,155 @@
1
+ // Human-scannable views of a site's interior, for answering "did we capture
2
+ // everything?" WITHOUT the React Flow canvas. Two formats over the SAME stored
3
+ // data (states + their typed affordance repertoire):
4
+ // • outline — a flat indented dump you read top-to-bottom and eyeball against
5
+ // the real site.
6
+ // • mermaid — a `stateDiagram-v2` you paste into GitHub / a markdown viewer /
7
+ // mermaid.live to get a rendered diagram with zero tooling.
8
+ // Both surface COMPLETENESS cues: unexplored exits (→ ?), dead-ends, orphans, and
9
+ // per-state coverage counts, so gaps in the capture are obvious.
10
+ // ── shared traversal ────────────────────────────────────────────────────────
11
+ // Flatten a state's affordance tree (recursing into reveal children) into a list
12
+ // of [affordance, depth] so reveal overlays render indented under their opener.
13
+ function flatten(affs, depth = 0) {
14
+ const out = [];
15
+ for (const a of affs) {
16
+ out.push({ aff: a, depth });
17
+ if (a.children && a.children.length)
18
+ out.push(...flatten(a.children, depth + 1));
19
+ }
20
+ return out;
21
+ }
22
+ // A navigate (or a childless reveal) that routes somewhere; reveals WITH children
23
+ // don't route themselves (the children do).
24
+ function routesSomewhere(a) {
25
+ if (a.kind === 'navigate')
26
+ return true;
27
+ if (a.kind === 'reveal')
28
+ return !(a.children && a.children.length);
29
+ return false;
30
+ }
31
+ /** Analyse a node's interior for completeness — counts, unexplored exits,
32
+ * dead-ends, orphans. Pure: takes the states, returns a report. */
33
+ export function analyseCoverage(node, states) {
34
+ const ids = new Set(states.map((s) => s.id));
35
+ const targeted = new Set(); // states something navigates to
36
+ const totals = { navigate: 0, reveal: 0, mutate: 0, input: 0, states: states.length, edges: 0, unexplored: 0 };
37
+ const perState = states.map((s) => {
38
+ const counts = { navigate: 0, reveal: 0, mutate: 0, input: 0 };
39
+ const unexplored = [];
40
+ let outgoing = 0;
41
+ for (const { aff } of flatten(s.affordances ?? [])) {
42
+ counts[aff.kind]++;
43
+ totals[aff.kind]++;
44
+ if (routesSomewhere(aff)) {
45
+ if (aff.toState) {
46
+ outgoing++;
47
+ totals.edges++;
48
+ targeted.add(aff.toState);
49
+ }
50
+ else {
51
+ // routes, but the destination isn't mapped → an unexplored exit.
52
+ unexplored.push(aff.label);
53
+ totals.unexplored++;
54
+ }
55
+ }
56
+ }
57
+ return { id: s.id, counts, outgoing, unexplored, deadEnd: outgoing === 0 };
58
+ });
59
+ // Entry = where the flow starts. Prefer a state literally named *login*/*home*/
60
+ // *search*/*index*/*start* (the conceptual entry, even if a back-edge like Logout
61
+ // targets it); else a state nothing navigates TO (a structural root); else the
62
+ // first state. This keeps the entry sensible on cyclic graphs.
63
+ const named = states.find((s) => /login|home|search|index|start/i.test(s.id));
64
+ const untargeted = states.filter((s) => !targeted.has(s.id));
65
+ const entry = (named ?? untargeted[0] ?? states[0])?.id ?? null;
66
+ // Orphans: a non-entry state nothing points to (a real structural gap).
67
+ const orphans = states
68
+ .filter((s) => s.id !== entry && !targeted.has(s.id))
69
+ .map((s) => s.id);
70
+ const deadEnds = perState.filter((s) => s.deadEnd).map((s) => s.id);
71
+ return { node, entry, states: perState, totals, orphans, deadEnds };
72
+ }
73
+ // ── outline (plain text, top-to-bottom) ──────────────────────────────────────
74
+ const KIND_GLYPH = { navigate: '→', reveal: '▸', mutate: '·', input: '·' };
75
+ /** A flat indented outline of the whole interior. Read top-to-bottom and check
76
+ * each state + its affordances against the real site. Unexplored exits get a
77
+ * trailing "?", dead-ends/orphans are flagged, and a coverage summary leads. */
78
+ export function toOutline(node, states) {
79
+ const cov = analyseCoverage(node, states);
80
+ const lines = [];
81
+ const short = (id) => id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
82
+ lines.push(`# ${node} — interior coverage`);
83
+ lines.push(`${cov.totals.states} states · ${cov.totals.edges} mapped navigations · ` +
84
+ `${cov.totals.unexplored} unexplored exits · ` +
85
+ `${cov.totals.navigate} navigate / ${cov.totals.reveal} reveal / ` +
86
+ `${cov.totals.mutate} mutate / ${cov.totals.input} input`);
87
+ if (cov.deadEnds.length)
88
+ lines.push(`dead-ends (no outgoing nav): ${cov.deadEnds.map(short).join(', ')}`);
89
+ if (cov.orphans.length)
90
+ lines.push(`orphans (nothing navigates here): ${cov.orphans.map(short).join(', ')}`);
91
+ lines.push('');
92
+ for (const s of states) {
93
+ const sc = cov.states.find((x) => x.id === s.id);
94
+ const flags = [sc.deadEnd ? 'DEAD-END' : '', cov.orphans.includes(s.id) ? 'ORPHAN' : '']
95
+ .filter(Boolean).join(' ');
96
+ lines.push(`${short(s.id)}${flags ? ' [' + flags + ']' : ''}`);
97
+ for (const { aff, depth } of flatten(s.affordances ?? [])) {
98
+ const pad = ' '.repeat(depth + 1);
99
+ const glyph = KIND_GLYPH[aff.kind];
100
+ let line = `${pad}${glyph} ${aff.kind}: ${aff.label}`;
101
+ if (aff.kind === 'navigate' || (aff.kind === 'reveal' && routesSomewhere(aff))) {
102
+ line += aff.toState ? ` → ${short(aff.toState)}` : ` → ? (UNEXPLORED)`;
103
+ }
104
+ else if (aff.kind === 'reveal') {
105
+ line += ' (opens overlay)';
106
+ }
107
+ if (aff.commit)
108
+ line += ' [commit · never auto-fired]';
109
+ lines.push(line);
110
+ }
111
+ if (!(s.affordances ?? []).length)
112
+ lines.push(' (no affordances captured)');
113
+ lines.push('');
114
+ }
115
+ return lines.join('\n').trimEnd();
116
+ }
117
+ // ── mermaid (stateDiagram-v2) ─────────────────────────────────────────────────
118
+ // Mermaid ids must be alnum/underscore. Map a state id to a safe token.
119
+ function mid(id) {
120
+ const s = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
121
+ return s.replace(/[^A-Za-z0-9_]/g, '_');
122
+ }
123
+ /** A Mermaid `stateDiagram-v2` of the interior. Paste into GitHub markdown,
124
+ * mermaid.live, or any markdown viewer to render. Unexplored exits go to a
125
+ * shared `unexplored` sink; the entry state gets the `[*]` start marker. */
126
+ export function toMermaid(node, states) {
127
+ const cov = analyseCoverage(node, states);
128
+ const lines = ['stateDiagram-v2', ` %% ${node} interior`];
129
+ if (cov.entry)
130
+ lines.push(` [*] --> ${mid(cov.entry)}`);
131
+ let usedUnexplored = false;
132
+ for (const s of states) {
133
+ // a readable label for the state node
134
+ const label = s.id.includes(':') ? s.id.slice(s.id.indexOf(':') + 1) : s.id;
135
+ lines.push(` ${mid(s.id)} : ${label}`);
136
+ for (const { aff } of flatten(s.affordances ?? [])) {
137
+ if (!routesSomewhere(aff))
138
+ continue;
139
+ const lbl = aff.label.replace(/"/g, "'").replace(/:/g, ' ');
140
+ if (aff.toState) {
141
+ lines.push(` ${mid(s.id)} --> ${mid(aff.toState)} : ${lbl}${aff.commit ? ' [commit]' : ''}`);
142
+ }
143
+ else {
144
+ usedUnexplored = true;
145
+ lines.push(` ${mid(s.id)} --> unexplored : ${lbl} ?`);
146
+ }
147
+ }
148
+ }
149
+ if (usedUnexplored)
150
+ lines.push(' unexplored : ❓ unexplored');
151
+ // mark dead-ends as terminal so they read as flow ends
152
+ for (const id of cov.deadEnds)
153
+ lines.push(` ${mid(id)} --> [*]`);
154
+ return lines.join('\n');
155
+ }