@hover-dev/core 0.21.0 → 0.23.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/dist/engine.d.ts CHANGED
@@ -15,15 +15,25 @@
15
15
  export { writeSpec } from './specs/writeSpec.js';
16
16
  export type { WriteSpecOptions, WriteSpecResult, Redaction } from './specs/writeSpec.js';
17
17
  export type { SkillStep } from './specs/specStep.js';
18
+ export { reRenderSpec } from './specs/writeSpec.js';
18
19
  export { writeApiSpec } from './specs/writeApiSpec.js';
19
20
  export type { ApiCheck, WriteApiSpecOptions, WriteApiSpecResult } from './specs/writeApiSpec.js';
21
+ export { buildOptimizeBrief, saveOptimizedCandidate, OptimizeError } from './specs/optimizeSpec.js';
22
+ export type { OptimizeResult } from './specs/optimizeSpec.js';
23
+ export { lintWiki, parseRunStatuses } from './specs/lintWiki.js';
24
+ export type { LintResult, LintFinding, LintKind, LintSeverity } from './specs/lintWiki.js';
25
+ export { parseBusinessMap } from './specs/businessMap.js';
26
+ export type { BusinessMapGraph, MapNode, MapEdge } from './specs/businessMap.js';
27
+ export { extractPageObjects, detectExtractableFlows } from './specs/extractPageObjects.js';
28
+ export type { ExtractResult, ExtractedPage } from './specs/extractPageObjects.js';
29
+ export type { SharedFlow } from './specs/detectSharedFlows.js';
20
30
  export { replayGroundedSteps, replayOnPage, applyGroundedStep, groundedLocate } from './specs/replayGrounded.js';
21
31
  export type { ReplayResult, ReplayFailure, ReplayStep, GroundedTarget } from './specs/replayGrounded.js';
22
32
  export { readSidecar } from './specs/sidecar.js';
23
33
  export type { SpecSidecar } from './specs/sidecar.js';
24
34
  export { launchDebugChrome, closeDebugChrome, findChromeBinary } from './playwright/launchChrome.js';
25
35
  export type { LaunchOptions, LaunchResult } from './playwright/launchChrome.js';
26
- export { loadMemory, formatMemoryForPrompt, writeFact, memoryDir } from './memory/businessMemory.js';
36
+ export { loadMemory, formatMemoryForPrompt, formatMemoryIndex, recallMemory, readFact, formatFact, writeFact, memoryDir } from './memory/businessMemory.js';
27
37
  export type { BusinessFact } from './memory/businessMemory.js';
28
38
  export { QA_INTENSITY, DEFAULT_QA_INTENSITY, asQaIntensity, qaBudgetDirective } from './qa/intensity.js';
29
39
  export type { QaIntensity, QaIntensitySpec } from './qa/intensity.js';
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACzF,YAAY,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACvD,YAAY,EAAE,QAAQ,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAEjG,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACjH,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAEzG,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGtD,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrG,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAGhF,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACrG,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG/D,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACzG,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACzF,YAAY,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEpD,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACvD,YAAY,EAAE,QAAQ,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAGjG,OAAO,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACpG,YAAY,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE9D,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACjE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAC3F,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,gBAAgB,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AAEjF,OAAO,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AAC3F,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAClF,YAAY,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAE/D,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACjH,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAEzG,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGtD,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrG,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAKhF,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAC5J,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG/D,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACzG,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/engine.js CHANGED
@@ -14,8 +14,17 @@
14
14
  */
15
15
  // ── crystallization + grounded replay ────────────────────────────────────────
16
16
  export { writeSpec } from './specs/writeSpec.js';
17
+ export { reRenderSpec } from './specs/writeSpec.js';
17
18
  // API-layer crystallizer — observed/replayed requests → *.api-test.spec.ts.
18
19
  export { writeApiSpec } from './specs/writeApiSpec.js';
20
+ // Optimize (F7) — build the improvement brief for the user's own agent, then
21
+ // file its result as a reviewed candidate. No Hover-owned model runs.
22
+ export { buildOptimizeBrief, saveOptimizedCandidate, OptimizeError } from './specs/optimizeSpec.js';
23
+ // LLM-Wiki P1 Lint — deterministic health check over .hover/ (map vs specs vs runs).
24
+ export { lintWiki, parseRunStatuses } from './specs/lintWiki.js';
25
+ export { parseBusinessMap } from './specs/businessMap.js';
26
+ // Page-Object extraction — lift NON-login shared flows into pages/ + fixtures.
27
+ export { extractPageObjects, detectExtractableFlows } from './specs/extractPageObjects.js';
19
28
  // Creation-verification + self-heal: replay a flow's grounded steps over CDP (no playwright test).
20
29
  export { replayGroundedSteps, replayOnPage, applyGroundedStep, groundedLocate } from './specs/replayGrounded.js';
21
30
  // Spec sidecar (recorded grounded steps) — read by self-heal to replay a saved spec.
@@ -23,6 +32,8 @@ export { readSidecar } from './specs/sidecar.js';
23
32
  // ── debug-Chrome lifecycle ───────────────────────────────────────────────────
24
33
  export { launchDebugChrome, closeDebugChrome, findChromeBinary } from './playwright/launchChrome.js';
25
34
  // ── business memory (ask → remember loop) ────────────────────────────────────
26
- export { loadMemory, formatMemoryForPrompt, writeFact, memoryDir } from './memory/businessMemory.js';
35
+ // recallMemory = progressive disclosure (full when small, index when large);
36
+ // readFact = the on-demand single-rule fetch behind recall_fact.
37
+ export { loadMemory, formatMemoryForPrompt, formatMemoryIndex, recallMemory, readFact, formatFact, writeFact, memoryDir } from './memory/businessMemory.js';
27
38
  // ── QA intensity (step budget; parked until wired into the workflow) ──────────
28
39
  export { QA_INTENSITY, DEFAULT_QA_INTENSITY, asQaIntensity, qaBudgetDirective } from './qa/intensity.js';
@@ -18,6 +18,26 @@ export declare function loadMemory(devRoot: string): Promise<BusinessFact[]>;
18
18
  /** Format loaded facts as a system-prompt block, or '' when there are none (so
19
19
  * the caller appends nothing). Grouped nothing-fancy: one bullet per fact. */
20
20
  export declare function formatMemoryForPrompt(facts: BusinessFact[]): string;
21
+ /** Above this many chars of formatted-full memory, recall returns the INDEX
22
+ * (title — description per rule) instead of every rule's body, and the agent
23
+ * pulls a specific rule with `recall_fact` on demand — Claude-Code-style
24
+ * progressive disclosure. Below it, inlining everything is cheaper than making
25
+ * the agent round-trip for five rules, so recall stays full. */
26
+ export declare const RECALL_INLINE_BUDGET = 2000;
27
+ /** The INDEX block: one `title — description (type)` line per rule, no bodies.
28
+ * This is the always-cheap tier; a rule's body is fetched by `readFact`. */
29
+ export declare function formatMemoryIndex(facts: BusinessFact[]): string;
30
+ /** Recall memory with progressive disclosure: full bodies when the set is small
31
+ * (≤ RECALL_INLINE_BUDGET chars formatted), the index alone when it's large.
32
+ * '' when there are no facts. This is what `recall_business_knowledge` returns. */
33
+ export declare function recallMemory(devRoot: string): Promise<string>;
34
+ /** Format one fact's FULL text (body verbatim, not whitespace-collapsed) for an
35
+ * on-demand `recall_fact`. */
36
+ export declare function formatFact(fact: BusinessFact): string;
37
+ /** Load ONE fact by name/slug for on-demand recall. Match order: exact slug →
38
+ * slugified-name equality → prefix → substring. Returns null if nothing matches
39
+ * (or the memory dir is empty). Total: never throws. */
40
+ export declare function readFact(devRoot: string, name: string): Promise<BusinessFact | null>;
21
41
  /** Write (or overwrite) a fact file + refresh the MEMORY.md index line. NEVER
22
42
  * throws — returns the path or an error string for the caller to log. Business
23
43
  * RULES only; the caller must never pass secrets / PII / credentials. */
@@ -1 +1 @@
1
- {"version":3,"file":"businessMemory.d.ts","sourceRoot":"","sources":["../../src/memory/businessMemory.ts"],"names":[],"mappings":"AAwBA,wDAAwD;AACxD,MAAM,WAAW,YAAY;IAC3B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,IAAI,EAAE,eAAe,GAAG,mBAAmB,GAAG,YAAY,GAAG,eAAe,CAAC;IAC7E,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,oDAAoD;AACpD,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAMzC;AAsBD;6EAC6E;AAC7E,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAkBzE;AAED;+EAC+E;AAC/E,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,MAAM,CAQnE;AAED;;0EAE0E;AAC1E,wBAAsB,SAAS,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,YAAY,GACjB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAc/C"}
1
+ {"version":3,"file":"businessMemory.d.ts","sourceRoot":"","sources":["../../src/memory/businessMemory.ts"],"names":[],"mappings":"AAwBA,wDAAwD;AACxD,MAAM,WAAW,YAAY;IAC3B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,IAAI,EAAE,eAAe,GAAG,mBAAmB,GAAG,YAAY,GAAG,eAAe,CAAC;IAC7E,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,oDAAoD;AACpD,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAMzC;AAsBD;6EAC6E;AAC7E,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAkBzE;AAED;+EAC+E;AAC/E,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,MAAM,CAQnE;AAED;;;;iEAIiE;AACjE,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC;6EAC6E;AAC7E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,MAAM,CAY/D;AAED;;oFAEoF;AACpF,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAKnE;AAED;+BAC+B;AAC/B,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,CAErD;AAED;;yDAEyD;AACzD,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAW1F;AAED;;0EAE0E;AAC1E,wBAAsB,SAAS,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,YAAY,GACjB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAc/C"}
@@ -87,6 +87,53 @@ export function formatMemoryForPrompt(facts) {
87
87
  'ground truth; do NOT re-ask what these already answer):\n' +
88
88
  lines.join('\n'));
89
89
  }
90
+ /** Above this many chars of formatted-full memory, recall returns the INDEX
91
+ * (title — description per rule) instead of every rule's body, and the agent
92
+ * pulls a specific rule with `recall_fact` on demand — Claude-Code-style
93
+ * progressive disclosure. Below it, inlining everything is cheaper than making
94
+ * the agent round-trip for five rules, so recall stays full. */
95
+ export const RECALL_INLINE_BUDGET = 2000;
96
+ /** The INDEX block: one `title — description (type)` line per rule, no bodies.
97
+ * This is the always-cheap tier; a rule's body is fetched by `readFact`. */
98
+ export function formatMemoryIndex(facts) {
99
+ if (!facts.length)
100
+ return '';
101
+ const lines = facts.map((f) => `- ${f.name}${f.description ? ` — ${f.description}` : ''} (${f.type})`);
102
+ return (`KNOWN BUSINESS KNOWLEDGE FOR THIS APP — ${facts.length} rules learned from earlier ` +
103
+ `runs (treat as ground truth; do NOT re-ask what these answer). This is the INDEX; ` +
104
+ `call recall_fact("<name>") to read a rule's full text when it's relevant to what ` +
105
+ `you're testing:\n` +
106
+ lines.join('\n'));
107
+ }
108
+ /** Recall memory with progressive disclosure: full bodies when the set is small
109
+ * (≤ RECALL_INLINE_BUDGET chars formatted), the index alone when it's large.
110
+ * '' when there are no facts. This is what `recall_business_knowledge` returns. */
111
+ export async function recallMemory(devRoot) {
112
+ const facts = await loadMemory(devRoot);
113
+ if (!facts.length)
114
+ return '';
115
+ const full = formatMemoryForPrompt(facts);
116
+ return full.length <= RECALL_INLINE_BUDGET ? full : formatMemoryIndex(facts);
117
+ }
118
+ /** Format one fact's FULL text (body verbatim, not whitespace-collapsed) for an
119
+ * on-demand `recall_fact`. */
120
+ export function formatFact(fact) {
121
+ return `${fact.name}${fact.description ? ` — ${fact.description}` : ''} (${fact.type}):\n${fact.body.trim()}`;
122
+ }
123
+ /** Load ONE fact by name/slug for on-demand recall. Match order: exact slug →
124
+ * slugified-name equality → prefix → substring. Returns null if nothing matches
125
+ * (or the memory dir is empty). Total: never throws. */
126
+ export async function readFact(devRoot, name) {
127
+ const facts = await loadMemory(devRoot);
128
+ if (!facts.length)
129
+ return null;
130
+ const q = slugify(name);
131
+ return (facts.find((f) => f.name === q) ??
132
+ facts.find((f) => slugify(f.name) === q) ??
133
+ facts.find((f) => f.name.startsWith(q) || slugify(f.name).startsWith(q)) ??
134
+ facts.find((f) => f.name.includes(q) || slugify(f.name).includes(q)) ??
135
+ null);
136
+ }
90
137
  /** Write (or overwrite) a fact file + refresh the MEMORY.md index line. NEVER
91
138
  * throws — returns the path or an error string for the caller to log. Business
92
139
  * RULES only; the caller must never pass secrets / PII / credentials. */
@@ -0,0 +1,26 @@
1
+ export type MapNodeKind = 'app' | 'area' | 'line' | 'spec';
2
+ export type CoverageStatus = 'covered' | 'uncovered';
3
+ export interface MapNode {
4
+ id: string;
5
+ label: string;
6
+ kind: MapNodeKind;
7
+ status?: CoverageStatus;
8
+ route?: string;
9
+ spec?: string;
10
+ }
11
+ export interface MapEdge {
12
+ source: string;
13
+ target: string;
14
+ }
15
+ export interface BusinessMapGraph {
16
+ app: string;
17
+ nodes: MapNode[];
18
+ edges: MapEdge[];
19
+ stats: {
20
+ lines: number;
21
+ covered: number;
22
+ areas: number;
23
+ };
24
+ }
25
+ export declare function parseBusinessMap(md: string, fallbackApp?: string): BusinessMapGraph;
26
+ //# sourceMappingURL=businessMap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"businessMap.d.ts","sourceRoot":"","sources":["../../src/specs/businessMap.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,WAAW,CAAC;AAErD,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,WAAW,CAAC;IAClB,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AACD,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1D;AA4BD,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,SAAQ,GAAG,gBAAgB,CAuDlF"}
@@ -0,0 +1,87 @@
1
+ /*
2
+ * Canonical parser for the `.hover/hover-map.md` business map the agent
3
+ * maintains — the overview page of the app's living test wiki. Turns the
4
+ * markdown checklist into a graph model (app → area → business line → spec,
5
+ * with coverage).
6
+ *
7
+ * This is the unit-tested source of truth. The cockpit keeps an in-extension
8
+ * copy (packages/vscode-ext/src/businessMap.ts) on purpose — a read-only view
9
+ * must not depend on the engine — so keep the two in sync if the format changes.
10
+ */
11
+ function slug(s) {
12
+ return (s
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/^-+|-+$/g, '') || 'x');
16
+ }
17
+ const SPEC_RE = /\.spec\.tsx?$/;
18
+ function splitItem(rest) {
19
+ const parts = rest
20
+ .split(/\s+[—–-]\s+/)
21
+ .map((p) => p.trim())
22
+ .filter(Boolean);
23
+ const name = parts.shift() ?? rest.trim();
24
+ let route;
25
+ let spec;
26
+ for (const p of parts) {
27
+ if (SPEC_RE.test(p))
28
+ spec = p;
29
+ else if (p.startsWith('/'))
30
+ route = p;
31
+ }
32
+ return { name, route, spec };
33
+ }
34
+ export function parseBusinessMap(md, fallbackApp = 'app') {
35
+ const nodes = [];
36
+ const edges = [];
37
+ const seen = new Set();
38
+ const add = (n) => {
39
+ if (seen.has(n.id))
40
+ return;
41
+ seen.add(n.id);
42
+ nodes.push(n);
43
+ };
44
+ let app = fallbackApp;
45
+ const title = md.match(/^#\s+(.+)$/m);
46
+ if (title) {
47
+ const t = title[1].trim();
48
+ const m = t.match(/business\s*map\s*[—–-]\s*(.+)$/i);
49
+ app = (m ? m[1] : t).trim() || fallbackApp;
50
+ }
51
+ add({ id: 'app', label: app, kind: 'app' });
52
+ let area = null;
53
+ let covered = 0;
54
+ let lineCount = 0;
55
+ let areaCount = 0;
56
+ for (const raw of md.split('\n')) {
57
+ const line = raw.trimEnd();
58
+ const areaM = line.match(/^##\s+(.+)$/);
59
+ if (areaM) {
60
+ const label = areaM[1].trim();
61
+ const id = `area:${slug(label)}`;
62
+ area = { id };
63
+ add({ id, label, kind: 'area' });
64
+ edges.push({ source: 'app', target: id });
65
+ areaCount++;
66
+ continue;
67
+ }
68
+ const itemM = line.match(/^\s*-\s*\[([ xX])\]\s+(.+)$/);
69
+ if (itemM) {
70
+ const status = itemM[1].toLowerCase() === 'x' ? 'covered' : 'uncovered';
71
+ const { name, route, spec } = splitItem(itemM[2]);
72
+ const parentId = area?.id ?? 'app';
73
+ const lineId = `line:${slug(area ? area.id.slice(5) : 'top')}/${slug(name)}`;
74
+ add({ id: lineId, label: name, kind: 'line', status, route, spec });
75
+ edges.push({ source: parentId, target: lineId });
76
+ lineCount++;
77
+ if (status === 'covered')
78
+ covered++;
79
+ if (spec) {
80
+ const specId = `spec:${spec}`;
81
+ add({ id: specId, label: spec, kind: 'spec', spec });
82
+ edges.push({ source: lineId, target: specId });
83
+ }
84
+ }
85
+ }
86
+ return { app, nodes, edges, stats: { lines: lineCount, covered, areas: areaCount } };
87
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"detectSharedFlows.d.ts","sourceRoot":"","sources":["../../src/specs/detectSharedFlows.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,MAAM,WAAW,UAAU;IACzB,uDAAuD;IACvD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB;2CACuC;IACvC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yDAAyD;IACzD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;wDACoD;IACpD,WAAW,EAAE,SAAS,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B;2EACuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;sDACkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAgC5E;AAwDD;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,UAAU,EAAE,CAAC,CAiCvB"}
1
+ {"version":3,"file":"detectSharedFlows.d.ts","sourceRoot":"","sources":["../../src/specs/detectSharedFlows.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,MAAM,WAAW,UAAU;IACzB,uDAAuD;IACvD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB;2CACuC;IACvC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yDAAyD;IACzD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;wDACoD;IACpD,WAAW,EAAE,SAAS,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B;2EACuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;sDACkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CA2C5E;AA+DD;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,UAAU,EAAE,CAAC,CAiCvB"}
@@ -55,11 +55,28 @@ export function stepSignature(tool, rawInput) {
55
55
  }
56
56
  case 'browser_press_key':
57
57
  return `press:${String(i.key ?? '')}`;
58
+ // Grounded control tools (MCP-first): the target is role+name/testId/text on
59
+ // the input. The typed/selected value is data; only the target is structure.
60
+ case 'click_control':
61
+ return `click:${normGround(i)}`;
62
+ case 'fill_control':
63
+ return `type:${normGround(i)}`;
64
+ case 'select_control':
65
+ return `select:${normGround(i)}`;
66
+ case 'check_control':
67
+ return `check:${normGround(i)}`;
58
68
  default:
59
- // Diagnostics / browser_tabs / browser_wait_for — not flow structure.
69
+ // Diagnostics / assert_visible / browser_tabs / browser_wait_for — not
70
+ // flow structure (an assertion isn't part of a reusable action prefix).
60
71
  return null;
61
72
  }
62
73
  }
74
+ /** Normalize a GROUNDED target ({ role, name, testId, text }) to a stable
75
+ * signature fragment — the *_control sibling of normElement. */
76
+ function normGround(i) {
77
+ const parts = [i.role, i.name, i.testId, i.text].filter((v) => typeof v === 'string' && v.length > 0);
78
+ return normElement(parts.join('|'));
79
+ }
63
80
  /** Read and parse every sidecar under `.hover/sidecars/`, unioned with any
64
81
  * still in the legacy `__vibe_tests__/.hover/` home (current home wins on a
65
82
  * slug collision). Malformed files are skipped (better to detect across the
@@ -0,0 +1,22 @@
1
+ import { type SharedFlow } from './detectSharedFlows.js';
2
+ export interface ExtractedPage {
3
+ className: string;
4
+ methodName: string;
5
+ fileName: string;
6
+ path: string;
7
+ /** Slugs of the specs that share this flow. */
8
+ specs: string[];
9
+ }
10
+ export interface ExtractResult {
11
+ pages: ExtractedPage[];
12
+ fixturesPath: string | null;
13
+ /** Slugs re-rendered to consume the new Page Objects. */
14
+ folded: string[];
15
+ }
16
+ /** Detect shared flows worth extracting — NON-login prefixes shared by >= minSpecs
17
+ * specs. Read-only: the agent uses this to decide what to ASK the user about. */
18
+ export declare function detectExtractableFlows(devRoot: string, minSpecs?: number): Promise<SharedFlow[]>;
19
+ export declare function extractPageObjects(devRoot: string, opts?: {
20
+ minSpecs?: number;
21
+ }): Promise<ExtractResult>;
22
+ //# sourceMappingURL=extractPageObjects.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extractPageObjects.d.ts","sourceRoot":"","sources":["../../src/specs/extractPageObjects.ts"],"names":[],"mappings":"AAeA,OAAO,EAAqB,KAAK,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAK5E,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,yDAAyD;IACzD,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAcD;kFACkF;AAClF,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAGjG;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+CxB"}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Page Object extraction (F4) — lift flows shared across saved specs into
3
+ * `pages/<Name>.ts` + a `fixtures.ts` entry point, then FOLD the specs that use
4
+ * them (they consume `await xPage.x()` from `./fixtures` instead of repeating
5
+ * the steps). Fully deterministic — no LLM.
6
+ *
7
+ * MCP-first trigger: after `test_app` crystallizes the suite, the agent calls
8
+ * `detect_shared_flows`, ASKS the user, and on yes calls this. LOGIN prefixes
9
+ * are EXCLUDED — auth-fixture already lifts login into auth.setup.ts, and
10
+ * folding it here would double it. So this only extracts NON-login shared
11
+ * entry flows (the case that scales on large suites).
12
+ */
13
+ import { mkdir, writeFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { detectSharedFlows } from './detectSharedFlows.js';
16
+ import { generatePageObject } from './generatePageObject.js';
17
+ import { writePageObjectManifest } from './pageObjectManifest.js';
18
+ import { reRenderSpec } from './writeSpec.js';
19
+ /** A shared prefix is a LOGIN (auth-fixture's job, not a POM) when it types a
20
+ * credential — the sidecar's fill value is a `process.env.<X>` ref. */
21
+ function isLoginPrefix(steps) {
22
+ return steps.some((s) => {
23
+ if (s.kind !== 'step')
24
+ return false;
25
+ const tool = (s.tool ?? '').replace(/^mcp__[a-z0-9_-]+?__/, '');
26
+ const input = (s.input ?? {});
27
+ const v = tool === 'browser_type' ? input.text : tool === 'fill_control' ? input.value : null;
28
+ return typeof v === 'string' && /process\.env\./.test(v);
29
+ });
30
+ }
31
+ /** Detect shared flows worth extracting — NON-login prefixes shared by >= minSpecs
32
+ * specs. Read-only: the agent uses this to decide what to ASK the user about. */
33
+ export async function detectExtractableFlows(devRoot, minSpecs = 2) {
34
+ const flows = await detectSharedFlows(devRoot, { minSpecs });
35
+ return flows.filter((f) => !isLoginPrefix(f.prefixSteps));
36
+ }
37
+ export async function extractPageObjects(devRoot, opts = {}) {
38
+ const flows = await detectExtractableFlows(devRoot, opts.minSpecs ?? 2);
39
+ if (flows.length === 0)
40
+ return { pages: [], fixturesPath: null, folded: [] };
41
+ const testsDir = join(devRoot, '__vibe_tests__');
42
+ const pagesDir = join(testsDir, 'pages');
43
+ await mkdir(pagesDir, { recursive: true });
44
+ const pages = [];
45
+ const entries = [];
46
+ const usedNames = new Set();
47
+ const affected = new Set();
48
+ for (const flow of flows) {
49
+ const probe = generatePageObject(flow.prefixSteps);
50
+ const className = uniqueName(probe.className, usedNames);
51
+ const po = className === probe.className ? probe : generatePageObject(flow.prefixSteps, { className });
52
+ const path = join(pagesDir, po.fileName);
53
+ await writeFile(path, po.source, 'utf-8');
54
+ pages.push({ className: po.className, methodName: po.methodName, fileName: po.fileName, path, specs: flow.specs });
55
+ entries.push({
56
+ className: po.className,
57
+ methodName: po.methodName,
58
+ fixtureName: fixtureName(po.className),
59
+ fileName: po.fileName,
60
+ signatures: flow.signatures,
61
+ specs: flow.specs,
62
+ });
63
+ flow.specs.forEach((s) => affected.add(s));
64
+ }
65
+ const fixturesPath = join(testsDir, 'fixtures.ts');
66
+ await writeFile(fixturesPath, renderFixtures(pages), 'utf-8');
67
+ // The manifest lets writeSpec's matchPageObject fold a matching prefix.
68
+ await writePageObjectManifest(devRoot, entries);
69
+ // Fold NOW: faithfully re-render each affected spec so it consumes the Page
70
+ // Object (via reRenderSpec, which re-applies auth-fixture/base-url from the
71
+ // sidecar — never regressing them). Non-login prefixes → no auth conflict.
72
+ const folded = [];
73
+ for (const slug of affected) {
74
+ try {
75
+ if (await reRenderSpec(devRoot, slug))
76
+ folded.push(slug);
77
+ }
78
+ catch {
79
+ /* a fold is best-effort — the page/fixture still exist for future specs */
80
+ }
81
+ }
82
+ return { pages, fixturesPath, folded };
83
+ }
84
+ function renderFixtures(pages) {
85
+ const lines = [
86
+ `import { test as base } from '@playwright/test';`,
87
+ ...pages.map((p) => `import { ${p.className} } from './pages/${p.className}';`),
88
+ ``,
89
+ `/**`,
90
+ ` * Generated by Hover — Page Object fixtures lifted from flows shared across`,
91
+ ` * specs. In a spec: \`import { test, expect } from './fixtures';\` then consume`,
92
+ ` * e.g. \`async ({ page, ${pages[0] ? fixtureName(pages[0].className) : 'somePage'} }) => …\`.`,
93
+ ` */`,
94
+ `export const test = base.extend<{ ${pages.map((p) => `${fixtureName(p.className)}: ${p.className}`).join('; ')} }>({`,
95
+ ...pages.flatMap((p) => [
96
+ ` ${fixtureName(p.className)}: async ({ page }, use) => {`,
97
+ ` await use(new ${p.className}(page));`,
98
+ ` },`,
99
+ ]),
100
+ `});`,
101
+ ``,
102
+ `export { expect } from '@playwright/test';`,
103
+ ``,
104
+ ];
105
+ return lines.join('\n');
106
+ }
107
+ /** LoginPage -> loginPage. */
108
+ function fixtureName(className) {
109
+ return className.charAt(0).toLowerCase() + className.slice(1);
110
+ }
111
+ function uniqueName(base, used) {
112
+ let name = base;
113
+ let n = 2;
114
+ while (used.has(name))
115
+ name = `${base}${n++}`;
116
+ used.add(name);
117
+ return name;
118
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"generatePageObject.d.ts","sourceRoot":"","sources":["../../src/specs/generatePageObject.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAUtD,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,mCAAmC;IACnC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,SAAS,EAAE,EAClB,QAAQ,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACzD,gBAAgB,CAoElB"}
1
+ {"version":3,"file":"generatePageObject.d.ts","sourceRoot":"","sources":["../../src/specs/generatePageObject.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAWtD,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,mCAAmC;IACnC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,SAAS,EAAE,EAClB,QAAQ,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACzD,gBAAgB,CA0FlB"}
@@ -1,4 +1,4 @@
1
- import { selectorFromDescription, selectorForFormField, emitInteraction, blockScope, } from './writeSpec.js';
1
+ import { selectorFromDescription, selectorForFormField, emitInteraction, blockScope, groundedSelector, } from './writeSpec.js';
2
2
  const PAGE_VAR = 'this.page';
3
3
  export function generatePageObject(steps, override = {}) {
4
4
  const derived = deriveNames(steps);
@@ -49,8 +49,29 @@ export function generatePageObject(steps, override = {}) {
49
49
  case 'browser_press_key':
50
50
  body.push(`await ${PAGE_VAR}.keyboard.press(${JSON.stringify(String(i.key ?? ''))});`);
51
51
  break;
52
+ // Grounded control tools (MCP-first): target is role+name/testId/text on
53
+ // the input; the typed/selected value is data → a method parameter (D4).
54
+ case 'click_control':
55
+ body.push(...blockScope(emitInteraction(groundedSelector(i, PAGE_VAR), 'click()')));
56
+ break;
57
+ case 'fill_control': {
58
+ const p = uniqueParam(paramName(groundLabel(i)), used);
59
+ params.push(p);
60
+ body.push(...blockScope(emitInteraction(groundedSelector(i, PAGE_VAR), `fill(${p})`)));
61
+ break;
62
+ }
63
+ case 'select_control': {
64
+ const withRole = i.role ? i : { ...i, role: i.name ? 'combobox' : undefined };
65
+ const p = uniqueParam(paramName(groundLabel(i)), used);
66
+ params.push(p);
67
+ body.push(...blockScope(emitInteraction(groundedSelector(withRole, PAGE_VAR), `selectOption(${p})`)));
68
+ break;
69
+ }
70
+ case 'check_control':
71
+ body.push(...blockScope(emitInteraction(groundedSelector(i, PAGE_VAR), i.checked === false ? 'uncheck()' : 'check()')));
72
+ break;
52
73
  default:
53
- break; // diagnostics / non-replayable — skipped
74
+ break; // diagnostics / assert_visible / non-replayable — skipped
54
75
  }
55
76
  }
56
77
  const paramList = params.map(p => `${p}: string`).join(', ');
@@ -127,6 +148,10 @@ function pascal(raw) {
127
148
  }
128
149
  /** Parameter name derived from a field/element label. */
129
150
  const paramName = camel;
151
+ /** A grounded target's label (name → testId → text) for naming its data param. */
152
+ function groundLabel(i) {
153
+ return String(i.name ?? i.testId ?? i.text ?? 'value');
154
+ }
130
155
  function uniqueParam(base, used) {
131
156
  const root = base || 'value';
132
157
  let name = root;
@@ -0,0 +1,33 @@
1
+ export type LintSeverity = 'error' | 'warn' | 'info';
2
+ export type LintKind = 'deleted-spec' | 'regressed-coverage' | 'orphan-spec';
3
+ export interface LintFinding {
4
+ kind: LintKind;
5
+ severity: LintSeverity;
6
+ /** One-line human-readable finding. */
7
+ message: string;
8
+ /** The business line involved (map label), if any. */
9
+ line?: string;
10
+ /** The spec basename involved, if any. */
11
+ spec?: string;
12
+ /** A suggested next action for the agent (e.g. heal, map, remove the ref). */
13
+ fix?: string;
14
+ }
15
+ export interface LintResult {
16
+ /** No error/warn findings (info-only still counts as ok). */
17
+ ok: boolean;
18
+ hasMap: boolean;
19
+ findings: LintFinding[];
20
+ summary: {
21
+ areas: number;
22
+ lines: number;
23
+ covered: number;
24
+ specs: number;
25
+ };
26
+ }
27
+ type RunStatus = 'pass' | 'fail' | 'flaky';
28
+ /** Parse a Playwright JSON report into { specBasename → worst status }. Mirrors
29
+ * the cockpit's parser; an unexpected shape yields no entries. */
30
+ export declare function parseRunStatuses(json: unknown): Record<string, RunStatus>;
31
+ export declare function lintWiki(devRoot: string): Promise<LintResult>;
32
+ export {};
33
+ //# sourceMappingURL=lintWiki.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lintWiki.d.ts","sourceRoot":"","sources":["../../src/specs/lintWiki.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;AACrD,MAAM,MAAM,QAAQ,GAAG,cAAc,GAAG,oBAAoB,GAAG,aAAa,CAAC;AAE7E,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,YAAY,CAAC;IACvB,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,UAAU;IACzB,6DAA6D;IAC7D,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3E;AAED,KAAK,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAG3C;mEACmE;AACnE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAmBzE;AAgDD,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAyEnE"}
@@ -0,0 +1,163 @@
1
+ /*
2
+ * LLM-Wiki P1 — Lint: a deterministic health check over `.hover/` (the app's
3
+ * living test wiki). It cross-checks the business map against the real spec
4
+ * files and the run ledger and reports drift:
5
+ *
6
+ * - deleted-spec a covered line points at a *.spec.ts that no longer exists
7
+ * - regressed-coverage a covered line's spec last ran fail/flaky (→ candidate for heal)
8
+ * - orphan-spec a *.spec.ts exists but no line references it (a gap in the map)
9
+ *
10
+ * These are the CHEAP, mechanical checks — no LLM, no network. The LLM-judged
11
+ * half (contradictory memory rules, routes in code missing from the map) is the
12
+ * agent's job, driven by the `/mcp__hover__lint` prompt on top of this result.
13
+ *
14
+ * Pure-ish: reads the FS, never writes; a missing map / bad JSON degrades to a
15
+ * partial (or empty) result, never throws.
16
+ */
17
+ import { readFile, readdir } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+ import { parseBusinessMap } from './businessMap.js';
20
+ const RUN_RANK = { pass: 0, flaky: 1, fail: 2 };
21
+ /** Parse a Playwright JSON report into { specBasename → worst status }. Mirrors
22
+ * the cockpit's parser; an unexpected shape yields no entries. */
23
+ export function parseRunStatuses(json) {
24
+ const out = {};
25
+ const worse = (a, b) => !a ? b : RUN_RANK[b] > RUN_RANK[a] ? b : a;
26
+ const visit = (suite, inherited) => {
27
+ const file = suite.file ?? inherited;
28
+ for (const raw of suite.specs ?? []) {
29
+ const spec = raw;
30
+ const key = (file ?? spec.file ?? 'unknown').split(/[\\/]/).pop() ?? 'unknown';
31
+ let status = spec.ok ? 'pass' : 'fail';
32
+ if (spec.ok && (spec.tests ?? []).some((t) => t.status === 'flaky'))
33
+ status = 'flaky';
34
+ out[key] = worse(out[key], status);
35
+ }
36
+ for (const child of suite.suites ?? [])
37
+ visit(child, file);
38
+ };
39
+ if (json && typeof json === 'object') {
40
+ for (const s of json.suites ?? [])
41
+ visit(s);
42
+ }
43
+ return out;
44
+ }
45
+ /** Recursively collect *.spec.ts basenames under __vibe_tests__/. */
46
+ async function collectSpecs(dir) {
47
+ const out = [];
48
+ const walk = async (d) => {
49
+ let entries;
50
+ try {
51
+ entries = await readdir(d, { withFileTypes: true });
52
+ }
53
+ catch {
54
+ return; // no __vibe_tests__ yet
55
+ }
56
+ for (const e of entries) {
57
+ const p = join(d, e.name);
58
+ if (e.isDirectory()) {
59
+ if (e.name === 'pages' || e.name === '.hover' || e.name === 'node_modules')
60
+ continue;
61
+ await walk(p);
62
+ }
63
+ else if (/\.spec\.tsx?$/.test(e.name)) {
64
+ out.push(e.name);
65
+ }
66
+ }
67
+ };
68
+ await walk(dir);
69
+ return out;
70
+ }
71
+ /** Latest run status per spec basename, merged newest-wins across `.hover/runs/*.json`
72
+ * (filename is the ISO stamp → lexical sort is chronological). */
73
+ async function latestRunStatuses(runsDir) {
74
+ let files;
75
+ try {
76
+ files = (await readdir(runsDir)).filter((f) => f.endsWith('.json')).sort();
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ const merged = {};
82
+ for (const f of files) {
83
+ // later files overwrite earlier → the latest run wins per spec
84
+ try {
85
+ const json = JSON.parse(await readFile(join(runsDir, f), 'utf-8'));
86
+ Object.assign(merged, parseRunStatuses(json));
87
+ }
88
+ catch {
89
+ /* skip a bad run file */
90
+ }
91
+ }
92
+ return merged;
93
+ }
94
+ export async function lintWiki(devRoot) {
95
+ const hoverDir = join(devRoot, '.hover');
96
+ const vibeDir = join(devRoot, '__vibe_tests__');
97
+ let md = '';
98
+ try {
99
+ md = await readFile(join(hoverDir, 'hover-map.md'), 'utf-8');
100
+ }
101
+ catch {
102
+ /* no map yet */
103
+ }
104
+ const hasMap = md.trim().length > 0;
105
+ const graph = parseBusinessMap(md);
106
+ const specFiles = await collectSpecs(vibeDir);
107
+ const specSet = new Set(specFiles);
108
+ const runs = await latestRunStatuses(join(hoverDir, 'runs'));
109
+ const findings = [];
110
+ const lines = graph.nodes.filter((n) => n.kind === 'line');
111
+ const referenced = new Set();
112
+ for (const line of lines) {
113
+ if (line.spec) {
114
+ referenced.add(line.spec);
115
+ // deleted-spec: a line points at a spec file that isn't on disk.
116
+ if (!specSet.has(line.spec)) {
117
+ findings.push({
118
+ kind: 'deleted-spec',
119
+ severity: 'error',
120
+ line: line.label,
121
+ spec: line.spec,
122
+ message: `"${line.label}" points at ${line.spec}, which no longer exists in __vibe_tests__/.`,
123
+ fix: `Re-crystallize the flow, or drop the stale spec reference from .hover/hover-map.md.`,
124
+ });
125
+ continue; // no point checking its run status
126
+ }
127
+ // regressed-coverage: a covered line whose spec last ran fail/flaky.
128
+ const run = runs[line.spec];
129
+ if (line.status === 'covered' && (run === 'fail' || run === 'flaky')) {
130
+ findings.push({
131
+ kind: 'regressed-coverage',
132
+ severity: 'warn',
133
+ line: line.label,
134
+ spec: line.spec,
135
+ message: `"${line.label}" is marked covered but ${line.spec} last ran ${run}.`,
136
+ fix: `Heal it: /mcp__hover__heal ${line.spec.replace(/\.spec\.tsx?$/, '')}`,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ // orphan-spec: a UI spec on disk that no business line references (a map gap).
142
+ // API specs (*.api-test.spec.ts) are siblings of a line, not lines → skip.
143
+ for (const spec of specFiles) {
144
+ if (spec.endsWith('.api-test.spec.ts'))
145
+ continue;
146
+ if (!referenced.has(spec)) {
147
+ findings.push({
148
+ kind: 'orphan-spec',
149
+ severity: 'info',
150
+ spec,
151
+ message: `${spec} exists but no line in the business map references it.`,
152
+ fix: `Add its business line to .hover/hover-map.md (mark it [x] with the spec).`,
153
+ });
154
+ }
155
+ }
156
+ const ok = !findings.some((f) => f.severity === 'error' || f.severity === 'warn');
157
+ return {
158
+ ok,
159
+ hasMap,
160
+ findings,
161
+ summary: { areas: graph.stats.areas, lines: graph.stats.lines, covered: graph.stats.covered, specs: specFiles.length },
162
+ };
163
+ }
@@ -29,12 +29,37 @@ export interface OptimizeResult {
29
29
  original: string;
30
30
  }
31
31
  export declare function optimizeSpec(devRoot: string, slug: string, runCodegen: RunCodegen): Promise<OptimizeResult>;
32
+ /**
33
+ * MCP-first optimize (F7) without a Hover-owned model: build the improvement
34
+ * brief for a spec, hand it to the USER's own agent (which IS the intelligence),
35
+ * and let it write the improved file back through `saveOptimizedCandidate`.
36
+ *
37
+ * Returns the prompt the agent works from (the same improvement rules the
38
+ * legacy in-engine `optimizeSpec` used) + the original spec, so a caller can
39
+ * diff. Throws OptimizeError if the spec doesn't exist. No LLM runs here.
40
+ */
41
+ export declare function buildOptimizeBrief(devRoot: string, slug: string): Promise<{
42
+ prompt: string;
43
+ original: string;
44
+ }>;
45
+ /**
46
+ * Deterministic finishing + write for an optimized spec the agent produced:
47
+ * validate the LLM's code against the same guardrails the deterministic path
48
+ * keeps, soft-batch the trailing independent assertions, and write it as a
49
+ * CANDIDATE (`.hover/cache/optimized/<slug>.spec.ts.draft`) — never the original.
50
+ * Throws OptimizeError if the code fails validation (the caller surfaces it so
51
+ * the agent can retry). No LLM runs here.
52
+ */
53
+ export declare function saveOptimizedCandidate(devRoot: string, slug: string, llmCode: string): Promise<{
54
+ candidatePath: string;
55
+ code: string;
56
+ }>;
32
57
  /**
33
58
  * Build the codegen prompt: the current spec + the observed session, plus the
34
59
  * same rules the deterministic path enforces (semantic selectors, no XPath, no
35
60
  * waitForTimeout, keep the test.step shape).
36
61
  */
37
- export declare function buildOptimizePrompt(draft: string, sidecar: SpecSidecar | null, seeds?: SeedRule[], suite?: SuiteContext): string;
62
+ export declare function buildOptimizePrompt(draft: string, sidecar: SpecSidecar | null, seeds?: SeedRule[], suite?: SuiteContext, outputInstruction?: string): string;
38
63
  /** Strip a ```ts fence if the model wrapped its output in one. */
39
64
  export declare function extractCode(raw: string): string;
40
65
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"optimizeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/optimizeSpec.ts"],"names":[],"mappings":"AAeA,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAgC,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAGzE,qBAAa,aAAc,SAAQ,KAAK;gBAC1B,OAAO,EAAE,MAAM;CAI5B;AAED,wEAAwE;AACxE,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE7D;;;4DAG4D;AAC5D,MAAM,WAAW,YAAY;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAOD;0DAC0D;AAC1D,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAgB/E;AAED,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,aAAa,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb;2EACuE;IACvE,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,cAAc,CAAC,CAwCzB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,WAAW,GAAG,IAAI,EAC3B,KAAK,GAAE,QAAQ,EAAO,EACtB,KAAK,GAAE,YAA4B,GAClC,MAAM,CAkFR;AAED,kEAAkE;AAClE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAWhF"}
1
+ {"version":3,"file":"optimizeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/optimizeSpec.ts"],"names":[],"mappings":"AAeA,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAgC,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAGzE,qBAAa,aAAc,SAAQ,KAAK;gBAC1B,OAAO,EAAE,MAAM;CAI5B;AAED,wEAAwE;AACxE,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE7D;;;4DAG4D;AAC5D,MAAM,WAAW,YAAY;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAOD;0DAC0D;AAC1D,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAgB/E;AAED,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,aAAa,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb;2EACuE;IACvE,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,cAAc,CAAC,CAKzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CA8B/C;AAED;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAkBlD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,WAAW,GAAG,IAAI,EAC3B,KAAK,GAAE,QAAQ,EAAO,EACtB,KAAK,GAAE,YAA4B,EACnC,iBAAiB,SAA+F,GAC/G,MAAM,CAiFR;AAED,kEAAkE;AAClE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAWhF"}
@@ -49,6 +49,21 @@ export async function gatherSuiteContext(devRoot) {
49
49
  return { conventions, pages };
50
50
  }
51
51
  export async function optimizeSpec(devRoot, slug, runCodegen) {
52
+ const { prompt, original } = await buildOptimizeBrief(devRoot, slug);
53
+ const raw = await runCodegen(prompt);
54
+ const { candidatePath, code } = await saveOptimizedCandidate(devRoot, slug, extractCode(raw));
55
+ return { candidatePath, code, original };
56
+ }
57
+ /**
58
+ * MCP-first optimize (F7) without a Hover-owned model: build the improvement
59
+ * brief for a spec, hand it to the USER's own agent (which IS the intelligence),
60
+ * and let it write the improved file back through `saveOptimizedCandidate`.
61
+ *
62
+ * Returns the prompt the agent works from (the same improvement rules the
63
+ * legacy in-engine `optimizeSpec` used) + the original spec, so a caller can
64
+ * diff. Throws OptimizeError if the spec doesn't exist. No LLM runs here.
65
+ */
66
+ export async function buildOptimizeBrief(devRoot, slug) {
52
67
  const specPath = join(devRoot, '__vibe_tests__', `${slug}.spec.ts`);
53
68
  let draft;
54
69
  try {
@@ -64,15 +79,31 @@ export async function optimizeSpec(devRoot, slug, runCodegen) {
64
79
  .map(s => s.tool));
65
80
  const seeds = relevantSeeds(BUILTIN_SEEDS, specTools);
66
81
  const suite = await gatherSuiteContext(devRoot);
67
- const raw = await runCodegen(buildOptimizePrompt(draft, sidecar, seeds, suite));
68
- const llmCode = extractCode(raw);
82
+ // The agent path ends by CALLING a tool (not by emitting raw text), so swap
83
+ // the legacy "output ONLY the file" footer for a save_optimized_spec directive.
84
+ const outputInstruction = `When done, call \`save_optimized_spec\` with slug "${slug}" and the COMPLETE improved ` +
85
+ `.ts file as \`code\`. Hover validates it (semantic selectors, no waitForTimeout/XPath), ` +
86
+ `soft-batches trailing assertions, and files it as a REVIEW CANDIDATE at ` +
87
+ `.hover/cache/optimized/${slug}.spec.ts.draft — it does NOT touch your spec. If it comes ` +
88
+ `back with a ✗ (a rejected check), fix that and call it again. Then tell the user the ` +
89
+ `candidate path so they can diff it against __vibe_tests__/${slug}.spec.ts and promote it.`;
90
+ return { prompt: buildOptimizePrompt(draft, sidecar, seeds, suite, outputInstruction), original: draft };
91
+ }
92
+ /**
93
+ * Deterministic finishing + write for an optimized spec the agent produced:
94
+ * validate the LLM's code against the same guardrails the deterministic path
95
+ * keeps, soft-batch the trailing independent assertions, and write it as a
96
+ * CANDIDATE (`.hover/cache/optimized/<slug>.spec.ts.draft`) — never the original.
97
+ * Throws OptimizeError if the code fails validation (the caller surfaces it so
98
+ * the agent can retry). No LLM runs here.
99
+ */
100
+ export async function saveOptimizedCandidate(devRoot, slug, llmCode) {
69
101
  const check = validateSpecCode(llmCode);
70
102
  if (!check.ok) {
71
103
  throw new OptimizeError(`optimization rejected — ${check.errors.join('; ')}`);
72
104
  }
73
- // Deterministic finishing step: the LLM decided WHAT to assert; soft-batch
74
- // applies the safe mechanical rewrite (trailing run of independent assertions
75
- // → expect.soft) surgically on its output. See softBatch.ts for the guard.
105
+ // Soft-batch applies the safe mechanical rewrite (a trailing run of
106
+ // independent assertions expect.soft) surgically. See softBatch.ts.
76
107
  const code = softBatch(llmCode).code;
77
108
  // Candidates are disposable derived artifacts → `.hover/cache/` (always
78
109
  // gitignored). Losing one costs a re-run of the optimization, nothing more.
@@ -82,14 +113,14 @@ export async function optimizeSpec(devRoot, slug, runCodegen) {
82
113
  // candidate before a human reviews it.
83
114
  const candidatePath = join(dir, `${slug}.spec.ts.draft`);
84
115
  await writeFile(candidatePath, code.endsWith('\n') ? code : `${code}\n`, 'utf-8');
85
- return { candidatePath, code, original: draft };
116
+ return { candidatePath, code };
86
117
  }
87
118
  /**
88
119
  * Build the codegen prompt: the current spec + the observed session, plus the
89
120
  * same rules the deterministic path enforces (semantic selectors, no XPath, no
90
121
  * waitForTimeout, keep the test.step shape).
91
122
  */
92
- export function buildOptimizePrompt(draft, sidecar, seeds = [], suite = { pages: [] }) {
123
+ export function buildOptimizePrompt(draft, sidecar, seeds = [], suite = { pages: [] }, outputInstruction = 'Output ONLY the complete .ts file contents — no markdown fences, no prose, no explanation.') {
93
124
  const done = sidecar?.steps.find(s => s.kind === 'done');
94
125
  const stepsJson = sidecar
95
126
  ? JSON.stringify(sidecar.steps.filter(s => s.kind === 'step'), null, 2)
@@ -138,8 +169,7 @@ export function buildOptimizePrompt(draft, sidecar, seeds = [], suite = { pages:
138
169
  ` human can find it and so the test breaks loudly once the app is fixed.`,
139
170
  ` Never silently lock buggy behavior into a normal-looking assertion.`,
140
171
  ``,
141
- `Output ONLY the complete .ts file contents — no markdown fences, no prose,`,
142
- `no explanation.`,
172
+ outputInstruction,
143
173
  ``,
144
174
  `=== CURRENT SPEC ===`,
145
175
  draft,
@@ -2,7 +2,7 @@ import type { SkillStep } from '../specs/specStep.js';
2
2
  import type { SpecAssertion } from './writeSpec.js';
3
3
  /** Current sidecar schema version. Bump when the shape changes so readers
4
4
  * (Stage 2 detection, Stage 7 optimization) can migrate or skip cleanly. */
5
- export declare const SIDECAR_VERSION = 1;
5
+ export declare const SIDECAR_VERSION = 2;
6
6
  export interface SpecSidecar {
7
7
  version: number;
8
8
  slug: string;
@@ -10,10 +10,24 @@ export interface SpecSidecar {
10
10
  /** ISO timestamp the sidecar was written. */
11
11
  createdAt: string;
12
12
  /** The full captured session, structured and verbatim — never re-derived
13
- * from the generated `.spec.ts`. */
13
+ * from the generated `.spec.ts`. Steps are already REDACTED (credentials are
14
+ * `process.env.<X>` refs, never literals), so the sidecar carries no secret. */
14
15
  steps: SkillStep[];
15
16
  /** Alt-click assertions captured alongside the session. */
16
17
  assertions: SpecAssertion[];
18
+ /** The env-var NAMES the run redacted credentials to (e.g. ['HOVER_PASSWORD']).
19
+ * Lets a re-render re-detect the login prefix without the literal secret. */
20
+ redactionEnvVars?: string[];
21
+ /** The run's target origin — re-render uses it for baseURL / goto synthesis. */
22
+ startUrl?: string;
23
+ /** Recon reset recipe (debt-2), so a re-render re-emits the reset beforeEach. */
24
+ resetRecipe?: {
25
+ tier: number;
26
+ storageKeys?: string[];
27
+ hook?: string;
28
+ };
29
+ /** Whether the run had approved editing an existing user config (Stage 4). */
30
+ authFixture?: boolean;
17
31
  }
18
32
  /** Project-root `.hover/` directory — the single home for Hover-derived data
19
33
  * (sidecars, runs, rules, conventions). */
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar.d.ts","sourceRoot":"","sources":["../../src/specs/sidecar.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD;6EAC6E;AAC7E,eAAO,MAAM,eAAe,IAAI,CAAC;AAEjC,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB;yCACqC;IACrC,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,2DAA2D;IAC3D,UAAU,EAAE,aAAa,EAAE,CAAC;CAC7B;AAED;4CAC4C;AAC5C,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,kFAAkF;AAClF,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzC;AAED;;;;;gCAKgC;AAChC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AACD,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAE/E;AACD,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAErF;AAED;2EAC2E;AAC3E,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;mCACmC;AACnC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;oEAEoE;AACpE,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,GAAG,WAAW,CAAC,GAC/C,OAAO,CAAC,MAAM,CAAC,CAWjB;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAoB5F;AAED;;gEAEgE;AAChE,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAOhF"}
1
+ {"version":3,"file":"sidecar.d.ts","sourceRoot":"","sources":["../../src/specs/sidecar.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD;6EAC6E;AAC7E,eAAO,MAAM,eAAe,IAAI,CAAC;AAEjC,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB;;qFAEiF;IACjF,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,2DAA2D;IAC3D,UAAU,EAAE,aAAa,EAAE,CAAC;IAI5B;kFAC8E;IAC9E,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE,8EAA8E;IAC9E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;4CAC4C;AAC5C,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,kFAAkF;AAClF,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzC;AAED;;;;;gCAKgC;AAChC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AACD,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAE/E;AACD,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAErF;AAED;2EAC2E;AAC3E,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;mCACmC;AACnC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;oEAEoE;AACpE,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,GAAG,WAAW,CAAC,GAC/C,OAAO,CAAC,MAAM,CAAC,CAWjB;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAoB5F;AAED;;gEAEgE;AAChE,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAOhF"}
@@ -19,7 +19,7 @@ import { mkdir, writeFile, readFile } from 'node:fs/promises';
19
19
  import { join } from 'node:path';
20
20
  /** Current sidecar schema version. Bump when the shape changes so readers
21
21
  * (Stage 2 detection, Stage 7 optimization) can migrate or skip cleanly. */
22
- export const SIDECAR_VERSION = 1;
22
+ export const SIDECAR_VERSION = 2;
23
23
  /** Project-root `.hover/` directory — the single home for Hover-derived data
24
24
  * (sidecars, runs, rules, conventions). */
25
25
  export function hoverDir(devRoot) {
@@ -90,6 +90,19 @@ export interface WriteSpecResult {
90
90
  };
91
91
  }
92
92
  export declare function writeSpec(opts: WriteSpecOptions): Promise<WriteSpecResult>;
93
+ /**
94
+ * Re-crystallize an already-saved spec from its sidecar — FAITHFULLY. Used by
95
+ * self-heal (re-render the healed flow) and Page-Object extraction (fold a
96
+ * newly-extracted shared flow into the specs that use it). Reads the v2 sidecar
97
+ * context so the re-render re-applies the SAME auth-fixture + base URL + reset —
98
+ * NOT a degraded pass that would drop the login into the spec.
99
+ *
100
+ * Credentials never round-trip: the sidecar stores env-var NAMES only and its
101
+ * steps are already redacted, so we pass `{ value: '', envVar }` placeholders —
102
+ * enough for authPrefixLength to re-detect the login prefix, with no literal
103
+ * secret anywhere. Returns null if the spec has no sidecar.
104
+ */
105
+ export declare function reRenderSpec(devRoot: string, slug: string): Promise<WriteSpecResult | null>;
93
106
  /**
94
107
  * Emit an interaction (click / dblclick / hover / fill / selectOption) as a
95
108
  * visibility-guarded prelude: hoist the locator to `el`, assert it's visible,
@@ -121,6 +134,15 @@ export declare function blockScope(lines: string[]): string[];
121
134
  * trailing role keyword is the convention Playwright MCP uses.
122
135
  */
123
136
  export declare function selectorFromDescription(desc: string, pageVar?: string): string;
137
+ /**
138
+ * Selector for a Hover control-actuation step (click/fill/select_control). The
139
+ * agent supplied these fields straight from the snapshot, in the same priority
140
+ * order the actuation server resolves them — role+name → testId → text — so the
141
+ * crystallized selector is exactly the one that drove the action at record time
142
+ * (no free-form description, hence no confabulation). Mirrors
143
+ * `locate()` in `mcp/actuateServer.ts`.
144
+ */
145
+ export declare function groundedSelector(input: Record<string, unknown>, pageVar?: string): string;
124
146
  /**
125
147
  * browser_select_option always targets a native `<select>` — whose ARIA role
126
148
  * is `combobox`. The agent's description is usually the label ("marital
@@ -1 +1 @@
1
- {"version":3,"file":"writeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/writeSpec.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAatD,MAAM,MAAM,QAAQ,GAAG,SAAS,CAAC;AAEjC;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,yBAAyB,CAAC;AAEzD;;0DAE0D;AAC1D,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE9D;AA2CD,MAAM,WAAW,aAAa;IAC5B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,eAAgB,SAAQ,KAAK;aACZ,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,UAAU,CAAC,EAAE,aAAa,EAAE,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;IACzB;;;;yEAIqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;sFAIkF;IAClF,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE;;;;qEAIiE;IACjE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAiDD,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtD;;;;;2DAKuD;IACvD,gBAAgB,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,CAAA;KAAE,CAAC;CACnE;AAQD,wBAAsB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAchF;AAonBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAM9E;AAED;;oCAEoC;AACpC,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAS,GAAG,MAAM,CAqB9E;AA0DD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAS,GAAG,MAAM,CAMxE;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,SAAS,GAAG,MAAM,CAQ1F"}
1
+ {"version":3,"file":"writeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/writeSpec.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAatD,MAAM,MAAM,QAAQ,GAAG,SAAS,CAAC;AAEjC;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,yBAAyB,CAAC;AAEzD;;0DAE0D;AAC1D,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE9D;AA2CD,MAAM,WAAW,aAAa;IAC5B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,eAAgB,SAAQ,KAAK;aACZ,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,UAAU,CAAC,EAAE,aAAa,EAAE,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;IACzB;;;;yEAIqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;sFAIkF;IAClF,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE;;;;qEAIiE;IACjE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAiDD,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtD;;;;;2DAKuD;IACvD,gBAAgB,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,CAAA;KAAE,CAAC;CACnE;AAQD,wBAAsB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAchF;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAcjG;AA0nBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAM9E;AAED;;oCAEoC;AACpC,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAS,GAAG,MAAM,CAqB9E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,SAAS,GAAG,MAAM,CA+BzF;AAiBD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAS,GAAG,MAAM,CAMxE;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,SAAS,GAAG,MAAM,CAQ1F"}
@@ -20,7 +20,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
20
20
  import { existsSync, readFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { humanSteps, humanStep } from './humanSteps.js';
23
- import { writeSidecar } from './sidecar.js';
23
+ import { writeSidecar, readSidecar } from './sidecar.js';
24
24
  import { readPageObjectManifest, } from './pageObjectManifest.js';
25
25
  import { stepSignature } from './detectSharedFlows.js';
26
26
  import { slugify, firstSentence } from './text.js';
@@ -162,6 +162,34 @@ export async function writeSpec(opts) {
162
162
  // writeSecuritySpec — those are stateless and independently replayable.)
163
163
  return writeOneSpec(opts, slugify(opts.name), opts.name, opts.steps);
164
164
  }
165
+ /**
166
+ * Re-crystallize an already-saved spec from its sidecar — FAITHFULLY. Used by
167
+ * self-heal (re-render the healed flow) and Page-Object extraction (fold a
168
+ * newly-extracted shared flow into the specs that use it). Reads the v2 sidecar
169
+ * context so the re-render re-applies the SAME auth-fixture + base URL + reset —
170
+ * NOT a degraded pass that would drop the login into the spec.
171
+ *
172
+ * Credentials never round-trip: the sidecar stores env-var NAMES only and its
173
+ * steps are already redacted, so we pass `{ value: '', envVar }` placeholders —
174
+ * enough for authPrefixLength to re-detect the login prefix, with no literal
175
+ * secret anywhere. Returns null if the spec has no sidecar.
176
+ */
177
+ export async function reRenderSpec(devRoot, slug) {
178
+ const sc = await readSidecar(devRoot, slug);
179
+ if (!sc)
180
+ return null;
181
+ return writeSpec({
182
+ devRoot,
183
+ name: sc.name,
184
+ steps: sc.steps,
185
+ assertions: sc.assertions,
186
+ startUrl: sc.startUrl,
187
+ resetRecipe: sc.resetRecipe,
188
+ authFixture: sc.authFixture,
189
+ redactions: (sc.redactionEnvVars ?? []).map((envVar) => ({ value: '', envVar })),
190
+ overwrite: true,
191
+ });
192
+ }
165
193
  /** Write ONE spec file from a (sub)set of steps. The single-file path and each
166
194
  * per-flow file both go through here, so rendering / sidecar / config logic is
167
195
  * identical whether or not the run was split. */
@@ -277,6 +305,12 @@ async function writeOneSpec(opts, slug, displayName, rawSteps) {
277
305
  name: displayName,
278
306
  steps,
279
307
  assertions: opts.assertions ?? [],
308
+ // v2 context — NAMES only (no literal creds), so a faithful re-render can
309
+ // re-apply auth-fixture + base URL + reset without re-driving the browser.
310
+ redactionEnvVars: (opts.redactions ?? []).map(r => r.envVar),
311
+ startUrl: opts.startUrl,
312
+ resetRecipe: opts.resetRecipe,
313
+ authFixture: opts.authFixture,
280
314
  });
281
315
  // Session-ledger patch, best-effort by contract: markSessionSaved swallows
282
316
  // its own failures — it must never break Save-as-spec.
@@ -812,7 +846,7 @@ export function selectorFromDescription(desc, pageVar = 'page') {
812
846
  * (no free-form description, hence no confabulation). Mirrors
813
847
  * `locate()` in `mcp/actuateServer.ts`.
814
848
  */
815
- function groundedSelector(input, pageVar = 'page') {
849
+ export function groundedSelector(input, pageVar = 'page') {
816
850
  const role = typeof input.role === 'string' ? input.role : '';
817
851
  const name = typeof input.name === 'string' ? input.name : '';
818
852
  const testId = typeof input.testId === 'string' ? input.testId : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hover-dev/core",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Hover's local Node service: agent invocation, Playwright CDP preflight, WebSocket bridge.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hyperyond",