@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,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,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
|
+
}
|