@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,11 @@
|
|
|
1
|
+
import type { SnapNode } from '../playwright/snapshot.js';
|
|
2
|
+
import type { DeclaredLink } from '../mapstore/record.js';
|
|
3
|
+
/**
|
|
4
|
+
* The structural signature of a page: the sorted, deduped set of element ROLES
|
|
5
|
+
* present. Two pages of the same TYPE (a repo-detail vs another repo-detail)
|
|
6
|
+
* share a fingerprint; instances collapse. Purely mechanical — no judgment.
|
|
7
|
+
*/
|
|
8
|
+
export declare function fingerprintPage(nodes: SnapNode[]): string[];
|
|
9
|
+
/** The navigable links a page declares: role 'link' WITH a url. `via` mirrors
|
|
10
|
+
* deriveEdges' phrasing so analysed edges read consistently with the rest. */
|
|
11
|
+
export declare function declaredLinks(nodes: SnapNode[]): DeclaredLink[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structural signature of a page: the sorted, deduped set of element ROLES
|
|
3
|
+
* present. Two pages of the same TYPE (a repo-detail vs another repo-detail)
|
|
4
|
+
* share a fingerprint; instances collapse. Purely mechanical — no judgment.
|
|
5
|
+
*/
|
|
6
|
+
export function fingerprintPage(nodes) {
|
|
7
|
+
return [...new Set(nodes.map((n) => n.role))].sort();
|
|
8
|
+
}
|
|
9
|
+
/** The navigable links a page declares: role 'link' WITH a url. `via` mirrors
|
|
10
|
+
* deriveEdges' phrasing so analysed edges read consistently with the rest. */
|
|
11
|
+
export function declaredLinks(nodes) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const n of nodes) {
|
|
14
|
+
if (n.role !== 'link' || !n.url)
|
|
15
|
+
continue;
|
|
16
|
+
out.push({ to: n.url, via: `follow link "${n.name ?? n.url}"` });
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { State } from '../mapstore/types.js';
|
|
2
|
+
import type { SnapNode } from '../playwright/snapshot.js';
|
|
3
|
+
export type MatchResult = {
|
|
4
|
+
status: 'matched';
|
|
5
|
+
state: State;
|
|
6
|
+
} | {
|
|
7
|
+
status: 'none';
|
|
8
|
+
} | {
|
|
9
|
+
status: 'ambiguous';
|
|
10
|
+
states: State[];
|
|
11
|
+
};
|
|
12
|
+
/** A fingerprint token is "role" or "role:name". All tokens must be present. */
|
|
13
|
+
export declare function hasToken(nodes: SnapNode[], token: string): boolean;
|
|
14
|
+
export declare function matchState(nodes: SnapNode[], states: State[]): MatchResult;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** A fingerprint token is "role" or "role:name". All tokens must be present. */
|
|
2
|
+
export function hasToken(nodes, token) {
|
|
3
|
+
const [role, name] = token.split(':');
|
|
4
|
+
return nodes.some((n) => n.role === role && (name === undefined || n.name === name));
|
|
5
|
+
}
|
|
6
|
+
export function matchState(nodes, states) {
|
|
7
|
+
// An EMPTY fingerprint identifies nothing — `[].every()` is vacuously true, so it would match
|
|
8
|
+
// EVERY page and make every landing `ambiguous`. `_shell` (site chrome, no identity) and any
|
|
9
|
+
// degenerate/held-out stub carry `[]`; they are routing sources, never match candidates. Excise
|
|
10
|
+
// them here so no caller has to remember to (live finding: `_shell` in the walk's state set made
|
|
11
|
+
// a walk to a real report escalate `ambiguous` forever — [report, _shell]).
|
|
12
|
+
const hits = states.filter((s) => s.fingerprint.length > 0 && s.fingerprint.every((t) => hasToken(nodes, t)));
|
|
13
|
+
if (hits.length === 1)
|
|
14
|
+
return { status: 'matched', state: hits[0] };
|
|
15
|
+
if (hits.length === 0)
|
|
16
|
+
return { status: 'none' };
|
|
17
|
+
return { status: 'ambiguous', states: hits };
|
|
18
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { SnapNode } from '../playwright/snapshot.js';
|
|
2
|
+
export interface UrlModel {
|
|
3
|
+
base: string[];
|
|
4
|
+
keyOf(url: string): string;
|
|
5
|
+
}
|
|
6
|
+
export declare const isOpaqueSeg: (s: string) => boolean;
|
|
7
|
+
/** Site base = greedy leading segments shared by ≥80% of observed paths (e.g. version+tenant
|
|
8
|
+
* `v3/9999`). keyOf strips query/hash + the base segments IN ORDER WHERE PRESENT, so a
|
|
9
|
+
* pre-redirect URL missing the tenant still lands on the same key (the ghost merges).
|
|
10
|
+
*
|
|
11
|
+
* A base POSITION whose value VARIES across observed URLs is a PARAM, not a locked literal: when
|
|
12
|
+
* the position's dominant value is an opaque id AND ≥2 DISTINCT opaque ids appear there (a tenant
|
|
13
|
+
* slot `{9999,8888}`), the whole position becomes a `{param}` base entry so BOTH tenants' identical
|
|
14
|
+
* pages key to the SAME key (account-portable identity). Guard (reuses isOpaqueSeg): a position
|
|
15
|
+
* whose varying values are MODULE WORDS (report/shop) is a genuinely different path, NOT a param —
|
|
16
|
+
* it must stay distinct. `keyOf` emits `{param}` at a param position only when the actual segment
|
|
17
|
+
* is opaque; a non-opaque segment there (a pre-redirect ghost missing the tenant) is left in place. */
|
|
18
|
+
export declare function inferUrlModel(urls: string[]): UrlModel;
|
|
19
|
+
export interface TemplateGroup {
|
|
20
|
+
template: string;
|
|
21
|
+
keys: string[];
|
|
22
|
+
paramPos: number;
|
|
23
|
+
}
|
|
24
|
+
/** PROPOSE param templates: keys of equal length differing at exactly one position (≥2 keys).
|
|
25
|
+
* Purely positional — no digit/hex shape guessing. The caller DISPOSES structurally. */
|
|
26
|
+
export declare function proposeTemplates(keys: string[]): TemplateGroup[];
|
|
27
|
+
export type Face = Set<string>;
|
|
28
|
+
export declare function faceOf(nodes: SnapNode[]): Face;
|
|
29
|
+
export declare function jaccard(a: Face, b: Face): number;
|
|
30
|
+
/** Containment |A∩B| / min(|A|,|B|): 1 when the smaller face is a subset of the larger. A
|
|
31
|
+
* PARTIAL RENDER (a page captured before its data grid arrived) reads as contained-in the full
|
|
32
|
+
* face even when jaccard is tiny (real case: 33-token subset of a 199-token list page → jaccard
|
|
33
|
+
* 0.17, containment 1.0), while a genuinely different page is not. Thresholds live at call sites. */
|
|
34
|
+
export declare function containment(a: Face, b: Face): number;
|
|
35
|
+
export declare const CONTROL_ROLES: ReadonlySet<string>;
|
|
36
|
+
/** The sub-face of interactive-control tokens ('role:name' filtered to CONTROL_ROLES). */
|
|
37
|
+
export declare function controlFace(f: Face): Face;
|
|
38
|
+
export declare const OVERLAY_ROLES: ReadonlySet<string>;
|
|
39
|
+
/** Walk ancestors by indent depth (same containment logic shadow.ts uses): the nearest
|
|
40
|
+
* lower-depth predecessor chain; true if any ancestor's role declares an overlay. */
|
|
41
|
+
export declare function insideOverlay(nodes: SnapNode[], idx: number): boolean;
|
|
42
|
+
export declare function nodeIndexByName(nodes: SnapNode[], name: string): number;
|
|
43
|
+
/** IDENTITY SCOPING (X3, OQ1): when a landing DECLARES a `main` landmark, a state's identity is
|
|
44
|
+
* what's inside `main` — ancillary `complementary` rails / secondary nav that sit OUTSIDE `main`
|
|
45
|
+
* are chrome, not identity, and must not pollute the face (a per-page-varying rail is neither
|
|
46
|
+
* shell nor page-distinguishing). Same nearest-lower-depth containment idiom as `insideOverlay`:
|
|
47
|
+
* keep a node iff its ancestor chain passes through a `main` (or it IS the `main`). NO `main`
|
|
48
|
+
* declared → return nodes unchanged (declared-evidence-gated, never inferred). */
|
|
49
|
+
export declare function mainScope(nodes: SnapNode[]): SnapNode[];
|
|
50
|
+
/** Shell = tokens present on ≥minFrac of DISTINCT pages. Needs ≥4 pages to claim anything —
|
|
51
|
+
* on tiny evidence a "shell" would just be coincidence. */
|
|
52
|
+
export declare function extractShell(faces: Face[], minFrac?: number): Face;
|
|
53
|
+
export interface SubtreeFold {
|
|
54
|
+
sig: string;
|
|
55
|
+
level: 'named' | 'abstracted';
|
|
56
|
+
count: number;
|
|
57
|
+
memberIndices: number[];
|
|
58
|
+
label: string;
|
|
59
|
+
unitSize: number;
|
|
60
|
+
}
|
|
61
|
+
/** Repeated sibling subtrees under a shared parent fold into typed sub-templates. Two signature
|
|
62
|
+
* levels, bottom-up:
|
|
63
|
+
* L1 (named) role (+ "name" if role ∈ CONTROL_ROLES — control labels are the strongest
|
|
64
|
+
* template evidence) + '(' + sorted children L1 sigs + ')'.
|
|
65
|
+
* L2 (abstracted) same, ALL names dropped — pure shape.
|
|
66
|
+
* Fold rules PER PARENT (thresholds evidence-scaled, documented here):
|
|
67
|
+
* (a) named ≥2 — ≥2 children sharing an L1 sig (identical repeated units; 'Expand drilldown' ×25).
|
|
68
|
+
* (b) abstracted ≥3 — among the still-unfolded children, ≥3 sharing an L2 sig across ≥2 DISTINCT
|
|
69
|
+
* L1 sigs (the varying-name 'OS Remove'/'Revenue Remove' class). Higher bar:
|
|
70
|
+
* an abstract match is weaker evidence than an exact-label repeat.
|
|
71
|
+
* A candidate unit must carry SOMETHING (a name or a control role somewhere in its subtree) — pure
|
|
72
|
+
* unnamed `generic` wrappers fold to nothing and are skipped. A node already inside a named fold is
|
|
73
|
+
* never reconsidered for abstracted folding. Pure + deterministic; no draft changes. */
|
|
74
|
+
export declare function subtreeFolds(nodes: SnapNode[]): {
|
|
75
|
+
folds: SubtreeFold[];
|
|
76
|
+
foldedIndices: Set<number>;
|
|
77
|
+
};
|
|
78
|
+
export interface CoreResult {
|
|
79
|
+
tokens: Face;
|
|
80
|
+
provisional: string | null;
|
|
81
|
+
}
|
|
82
|
+
/** A state's durable face = tokens repeating across its settled landings (majority k-of-n,
|
|
83
|
+
* 0.6 — tolerates one A/B-noisy visit in three). ONE landing = no variance signal: keep all,
|
|
84
|
+
* mark provisional; analyse surfaces the note as a record-next request. */
|
|
85
|
+
export declare function templateCore(landingFaces: Face[]): CoreResult;
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Observation-based inference primitives (settled 2026-07-10, structure-inference design).
|
|
2
|
+
// Pure + deterministic; ZERO site knowledge (#5a). Thresholds are documented tunables.
|
|
3
|
+
// Path segments of a URL. Accepts BOTH absolute (`https://h/v3/9999/x`) and RELATIVE
|
|
4
|
+
// (`/v3/9999/x`) forms — snapshot link hrefs are usually relative, and keying them must
|
|
5
|
+
// land on the same key as the absolute toUrl (else every sidebar link keys to `/` and no
|
|
6
|
+
// from-anywhere shell edge ever resolves). A dummy base makes the relative parse succeed
|
|
7
|
+
// without affecting the pathname.
|
|
8
|
+
const segsOf = (url) => {
|
|
9
|
+
try {
|
|
10
|
+
return new URL(url, 'http://_').pathname.split('/').filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
// A path segment that is an opaque INSTANCE id — all-digits or a long hex/uuid-ish token — vs a
|
|
17
|
+
// meaningful MODULE word (report/dashboard/list). The design's sanctioned URL-SHAPE prior; site-
|
|
18
|
+
// AGNOSTIC (no product tokens). `{param}` (an already-abstracted template slot) counts as opaque.
|
|
19
|
+
// Lives here with the URL model (the canonical home for the word-vs-opaque distinction); draft.ts
|
|
20
|
+
// imports it so both the base-inference param detection and the template dispose share one rule.
|
|
21
|
+
export const isOpaqueSeg = (s) => s === PARAM || /^\d+$/.test(s) || /^[0-9a-f]{16,}$/i.test(s);
|
|
22
|
+
const PARAM = '{param}';
|
|
23
|
+
/** Site base = greedy leading segments shared by ≥80% of observed paths (e.g. version+tenant
|
|
24
|
+
* `v3/9999`). keyOf strips query/hash + the base segments IN ORDER WHERE PRESENT, so a
|
|
25
|
+
* pre-redirect URL missing the tenant still lands on the same key (the ghost merges).
|
|
26
|
+
*
|
|
27
|
+
* A base POSITION whose value VARIES across observed URLs is a PARAM, not a locked literal: when
|
|
28
|
+
* the position's dominant value is an opaque id AND ≥2 DISTINCT opaque ids appear there (a tenant
|
|
29
|
+
* slot `{9999,8888}`), the whole position becomes a `{param}` base entry so BOTH tenants' identical
|
|
30
|
+
* pages key to the SAME key (account-portable identity). Guard (reuses isOpaqueSeg): a position
|
|
31
|
+
* whose varying values are MODULE WORDS (report/shop) is a genuinely different path, NOT a param —
|
|
32
|
+
* it must stay distinct. `keyOf` emits `{param}` at a param position only when the actual segment
|
|
33
|
+
* is opaque; a non-opaque segment there (a pre-redirect ghost missing the tenant) is left in place. */
|
|
34
|
+
export function inferUrlModel(urls) {
|
|
35
|
+
let work = urls.map(segsOf);
|
|
36
|
+
const base = [];
|
|
37
|
+
for (;;) {
|
|
38
|
+
const nonEmpty = work.filter((p) => p.length);
|
|
39
|
+
if (!nonEmpty.length)
|
|
40
|
+
break;
|
|
41
|
+
const heads = new Map();
|
|
42
|
+
for (const p of nonEmpty)
|
|
43
|
+
heads.set(p[0], (heads.get(p[0]) ?? 0) + 1);
|
|
44
|
+
const [top, cnt] = [...heads.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
45
|
+
// A VARYING opaque-id position → `{param}` base entry (≥2 distinct opaque values seen here).
|
|
46
|
+
// The dominant value must itself be opaque, else a mostly-constant word position with a couple
|
|
47
|
+
// of stray opaque ids would wrongly abstract. Consume the position from every path either way.
|
|
48
|
+
const distinctOpaque = new Set([...heads.keys()].filter(isOpaqueSeg));
|
|
49
|
+
if (isOpaqueSeg(top) && distinctOpaque.size >= 2) {
|
|
50
|
+
base.push(PARAM);
|
|
51
|
+
work = work.map((p) => (isOpaqueSeg(p[0]) ? p.slice(1) : p));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (cnt < 0.8 * nonEmpty.length)
|
|
55
|
+
break;
|
|
56
|
+
base.push(top);
|
|
57
|
+
work = work.map((p) => (p[0] === top ? p.slice(1) : p));
|
|
58
|
+
}
|
|
59
|
+
const keyOf = (url) => {
|
|
60
|
+
let p = segsOf(url);
|
|
61
|
+
for (const b of base) {
|
|
62
|
+
if (b === PARAM) {
|
|
63
|
+
if (isOpaqueSeg(p[0]))
|
|
64
|
+
p = [PARAM, ...p.slice(1)];
|
|
65
|
+
} // opaque seg → {param}
|
|
66
|
+
else if (p[0] === b)
|
|
67
|
+
p = p.slice(1); // literal base → strip
|
|
68
|
+
}
|
|
69
|
+
return '/' + p.join('/');
|
|
70
|
+
};
|
|
71
|
+
return { base, keyOf };
|
|
72
|
+
}
|
|
73
|
+
/** PROPOSE param templates: keys of equal length differing at exactly one position (≥2 keys).
|
|
74
|
+
* Purely positional — no digit/hex shape guessing. The caller DISPOSES structurally. */
|
|
75
|
+
export function proposeTemplates(keys) {
|
|
76
|
+
const bySig = new Map();
|
|
77
|
+
const parts = keys.map((k) => k.split('/').filter(Boolean));
|
|
78
|
+
for (let i = 0; i < parts.length; i++) {
|
|
79
|
+
for (let pos = 0; pos < parts[i].length; pos++) {
|
|
80
|
+
const sig = parts[i].length + ':' + pos + ':' + parts[i].map((s, j) => (j === pos ? '{param}' : s)).join('/');
|
|
81
|
+
const g = bySig.get(sig) ?? { keys: [], paramPos: pos, parts: parts[i] };
|
|
82
|
+
g.keys.push(keys[i]);
|
|
83
|
+
bySig.set(sig, g);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...bySig.values()].filter((g) => g.keys.length >= 2).map((g) => ({
|
|
87
|
+
template: '/' + g.parts.map((s, j) => (j === g.paramPos ? '{param}' : s)).join('/'),
|
|
88
|
+
keys: g.keys, paramPos: g.paramPos,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
export function faceOf(nodes) {
|
|
92
|
+
const f = new Set();
|
|
93
|
+
for (const n of nodes)
|
|
94
|
+
if (n.name && n.name.trim())
|
|
95
|
+
f.add(`${n.role}:${n.name}`);
|
|
96
|
+
return f;
|
|
97
|
+
}
|
|
98
|
+
export function jaccard(a, b) {
|
|
99
|
+
if (!a.size && !b.size)
|
|
100
|
+
return 1;
|
|
101
|
+
let inter = 0;
|
|
102
|
+
for (const t of a)
|
|
103
|
+
if (b.has(t))
|
|
104
|
+
inter++;
|
|
105
|
+
return inter / (a.size + b.size - inter);
|
|
106
|
+
}
|
|
107
|
+
/** Containment |A∩B| / min(|A|,|B|): 1 when the smaller face is a subset of the larger. A
|
|
108
|
+
* PARTIAL RENDER (a page captured before its data grid arrived) reads as contained-in the full
|
|
109
|
+
* face even when jaccard is tiny (real case: 33-token subset of a 199-token list page → jaccard
|
|
110
|
+
* 0.17, containment 1.0), while a genuinely different page is not. Thresholds live at call sites. */
|
|
111
|
+
export function containment(a, b) {
|
|
112
|
+
if (!a.size || !b.size)
|
|
113
|
+
return a.size === b.size ? 1 : 0;
|
|
114
|
+
let inter = 0;
|
|
115
|
+
for (const t of a)
|
|
116
|
+
if (b.has(t))
|
|
117
|
+
inter++;
|
|
118
|
+
return inter / Math.min(a.size, b.size);
|
|
119
|
+
}
|
|
120
|
+
// Interactive CONTROL roles — a template's SKELETON. Two instances of one page template share
|
|
121
|
+
// what you can DO (buttons/fields/tabs) even when their text/link tokens are all instance data
|
|
122
|
+
// (product names, prices, related items). Observational, no site tokens.
|
|
123
|
+
export const CONTROL_ROLES = new Set(['button', 'textbox', 'combobox', 'searchbox', 'spinbutton', 'checkbox', 'tab']);
|
|
124
|
+
/** The sub-face of interactive-control tokens ('role:name' filtered to CONTROL_ROLES). */
|
|
125
|
+
export function controlFace(f) {
|
|
126
|
+
const out = new Set();
|
|
127
|
+
for (const t of f)
|
|
128
|
+
if (CONTROL_ROLES.has(t.slice(0, t.indexOf(':'))))
|
|
129
|
+
out.add(t);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
// Roles that DECLARE a transient overlay container (WAI-ARIA). A node nested under one is
|
|
133
|
+
// overlay content (a value being chosen, or the overlay's own controls) — never page structure.
|
|
134
|
+
export const OVERLAY_ROLES = new Set(['dialog', 'alertdialog', 'menu', 'listbox', 'tooltip']);
|
|
135
|
+
/** Walk ancestors by indent depth (same containment logic shadow.ts uses): the nearest
|
|
136
|
+
* lower-depth predecessor chain; true if any ancestor's role declares an overlay. */
|
|
137
|
+
export function insideOverlay(nodes, idx) {
|
|
138
|
+
if (idx < 0 || idx >= nodes.length)
|
|
139
|
+
return false;
|
|
140
|
+
let cur = nodes[idx].depth;
|
|
141
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
142
|
+
if (nodes[i].depth < cur) {
|
|
143
|
+
if (OVERLAY_ROLES.has(nodes[i].role))
|
|
144
|
+
return true;
|
|
145
|
+
cur = nodes[i].depth;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
export function nodeIndexByName(nodes, name) {
|
|
151
|
+
return nodes.findIndex((n) => n.name === name);
|
|
152
|
+
}
|
|
153
|
+
/** IDENTITY SCOPING (X3, OQ1): when a landing DECLARES a `main` landmark, a state's identity is
|
|
154
|
+
* what's inside `main` — ancillary `complementary` rails / secondary nav that sit OUTSIDE `main`
|
|
155
|
+
* are chrome, not identity, and must not pollute the face (a per-page-varying rail is neither
|
|
156
|
+
* shell nor page-distinguishing). Same nearest-lower-depth containment idiom as `insideOverlay`:
|
|
157
|
+
* keep a node iff its ancestor chain passes through a `main` (or it IS the `main`). NO `main`
|
|
158
|
+
* declared → return nodes unchanged (declared-evidence-gated, never inferred). */
|
|
159
|
+
export function mainScope(nodes) {
|
|
160
|
+
if (!nodes.some((n) => n.role === 'main'))
|
|
161
|
+
return nodes;
|
|
162
|
+
const out = [];
|
|
163
|
+
for (let idx = 0; idx < nodes.length; idx++) {
|
|
164
|
+
if (nodes[idx].role === 'main') {
|
|
165
|
+
out.push(nodes[idx]);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
let cur = nodes[idx].depth, inMain = false;
|
|
169
|
+
for (let i = idx - 1; i >= 0 && !inMain; i--) {
|
|
170
|
+
if (nodes[i].depth < cur) {
|
|
171
|
+
if (nodes[i].role === 'main')
|
|
172
|
+
inMain = true;
|
|
173
|
+
cur = nodes[i].depth;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (inMain)
|
|
177
|
+
out.push(nodes[idx]);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/** Shell = tokens present on ≥minFrac of DISTINCT pages. Needs ≥4 pages to claim anything —
|
|
182
|
+
* on tiny evidence a "shell" would just be coincidence. */
|
|
183
|
+
export function extractShell(faces, minFrac = 0.8) {
|
|
184
|
+
if (faces.length < 4)
|
|
185
|
+
return new Set();
|
|
186
|
+
const count = new Map();
|
|
187
|
+
for (const f of faces)
|
|
188
|
+
for (const t of f)
|
|
189
|
+
count.set(t, (count.get(t) ?? 0) + 1);
|
|
190
|
+
const shell = new Set();
|
|
191
|
+
for (const [t, c] of count)
|
|
192
|
+
if (c >= minFrac * faces.length)
|
|
193
|
+
shell.add(t);
|
|
194
|
+
return shell;
|
|
195
|
+
}
|
|
196
|
+
// Tiny deterministic string hash (djb2 → 8 hex). No crypto import — sigs need only be stable
|
|
197
|
+
// across runs and collision-resistant enough to key groups; they are not security material.
|
|
198
|
+
function sigHash(s) {
|
|
199
|
+
let h = 5381;
|
|
200
|
+
for (let i = 0; i < s.length; i++)
|
|
201
|
+
h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
|
|
202
|
+
return h.toString(16).padStart(8, '0');
|
|
203
|
+
}
|
|
204
|
+
// Longest common trailing word(s) across names. Returns '' when the names share no trailing word.
|
|
205
|
+
// Anchors an abstracted fold's label ('OS Remove'/'Revenue Remove' → 'Remove').
|
|
206
|
+
function commonTrailingWords(names) {
|
|
207
|
+
const wordLists = names.map((n) => n.trim().split(/\s+/));
|
|
208
|
+
const out = [];
|
|
209
|
+
for (let back = 1;; back++) {
|
|
210
|
+
const words = wordLists.map((w) => (w.length >= back ? w[w.length - back] : undefined));
|
|
211
|
+
if (words.some((w) => w === undefined) || new Set(words).size !== 1)
|
|
212
|
+
break;
|
|
213
|
+
out.unshift(words[0]);
|
|
214
|
+
}
|
|
215
|
+
return out.join(' ');
|
|
216
|
+
}
|
|
217
|
+
/** Repeated sibling subtrees under a shared parent fold into typed sub-templates. Two signature
|
|
218
|
+
* levels, bottom-up:
|
|
219
|
+
* L1 (named) role (+ "name" if role ∈ CONTROL_ROLES — control labels are the strongest
|
|
220
|
+
* template evidence) + '(' + sorted children L1 sigs + ')'.
|
|
221
|
+
* L2 (abstracted) same, ALL names dropped — pure shape.
|
|
222
|
+
* Fold rules PER PARENT (thresholds evidence-scaled, documented here):
|
|
223
|
+
* (a) named ≥2 — ≥2 children sharing an L1 sig (identical repeated units; 'Expand drilldown' ×25).
|
|
224
|
+
* (b) abstracted ≥3 — among the still-unfolded children, ≥3 sharing an L2 sig across ≥2 DISTINCT
|
|
225
|
+
* L1 sigs (the varying-name 'OS Remove'/'Revenue Remove' class). Higher bar:
|
|
226
|
+
* an abstract match is weaker evidence than an exact-label repeat.
|
|
227
|
+
* A candidate unit must carry SOMETHING (a name or a control role somewhere in its subtree) — pure
|
|
228
|
+
* unnamed `generic` wrappers fold to nothing and are skipped. A node already inside a named fold is
|
|
229
|
+
* never reconsidered for abstracted folding. Pure + deterministic; no draft changes. */
|
|
230
|
+
export function subtreeFolds(nodes) {
|
|
231
|
+
const n = nodes.length;
|
|
232
|
+
// Tree via depth stack: parent of node i = nearest preceding node with lower depth (the
|
|
233
|
+
// containment idiom used everywhere). Root nodes (no lower-depth predecessor) get parent -1.
|
|
234
|
+
const parent = new Array(n).fill(-1);
|
|
235
|
+
const children = Array.from({ length: n }, () => []);
|
|
236
|
+
const roots = [];
|
|
237
|
+
const stack = [];
|
|
238
|
+
for (let i = 0; i < n; i++) {
|
|
239
|
+
while (stack.length && nodes[stack[stack.length - 1]].depth >= nodes[i].depth)
|
|
240
|
+
stack.pop();
|
|
241
|
+
if (stack.length) {
|
|
242
|
+
parent[i] = stack[stack.length - 1];
|
|
243
|
+
children[parent[i]].push(i);
|
|
244
|
+
}
|
|
245
|
+
else
|
|
246
|
+
roots.push(i);
|
|
247
|
+
stack.push(i);
|
|
248
|
+
}
|
|
249
|
+
// Bottom-up signatures + subtree size + content flag. Children always follow their parent at
|
|
250
|
+
// greater depth, so reverse index order is a valid post-order (child computed before parent).
|
|
251
|
+
const l1 = new Array(n), l2 = new Array(n);
|
|
252
|
+
const size = new Array(n).fill(1);
|
|
253
|
+
const hasContent = new Array(n).fill(false);
|
|
254
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
255
|
+
const isControl = CONTROL_ROLES.has(nodes[i].role);
|
|
256
|
+
const self = hasContent[i] || (nodes[i].name != null && nodes[i].name.trim() !== '') || isControl;
|
|
257
|
+
hasContent[i] = self;
|
|
258
|
+
const kids = children[i];
|
|
259
|
+
const k1 = [], k2 = [];
|
|
260
|
+
for (const c of kids) {
|
|
261
|
+
k1.push(l1[c]);
|
|
262
|
+
k2.push(l2[c]);
|
|
263
|
+
size[i] += size[c];
|
|
264
|
+
if (hasContent[c])
|
|
265
|
+
hasContent[i] = true;
|
|
266
|
+
}
|
|
267
|
+
k1.sort();
|
|
268
|
+
k2.sort();
|
|
269
|
+
const label1 = nodes[i].role + (isControl && nodes[i].name ? '"' + nodes[i].name + '"' : '');
|
|
270
|
+
l1[i] = label1 + '(' + k1.join(',') + ')';
|
|
271
|
+
l2[i] = nodes[i].role + '(' + k2.join(',') + ')';
|
|
272
|
+
}
|
|
273
|
+
// All indices inside a subtree (root + descendants), document order.
|
|
274
|
+
const subtreeIndices = (root) => {
|
|
275
|
+
const acc = [];
|
|
276
|
+
const walk = (i) => { acc.push(i); for (const c of children[i])
|
|
277
|
+
walk(c); };
|
|
278
|
+
walk(root);
|
|
279
|
+
return acc;
|
|
280
|
+
};
|
|
281
|
+
// First control-role node WITH a name in a unit's subtree (document order) → its stable label.
|
|
282
|
+
const firstControlName = (root) => {
|
|
283
|
+
for (const i of subtreeIndices(root)) {
|
|
284
|
+
if (CONTROL_ROLES.has(nodes[i].role) && nodes[i].name && nodes[i].name.trim())
|
|
285
|
+
return nodes[i].name.trim();
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
};
|
|
289
|
+
const folds = [];
|
|
290
|
+
const foldedIndices = new Set();
|
|
291
|
+
const foldUnderParent = (kids) => {
|
|
292
|
+
const eligible = kids.filter((c) => hasContent[c]); // skip pure unnamed wrappers
|
|
293
|
+
// (a) NAMED: group by L1, groups ≥2.
|
|
294
|
+
const byL1 = new Map();
|
|
295
|
+
for (const c of eligible)
|
|
296
|
+
(byL1.get(l1[c]) ?? byL1.set(l1[c], []).get(l1[c])).push(c);
|
|
297
|
+
const claimed = new Set();
|
|
298
|
+
for (const [sig, members] of byL1) {
|
|
299
|
+
if (members.length < 2)
|
|
300
|
+
continue;
|
|
301
|
+
for (const m of members)
|
|
302
|
+
claimed.add(m);
|
|
303
|
+
const mi = [];
|
|
304
|
+
for (const m of members)
|
|
305
|
+
for (const idx of subtreeIndices(m)) {
|
|
306
|
+
mi.push(idx);
|
|
307
|
+
foldedIndices.add(idx);
|
|
308
|
+
}
|
|
309
|
+
folds.push({
|
|
310
|
+
sig: sigHash(sig), level: 'named', count: members.length, memberIndices: mi,
|
|
311
|
+
label: firstControlName(members[0]) ?? nodes[members[0]].role, unitSize: size[members[0]],
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// (b) ABSTRACTED: among children NOT claimed by a named fold, group by L2; groups ≥3 with ≥2
|
|
315
|
+
// distinct L1 sigs (varying names are the param slots).
|
|
316
|
+
const byL2 = new Map();
|
|
317
|
+
for (const c of eligible) {
|
|
318
|
+
if (claimed.has(c))
|
|
319
|
+
continue;
|
|
320
|
+
(byL2.get(l2[c]) ?? byL2.set(l2[c], []).get(l2[c])).push(c);
|
|
321
|
+
}
|
|
322
|
+
for (const [sig, members] of byL2) {
|
|
323
|
+
if (members.length < 3)
|
|
324
|
+
continue;
|
|
325
|
+
if (new Set(members.map((m) => l1[m])).size < 2)
|
|
326
|
+
continue; // identical units belong to named, not here
|
|
327
|
+
const mi = [];
|
|
328
|
+
for (const m of members)
|
|
329
|
+
for (const idx of subtreeIndices(m)) {
|
|
330
|
+
mi.push(idx);
|
|
331
|
+
foldedIndices.add(idx);
|
|
332
|
+
}
|
|
333
|
+
// Label = longest common trailing word of the members' distinguishing names, else unit role.
|
|
334
|
+
const names = [];
|
|
335
|
+
for (const m of members) {
|
|
336
|
+
const nm = firstControlName(m) ?? nodes[m].name;
|
|
337
|
+
if (nm && nm.trim())
|
|
338
|
+
names.push(nm.trim());
|
|
339
|
+
}
|
|
340
|
+
const trailing = names.length === members.length ? commonTrailingWords(names) : '';
|
|
341
|
+
folds.push({
|
|
342
|
+
sig: sigHash(sig), level: 'abstracted', count: members.length, memberIndices: mi,
|
|
343
|
+
label: trailing || nodes[members[0]].role, unitSize: size[members[0]],
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
foldUnderParent(roots);
|
|
348
|
+
for (let i = 0; i < n; i++)
|
|
349
|
+
if (children[i].length)
|
|
350
|
+
foldUnderParent(children[i]);
|
|
351
|
+
return { folds, foldedIndices };
|
|
352
|
+
}
|
|
353
|
+
/** A state's durable face = tokens repeating across its settled landings (majority k-of-n,
|
|
354
|
+
* 0.6 — tolerates one A/B-noisy visit in three). ONE landing = no variance signal: keep all,
|
|
355
|
+
* mark provisional; analyse surfaces the note as a record-next request. */
|
|
356
|
+
export function templateCore(landingFaces) {
|
|
357
|
+
if (landingFaces.length === 1) {
|
|
358
|
+
return { tokens: new Set(landingFaces[0]), provisional: 'seen once — record another visit to separate structure from data' };
|
|
359
|
+
}
|
|
360
|
+
const need = Math.ceil(landingFaces.length * 0.6);
|
|
361
|
+
const count = new Map();
|
|
362
|
+
for (const f of landingFaces)
|
|
363
|
+
for (const t of f)
|
|
364
|
+
count.set(t, (count.get(t) ?? 0) + 1);
|
|
365
|
+
const tokens = new Set();
|
|
366
|
+
for (const [t, c] of count)
|
|
367
|
+
if (c >= need)
|
|
368
|
+
tokens.add(t);
|
|
369
|
+
return { tokens, provisional: null };
|
|
370
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PatternPack } from './patterns.js';
|
|
2
|
+
export interface UnknownLike {
|
|
3
|
+
kind: string;
|
|
4
|
+
evidence: string;
|
|
5
|
+
context: string;
|
|
6
|
+
extensionPoint: 'overlay-open' | 'value-domain' | 'core-design';
|
|
7
|
+
}
|
|
8
|
+
export interface ProposeResult {
|
|
9
|
+
pack: PatternPack | null;
|
|
10
|
+
lintReasons: string[];
|
|
11
|
+
coreDesignBoundary?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Scaffold a PatternPack from one unknown. Pure — never touches disk. The returned pack's trigger
|
|
14
|
+
* is a deliberate TODO (empty `contains`) so lintPackEntry FAILS on it (loud, expected) until the
|
|
15
|
+
* agent fills in real predicates from the evidence. `core-design` unknowns get no pack at all —
|
|
16
|
+
* the honest boundary: file a core-design issue with the fixture instead (docs/EXTENDING.md). */
|
|
17
|
+
export declare function proposeFromUnknown(unknown: UnknownLike, name: string): ProposeResult;
|
|
18
|
+
/** Parse `--from-unknown <path>#<index>` into its two parts. Throws a plain Error with a usage
|
|
19
|
+
* hint on malformed input (cli.ts turns that into the standard {status:'error'} envelope). */
|
|
20
|
+
export declare function parseFromUnknownArg(raw: string): {
|
|
21
|
+
path: string;
|
|
22
|
+
index: number;
|
|
23
|
+
};
|
|
24
|
+
/** Generate the `gh pr create` command an agent runs once the pack lints clean, re-analyse
|
|
25
|
+
* resolves the unknown, and a grammar fixture test is added. Cites the unknown's evidence in the
|
|
26
|
+
* PR body per the plan ("a generated body citing the evidence"). Prints only — NEVER auto-run. */
|
|
27
|
+
export declare function ghPrCommand(pack: PatternPack, unknown: UnknownLike): string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { lintPackEntry } from './patterns.js';
|
|
2
|
+
// `evidence` is a flat, un-indented list of raw snapshot lines (draft.ts's evidenceOf — readable,
|
|
3
|
+
// not necessarily a valid nesting). Wrapped as a rootless depth-0 fixture: every line becomes a
|
|
4
|
+
// top-level `- <line>` so parseSnapshot accepts it; the agent adjusts nesting/root by hand if the
|
|
5
|
+
// real trigger needs to scope containment (documented in the printed checklist).
|
|
6
|
+
function evidenceToFixtureSnapshot(evidence) {
|
|
7
|
+
return evidence.split('\n').filter((l) => l.trim()).map((l) => `- ${l.trim()}`).join('\n');
|
|
8
|
+
}
|
|
9
|
+
// The context a pack's trigger should evaluate in, inferred from the unknown kind. Only the two
|
|
10
|
+
// pack-scaffoldable extensionPoints reach here (core-design returns before this is called).
|
|
11
|
+
const CONTEXT_BY_EXTENSION_POINT = {
|
|
12
|
+
'overlay-open': 'diff.added',
|
|
13
|
+
'value-domain': 'overlay',
|
|
14
|
+
};
|
|
15
|
+
/** Scaffold a PatternPack from one unknown. Pure — never touches disk. The returned pack's trigger
|
|
16
|
+
* is a deliberate TODO (empty `contains`) so lintPackEntry FAILS on it (loud, expected) until the
|
|
17
|
+
* agent fills in real predicates from the evidence. `core-design` unknowns get no pack at all —
|
|
18
|
+
* the honest boundary: file a core-design issue with the fixture instead (docs/EXTENDING.md). */
|
|
19
|
+
export function proposeFromUnknown(unknown, name) {
|
|
20
|
+
if (unknown.extensionPoint === 'core-design') {
|
|
21
|
+
return {
|
|
22
|
+
pack: null, lintReasons: [],
|
|
23
|
+
coreDesignBoundary: `this unknown (kind: ${unknown.kind}) is NOT expressible as a pattern pack — `
|
|
24
|
+
+ `its extensionPoint is "core-design", meaning no existing pack TYPE (overlay-open | value-domain) can fix it. `
|
|
25
|
+
+ `File a core-design issue upstream with this unknown's evidence + context attached as the reproducing fixture; `
|
|
26
|
+
+ `do not hand-patch the engine.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const type = unknown.extensionPoint;
|
|
30
|
+
const pack = {
|
|
31
|
+
name,
|
|
32
|
+
version: 1,
|
|
33
|
+
type,
|
|
34
|
+
trigger: {
|
|
35
|
+
context: CONTEXT_BY_EXTENSION_POINT[unknown.extensionPoint],
|
|
36
|
+
// TODO(agent): narrow this — e.g. { role: 'generic' } for a role-less div-soup portal/grid.
|
|
37
|
+
contains: [], // TODO(agent): add >=1 { role, min?, attr? } predicate from the evidence below.
|
|
38
|
+
},
|
|
39
|
+
fixture: {
|
|
40
|
+
snapshot: evidenceToFixtureSnapshot(unknown.evidence),
|
|
41
|
+
expect: 'matched',
|
|
42
|
+
},
|
|
43
|
+
evidence: `${unknown.context} (scaffolded by dev pattern-propose from a graph-analyse unknown, kind=${unknown.kind})`,
|
|
44
|
+
};
|
|
45
|
+
return { pack, lintReasons: lintPackEntry(pack) };
|
|
46
|
+
}
|
|
47
|
+
/** Parse `--from-unknown <path>#<index>` into its two parts. Throws a plain Error with a usage
|
|
48
|
+
* hint on malformed input (cli.ts turns that into the standard {status:'error'} envelope). */
|
|
49
|
+
export function parseFromUnknownArg(raw) {
|
|
50
|
+
const hashIdx = raw.lastIndexOf('#');
|
|
51
|
+
if (hashIdx < 0)
|
|
52
|
+
throw new Error(`--from-unknown must be "<analyse-json-path>#<index>" (got ${JSON.stringify(raw)})`);
|
|
53
|
+
const path = raw.slice(0, hashIdx);
|
|
54
|
+
const index = Number(raw.slice(hashIdx + 1));
|
|
55
|
+
if (!path || !Number.isInteger(index) || index < 0)
|
|
56
|
+
throw new Error(`--from-unknown must be "<analyse-json-path>#<index>" with a non-negative integer index (got ${JSON.stringify(raw)})`);
|
|
57
|
+
return { path, index };
|
|
58
|
+
}
|
|
59
|
+
/** Generate the `gh pr create` command an agent runs once the pack lints clean, re-analyse
|
|
60
|
+
* resolves the unknown, and a grammar fixture test is added. Cites the unknown's evidence in the
|
|
61
|
+
* PR body per the plan ("a generated body citing the evidence"). Prints only — NEVER auto-run. */
|
|
62
|
+
export function ghPrCommand(pack, unknown) {
|
|
63
|
+
const title = `pattern pack: ${pack.name}`;
|
|
64
|
+
const body = [
|
|
65
|
+
`## Pattern pack: \`${pack.name}\` (${pack.type})`,
|
|
66
|
+
'',
|
|
67
|
+
`Resolves an extension-loop unknown (kind: \`${unknown.kind}\`) that the core's structural inference could not classify:`,
|
|
68
|
+
'',
|
|
69
|
+
'```',
|
|
70
|
+
unknown.context,
|
|
71
|
+
'```',
|
|
72
|
+
'',
|
|
73
|
+
'### Evidence',
|
|
74
|
+
'```',
|
|
75
|
+
unknown.evidence,
|
|
76
|
+
'```',
|
|
77
|
+
'',
|
|
78
|
+
`Pack file: \`packs/patterns/core/${pack.name}.json\` (moved from \`proposed/\` after review). Lints clean, fixture passes, and a grammar test proves the resolved shape.`,
|
|
79
|
+
].join('\n');
|
|
80
|
+
return `gh pr create --title ${JSON.stringify(title)} --body ${JSON.stringify(body)}`;
|
|
81
|
+
}
|