@ia-qa/self-healing 0.5.1 → 0.7.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/README.md CHANGED
@@ -6,6 +6,23 @@ Self-healing E2E toolkit: a **local MCP server** for AI agents, a guided **CLI**
6
6
 
7
7
  **It runs entirely on the end user's machine.** It brings its own headless Chromium, so it sees `localhost`, staging, or an app behind a VPN — and **no data ever leaves the machine**. Nothing is hosted.
8
8
 
9
+ ## Quick start
10
+
11
+ ```bash
12
+ npx -y -p @ia-qa/self-healing ia-qa-heal init # wizard → .ia-qa/config.json
13
+ npx -y -p @ia-qa/self-healing ia-qa-heal map # → .ia-qa/mapping/<page>.json + .md
14
+ npx -y -p @ia-qa/self-healing ia-qa-heal baseline # → .ia-qa/baseline/ — the reference. Commit it.
15
+ # …change the app, then `map` again…
16
+ npx -y -p @ia-qa/self-healing ia-qa-heal diff # baseline/ vs mapping/ → PASS / FIX / BLOCK
17
+ ```
18
+
19
+ Install it in the project that holds your tests to drop the `-p` dance — and to get the `aiClick` / `aiFill` runtime helpers, which are imported, not run:
20
+
21
+ ```bash
22
+ npm i -D @ia-qa/self-healing
23
+ npx ia-qa-heal init
24
+ ```
25
+
9
26
  ## Two front doors, one engine
10
27
 
11
28
  | | For | Entry point |
@@ -24,8 +41,8 @@ Agent config (Claude Code / Desktop, Cursor…):
24
41
  }
25
42
  }
26
43
  ```
27
- `-p` is required, not cosmetic: the package ships **two** bins (`ia-qa-heal`, `ia-qa-heal-mcp`), so `npx @ia-qa/self-healing …` cannot resolve which to run and silently starts nothing. Verified against a packed tarball.
28
- The MCP server is a dependency-free JSON-RPC 2.0 stdio implementation (`src/mcp/server.ts`) — no SDK, matching the repo's zero-dep client ethos. It does **not** require the separate Playwright MCP: it drives its own browser. stdout is the protocol channel; logs go to stderr.
44
+ `-p` is required, not cosmetic: the package ships **two** bins (`ia-qa-heal`, `ia-qa-heal-mcp`), so `npx @ia-qa/self-healing …` cannot resolve which to run and silently starts nothing.
45
+ The MCP server is a dependency-free JSON-RPC 2.0 stdio implementation (`src/mcp/server.ts`) — no SDK. It does **not** require the separate Playwright MCP: it drives its own browser. stdout is the protocol channel; logs go to stderr.
29
46
 
30
47
  ### Browser reuse — no second Chromium (`src/launcher.ts`)
31
48
  Playwright's browser cache is **machine-wide and shared** (`%LOCALAPPDATA%\ms-playwright`, `~/.cache/ms-playwright`). If the user already has *any* Playwright install — their own tests or the Playwright MCP — the bundled Chromium is already there and we reuse it for free. There is no "second Chromium" to avoid; the only real cost is **revision skew** (a different Playwright version pins a different Chromium revision). For that case, and for machines that forbid the download, the launcher resolves in this order:
@@ -53,32 +70,30 @@ Only if all four fail does it error, listing the three fixes. `map_app` exposes
53
70
  ```
54
71
  The `<page>.md` groups elements by role and carries a self-describing header telling a model exactly how to use it. **The CI auto-heal loop:** when a UI test fails, hand the failing test file + the current `<page>.md` to an LLM — it finds the element by role + accessible name, reads the current selector, and rewrites the test in **whatever framework and language** it is written in (Cypress, Playwright, Selenium; JS or Python). The contract is the source of truth; the model handles the syntax. The same contract is downloadable from the zero-install **Selector Drift Detector** page on ia-qa.com.
55
72
 
56
- `map` also rebuilds **`.ia-qa/mapping/_overview.md`** — the app index, and the file to open first. Forty-seven page contracts answer "what is on this page?"; none of them answers "what is this app, and which contract do I need?". The overview does: every page with its URL, element count and a link to its contract; the shared layout; and the surface by role. It also surfaces two things no single contract can same-origin destinations your app links to but your config never maps (the coverage gap), and the external domains you link out to. It is derived entirely from the mappings on disk (no extra page visit) and is rebuilt on every `map`, including `map <page>`, so it never drifts out of date.
57
-
58
- When pages link to each other, `map` writes the **navigation graph** next to it, built from link `href`s: **`_navigation.md`** (a Mermaid fence — GitHub, GitLab and the VS Code preview render it natively) and **`_navigation.svg`** (the same graph, hand-rolled, opening in any browser offline — no mermaid.js, no CDN, nothing fetched).
73
+ Alongside the page contracts, `map` rebuilds the app index and the navigation graphsee [Map artifacts](#map-artifacts).
74
+ 3. **`ia-qa-heal baseline`** — promote `.ia-qa/mapping/` to `.ia-qa/baseline/`, the reference `diff` compares against. Commit it: a reference that is not in git cannot be reviewed or reverted, and accepting drift is a code-review decision like any other.
59
75
 
60
- The graph draws **what a page table cannot tell you: pages that link to each other**. Three things are deliberately left out, on one principle — a picture should not spend ink on what the page list already says:
61
- - **shell links** (navbar, footer) — in the overview, listed once, instead of an edge from every page to every navbar destination;
62
- - **isolated pages** — a box with no arrow;
63
- - **hub fans** — a page whose 144 spokes link nowhere else is stated in one sentence (`home` fans out to 144 pages) instead of drawn as 144 identical arrows crossing every other line. That is the catalogue shape, and no node-link diagram survives it.
76
+ Drift is a claim about **two moments**, and `map` only ever captures one. Promotion is the second half, and it is a separate verb on purposedeliberately *not* a flag on `map`, because promoting in the same breath as capturing would make the reference incapable of ever being older than what it is compared to. `map` records what the app **is**; `baseline` says it is also what the app is **supposed to be**. That second claim is a human judgement.
64
77
 
65
- What is left is the real structure: a checkout tunnel stays, its noise does not. If nothing survives, both files are removed rather than left stale for that app, the page list genuinely is the whole story.
66
- 3. **`ia-qa-heal diff <before.json> <after.json>`** — CI-gate verdict. Diffs two mappings (from `map` or the ia-qa.com browser snippet) and reports which selectors survived, which the runtime healer would recover, and which will hard-fail:
78
+ Skip it and the natural workaround is `cp current.json baseline.json && diff` — which compares a capture to a copy of itself and prints `PASS ✅ No drift`. That verdict is green because nothing was compared, not because nothing changed. `diff` now refuses it outright (exit `2`): every side carries a `capturedAt`, equal timestamps mean one `map` run, and one run cannot drift from itself. A gate that reports green without looking is worse than no gate, because it is trusted.
79
+ 4. **`ia-qa-heal diff [before.json] [after.json]`** — CI-gate verdict. With no arguments, diffs `baseline/` against `mapping/` — the everyday case. Diffs two mappings (from `map` or the ia-qa.com browser snippet) and reports which selectors survived, which the runtime healer would recover, and which will hard-fail:
67
80
  ```
68
81
  ⛔ BLOCK
69
82
  before.json → after.json · 3 ok · 2 healable · 1 lost · 2 new
70
83
  🔧 healable button "Se connecter" button#login-btn → button.btn-primary (exact name)
71
84
  ⛔ lost button "Supprimer le compte" button#delete → (no match)
72
85
  ```
73
- Exit codes for pipelines: `0` = PASS or FIX, `1` = BLOCK — lost, ambiguous, or rebound (add `--strict` to fail on FIX too), `2` = bad input. Use `--json` for machine-readable output. Same diff engine as the web tool (`src/browser/match.js`).
86
+ Exit codes for pipelines: `0` = PASS or FIX, `1` = BLOCK — lost, ambiguous, or rebound (add `--strict` to fail on FIX too), `2` = bad input, no baseline, or nothing to compare. Use `--json` for machine-readable output. Same diff engine as the web tool (`src/browser/match.js`).
87
+
88
+ A contract that was **not re-mapped** since the baseline carries the baseline's own `capturedAt`, so it cannot show drift. Those are listed and left **out** of the verdict rather than counted as `ok` — padding a gate with comparisons that never happened is the same lie in smaller print. `--json` reports them as `staleExcluded`.
74
89
 
75
90
  Every element carries a **context** (nearest landmark + section heading) so two elements with the same role + accessible name (five "Delete" buttons) stay distinguishable. The diff classifies each baseline element as `ok`, `renamed` (same element, new accessible name — a *content* drift to eyeball), `healable` (deterministic old→new rewrite), `ambiguous` (several equal matches — the tool **refuses to guess**, a human decides), or `lost` (no match).
76
91
 
77
92
  Identity comes from the contract (role + accessible name), never from the selector string. A selector that still resolves only proves the *position* is still occupied: insert one button above a tab bar and every `nth-of-type` below it silently slides onto its neighbour. Any row where that happened is flagged `rebound` — the old selector resolves, but to a **different element**, so the suite is green while clicking the wrong thing. Rebound rows are rewritten like any other healable row, and they **BLOCK**: a `FIX` verdict exits `0`, which is exactly how this ships unnoticed.
78
- 4. **`ia-qa-heal fix <before.json> <after.json> <test-paths…>`** — the deterministic half. Applies **only** the `healable` rewrites to your test files, replacing the selector only where it appears as a quoted string literal (`'…'`/`"…"`/`` `…` ``, quote-safe for attribute selectors), across any framework/language. Every rewrite is applied in a single pass against the selector each occurrence had in the file, so a *permuted* set (`nth-of-type(1)→(2)`, `(2)→(3)`) can never chain and drag `(1)` to `(3)`. LOST / AMBIGUOUS / RENAMED are printed but never touched — they need judgment. It **edits the working tree and never commits**: review with `git diff`, then push or discard. `--dry-run` previews the plan. This is "prepare the ground, the human decides" as a command.
93
+ 5. **`ia-qa-heal fix <before.json> <after.json> <test-paths…>`** — the deterministic half. Applies **only** the `healable` rewrites to your test files, replacing the selector only where it appears as a quoted string literal (`'…'`/`"…"`/`` `…` ``, quote-safe for attribute selectors), across any framework/language. Every rewrite is applied in a single pass against the selector each occurrence had in the file, so a *permuted* set (`nth-of-type(1)→(2)`, `(2)→(3)`) can never chain and drag `(1)` to `(3)`. LOST / AMBIGUOUS / RENAMED are printed but never touched — they need judgment. It **edits the working tree and never commits**: review with `git diff`, then push or discard. `--dry-run` previews the plan. This is "prepare the ground, the human decides" as a command.
79
94
 
80
95
  **`--locators`** rewrites to a Playwright role locator — `page.getByRole('button', { name: 'Validator' })` — instead of a selector, wherever role + accessible name identifies exactly one element. A repaired positional selector is only correct until the next insertion; a role locator is what the contract already means, so it does not drift at all. Applies to `.js`/`.ts` files and Playwright call shapes (`page.click('…')`, `page.locator('…')`, `page.fill('…', v)`, …); every other file and every element without an unambiguous role + name keeps the plain selector rewrite. `getByRole` is strict, so a locator is emitted only when it provably resolves to one element — otherwise the tool would trade a silent wrong click for a loud crash.
81
- 5. **Runtime healing** — in your Playwright tests:
96
+ 6. **Runtime healing** — in your Playwright tests:
82
97
  ```ts
83
98
  import { aiClick, aiFill } from '@ia-qa/self-healing';
84
99
 
@@ -87,6 +102,21 @@ Only if all four fail does it error, listing the three fixes. `map_app` exposes
87
102
  ```
88
103
  If the selector fails, the helper re-scans the live page, matches the element by role + accessible name (exact, then fuzzy Dice ≥ 0.6 — or your own `llmResolver`), retries on the healed selector, and logs a warning telling you to update the test and re-map.
89
104
 
105
+ ## Map artifacts
106
+
107
+ Beyond one contract per page, every `map` run rebuilds two views of the whole app. Both are derived entirely from the mappings on disk (no extra page visit) and rebuilt on every run, including `map <page>`, so they never drift out of date.
108
+
109
+ **`.ia-qa/mapping/_overview.md`** — the app index, and the file to open first. Every page contract answers "what is on this page?"; none of them answers "what is this app, and which contract do I need?". The overview does: every page with its URL, element count and a link to its contract; the shared layout; and the surface by role. It also surfaces two things no single contract can — same-origin destinations your app links to but your config never maps (the coverage gap), and the external domains you link out to.
110
+
111
+ **`_navigation.md` / `_navigation.svg`** — the navigation graph, written when pages link to each other, built from link `href`s. The `.md` is a Mermaid fence (GitHub, GitLab and the VS Code preview render it natively); the `.svg` is the same graph, hand-rolled, opening in any browser offline — no mermaid.js, no CDN, nothing fetched.
112
+
113
+ The graph draws **what a page table cannot tell you: pages that link to each other**. Three things are deliberately left out, on one principle — a picture should not spend ink on what the page list already says:
114
+ - **shell links** (navbar, footer) — in the overview, listed once, instead of an edge from every page to every navbar destination;
115
+ - **isolated pages** — a box with no arrow;
116
+ - **hub fans** — a page whose spokes link nowhere else is stated in one sentence (`home` fans out to 144 pages) instead of drawn as 144 identical arrows crossing every other line. That is the catalogue shape, and no node-link diagram survives it.
117
+
118
+ What is left is the real structure: a checkout tunnel stays, its noise does not. If nothing survives, both files are removed rather than left stale — for that app, the page list genuinely is the whole story.
119
+
90
120
  ## Design notes
91
121
 
92
122
  - `page.accessibility.snapshot()` is deprecated **and returns no selectors**, so mapping walks the DOM directly and computes ARIA role + accname + a stable selector (`data-testid` → `id` → `name` → short structural path).
@@ -94,7 +124,15 @@ Only if all four fail does it error, listing the three fixes. `map_app` exposes
94
124
  - Secrets: never written to disk. `env` reads `process.env`; `aws-ssm` calls `GetParameter` with decryption using the AWS SDK default credential chain (needs `ssm:GetParameter`, plus `kms:Decrypt` for SecureString).
95
125
  - `llmResolverStub` in `src/playwright/healer.ts` is the extension point for a real LLM/embedding-based resolver (e.g. via `POST https://www.ia-qa.com/mcp/call`).
96
126
 
97
- ## Build & run
127
+ ## Peer dependencies
128
+
129
+ `playwright` is a peer dependency — the consuming test project provides it (`npx playwright install chromium` if needed). This is deliberate: bundling our own copy would let a project on a different Playwright version end up with two installs and a browser-revision skew, which is exactly what the shared launcher exists to avoid.
130
+
131
+ `@aws-sdk/client-ssm` is an **optional** peer, needed only by the `aws-ssm` secret source. It is ~5 MB and imported lazily, so it is not a tax on installs that use the `env` source: `npm i @aws-sdk/client-ssm` if you want SSM, and the CLI tells you so if you pick it without.
132
+
133
+ ## Build from source
134
+
135
+ For work on the package itself — installing from npm needs none of this.
98
136
 
99
137
  ```bash
100
138
  cd packages/self-healing
@@ -102,7 +140,3 @@ npm run build # tsc → dist/
102
140
  node dist/cli/index.js init
103
141
  node dist/cli/index.js map
104
142
  ```
105
-
106
- `playwright` is a peer dependency — the consuming test project provides it (`npx playwright install chromium` if needed). This is deliberate: bundling our own copy would let a project on a different Playwright version end up with two installs and a browser-revision skew, which is exactly what the shared launcher exists to avoid.
107
-
108
- `@aws-sdk/client-ssm` is an **optional** peer, needed only by the `aws-ssm` secret source. It is ~5 MB and imported lazily, so it is not a tax on installs that use the `env` source: `npm i @aws-sdk/client-ssm` if you want SSM, and the CLI tells you so if you pick it without.
package/TUTORIAL.md CHANGED
@@ -165,10 +165,12 @@ The agent launches a browser in the background and gives you back something like
165
165
  | Valider | main › Paiement | `button[data-testid="pay"]` |
166
166
  ```
167
167
 
168
- **That's your baseline.** Commit it it lives in git like code:
168
+ **That's a capture of what the app is right now.** To make it the reference everything is
169
+ measured against, promote it — then commit both, they live in git like code:
169
170
 
170
171
  ```bash
171
- git add .ia-qa/mapping
172
+ npx ia-qa-heal baseline # .ia-qa/mapping → .ia-qa/baseline
173
+ git add .ia-qa/mapping .ia-qa/baseline
172
174
  git commit -m "Add page contract baseline"
173
175
  ```
174
176
 
@@ -238,13 +240,28 @@ Commit them.
238
240
  Here's the loop you'll actually live in.
239
241
 
240
242
  ### Step 6.1 — You have a baseline
241
- From step 4.4 or 5.3, committed to git. Call it `checkout.baseline.json` (just copy it):
243
+ From step 4.4 or 5.3: promoted with `ia-qa-heal baseline` and committed to git.
242
244
 
243
245
  ```bash
244
- cp .ia-qa/mapping/checkout.json .ia-qa/mapping/checkout.baseline.json
245
- git add .ia-qa/mapping && git commit -m "baseline"
246
+ npx ia-qa-heal baseline
247
+ git add .ia-qa/mapping .ia-qa/baseline && git commit -m "baseline"
246
248
  ```
247
249
 
250
+ Promotion is its own step because **drift is a claim about two moments**, and `map` only
251
+ ever captures one. `map` records what the app *is*; `baseline` says it is also what the app
252
+ is *supposed to be*. That second claim is yours to make, so it gets its own command.
253
+
254
+ Do it now and `diff` will refuse — correctly. Nothing has been re-mapped, so both sides are
255
+ the same capture, and a capture cannot drift from itself:
256
+
257
+ ```
258
+ ❌ Nothing to compare: all 2 pairs are the same capture on both sides.
259
+ ```
260
+
261
+ That refusal *is* the feature. The obvious shortcut — copy the file, diff against the copy —
262
+ prints `PASS ✅ No drift — your suite is safe to run`, which sounds like an answer and is
263
+ not one: nothing was compared. A green gate that never looked is worse than no gate.
264
+
248
265
  ### Step 6.2 — Someone changes the UI
249
266
  A developer ships a redesign. Your tests start failing. **Normal.**
250
267
 
@@ -256,18 +273,22 @@ Terminal: `npx ia-qa-heal map checkout`
256
273
  Agent: **"Diff the baseline against the new contract"**
257
274
  Terminal:
258
275
  ```bash
259
- npx ia-qa-heal diff .ia-qa/mapping/checkout.baseline.json .ia-qa/mapping/checkout.json
276
+ npx ia-qa-heal diff # baseline/ against mapping/ — no arguments needed
260
277
  ```
261
278
 
279
+ Now there are two moments, so there is a verdict. Any page you did **not** re-map still
280
+ carries the baseline's own timestamp; it is listed and left out of the verdict rather than
281
+ counted as `ok`, because it was never compared either.
282
+
262
283
  ### Step 6.5 — Apply the safe fixes
263
284
  Agent: **"Fix my tests in the tests/ folder using that diff"**
264
285
  Terminal:
265
286
  ```bash
266
287
  # Preview first — nothing is written
267
- npx ia-qa-heal fix .ia-qa/mapping/checkout.baseline.json .ia-qa/mapping/checkout.json tests/ --dry-run
288
+ npx ia-qa-heal fix .ia-qa/baseline/checkout.json .ia-qa/mapping/checkout.json tests/ --dry-run
268
289
 
269
290
  # Do it
270
- npx ia-qa-heal fix .ia-qa/mapping/checkout.baseline.json .ia-qa/mapping/checkout.json tests/
291
+ npx ia-qa-heal fix .ia-qa/baseline/checkout.json .ia-qa/mapping/checkout.json tests/
271
292
  ```
272
293
 
273
294
  ### Step 6.5b — Stop fixing the same test twice (`--locators`)
@@ -351,9 +372,14 @@ jobs:
351
372
  run: npx ia-qa-heal map
352
373
 
353
374
  - name: Fail if selectors drifted with no safe fix
354
- run: npx ia-qa-heal diff .ia-qa/mapping/checkout.baseline.json .ia-qa/mapping/checkout.json
375
+ run: npx ia-qa-heal diff
355
376
  ```
356
377
 
378
+ `diff` with no arguments compares the committed `.ia-qa/baseline/` against the `.ia-qa/mapping/`
379
+ the previous step just re-captured — which is why the baseline has to be in git for CI to have
380
+ anything to check out. It exits `2` if there is no baseline, or if nothing was re-mapped, so a
381
+ misconfigured job fails loudly instead of passing without comparing anything.
382
+
357
383
  Add `--strict` if you want **FIX** to fail the build too (forces tests to be updated, not just healed).
358
384
 
359
385
  ---
@@ -419,9 +445,11 @@ Yes, any URL your machine can reach.
419
445
  ```bash
420
446
  npx ia-qa-heal init # one-time setup
421
447
  npx ia-qa-heal map # scan → *.json + *.md + _overview.md + _navigation.md/.svg
422
- npx ia-qa-heal diff before.json after.json # verdict: PASS / FIX / BLOCK
423
- npx ia-qa-heal fix before.json after.json tests/ --dry-run # preview
424
- npx ia-qa-heal fix before.json after.json tests/ # apply (never commits)
448
+ npx ia-qa-heal baseline # promote mapping/ baseline/ (commit it)
449
+ # …change the app, then `map` again…
450
+ npx ia-qa-heal diff # verdict: PASS / FIX / BLOCK (baseline/ vs mapping/)
451
+ npx ia-qa-heal fix .ia-qa/baseline/checkout.json .ia-qa/mapping/checkout.json tests/ --dry-run # preview
452
+ npx ia-qa-heal fix .ia-qa/baseline/checkout.json .ia-qa/mapping/checkout.json tests/ # apply (never commits)
425
453
  ```
426
454
 
427
455
  Talking to an agent instead:
package/dist/aom.d.ts CHANGED
@@ -23,6 +23,30 @@ export interface PageMapping {
23
23
  capturedAt: string;
24
24
  elements: MappedElement[];
25
25
  }
26
+ /**
27
+ * Did these two mappings come out of the same `map` run?
28
+ *
29
+ * `capturedAt` is stamped once per page per run, so equality means one capture is being
30
+ * compared to a copy of itself. That is what a hand-rolled baseline (`cp current.json
31
+ * baseline.json && diff`) always produces on its first run, and the diff of a file against
32
+ * itself is `PASS · N ok · 0 healable` — printed under "✅ No drift — your suite is safe to
33
+ * run". Every word of that is false: nothing was compared, so nothing is known about the
34
+ * suite. A green gate that never looked is worse than no gate, because it is trusted.
35
+ *
36
+ * Two real `map` runs collide only if stamped on the same millisecond, which does not
37
+ * happen across the app changes a drift check exists to find. A *missing* timestamp is
38
+ * malformed input, not a self-comparison: two `undefined`s share nothing meaningful, and
39
+ * blocking on them would refuse a legitimate diff over a field the diff never reads.
40
+ *
41
+ * Lives here rather than beside either caller because both doors into the diff — the CLI
42
+ * and the MCP server — have to agree on what is not a comparison. One guarding and the
43
+ * other not would just move the false green to whichever door the user knocked on.
44
+ */
45
+ export declare function isSameCapture(baseline: {
46
+ capturedAt?: string;
47
+ } | null | undefined, current: {
48
+ capturedAt?: string;
49
+ } | null | undefined): boolean;
26
50
  export declare function extractInteractiveElements(page: Page): Promise<MappedElement[]>;
27
51
  export declare function saveMapping(mapping: PageMapping, dir?: string): string;
28
52
  export declare function loadMapping(pageName: string, dir?: string): PageMapping;
package/dist/aom.js CHANGED
@@ -33,12 +33,35 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isSameCapture = isSameCapture;
36
37
  exports.extractInteractiveElements = extractInteractiveElements;
37
38
  exports.saveMapping = saveMapping;
38
39
  exports.loadMapping = loadMapping;
39
40
  const fs = __importStar(require("fs"));
40
41
  const config_1 = require("./config");
41
42
  const extract_1 = require("./browser/extract");
43
+ /**
44
+ * Did these two mappings come out of the same `map` run?
45
+ *
46
+ * `capturedAt` is stamped once per page per run, so equality means one capture is being
47
+ * compared to a copy of itself. That is what a hand-rolled baseline (`cp current.json
48
+ * baseline.json && diff`) always produces on its first run, and the diff of a file against
49
+ * itself is `PASS · N ok · 0 healable` — printed under "✅ No drift — your suite is safe to
50
+ * run". Every word of that is false: nothing was compared, so nothing is known about the
51
+ * suite. A green gate that never looked is worse than no gate, because it is trusted.
52
+ *
53
+ * Two real `map` runs collide only if stamped on the same millisecond, which does not
54
+ * happen across the app changes a drift check exists to find. A *missing* timestamp is
55
+ * malformed input, not a self-comparison: two `undefined`s share nothing meaningful, and
56
+ * blocking on them would refuse a legitimate diff over a field the diff never reads.
57
+ *
58
+ * Lives here rather than beside either caller because both doors into the diff — the CLI
59
+ * and the MCP server — have to agree on what is not a comparison. One guarding and the
60
+ * other not would just move the false green to whichever door the user knocked on.
61
+ */
62
+ function isSameCapture(baseline, current) {
63
+ return Boolean(baseline?.capturedAt) && baseline.capturedAt === current?.capturedAt;
64
+ }
42
65
  async function extractInteractiveElements(page) {
43
66
  return page.evaluate(extract_1.extractInPage);
44
67
  }
package/dist/aom.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"aom.js","sourceRoot":"","sources":["../src/aom.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,gEAEC;AAED,kCAKC;AAED,kCAQC;AAnDD,uCAAyB;AAEzB,qCAAmD;AACnD,+CAAkD;AA6B3C,KAAK,UAAU,0BAA0B,CAAC,IAAU;IACzD,OAAO,IAAI,CAAC,QAAQ,CAAC,uBAAa,CAA6B,CAAC;AAClE,CAAC;AAED,SAAgB,WAAW,CAAC,OAAoB,EAAE,MAAc,IAAA,mBAAU,GAAE;IAC1E,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,IAAA,oBAAW,EAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC5C,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;IACxE,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,WAAW,CAAC,QAAgB,EAAE,MAAc,IAAA,mBAAU,GAAE;IACtE,MAAM,IAAI,GAAG,IAAA,oBAAW,EAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IACxC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,8BAA8B,QAAQ,MAAM,IAAI,2BAA2B,QAAQ,WAAW,CAC/F,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAgB,CAAC;AAClE,CAAC"}
1
+ {"version":3,"file":"aom.js","sourceRoot":"","sources":["../src/aom.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDA,sCAKC;AAED,gEAEC;AAED,kCAKC;AAED,kCAQC;AA7ED,uCAAyB;AAEzB,qCAAmD;AACnD,+CAAkD;AA6BlD;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,aAAa,CAC3B,QAAoD,EACpD,OAAmD;IAEnD,OAAO,OAAO,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,QAAS,CAAC,UAAU,KAAK,OAAO,EAAE,UAAU,CAAC;AACvF,CAAC;AAEM,KAAK,UAAU,0BAA0B,CAAC,IAAU;IACzD,OAAO,IAAI,CAAC,QAAQ,CAAC,uBAAa,CAA6B,CAAC;AAClE,CAAC;AAED,SAAgB,WAAW,CAAC,OAAoB,EAAE,MAAc,IAAA,mBAAU,GAAE;IAC1E,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,IAAA,oBAAW,EAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC5C,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;IACxE,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,WAAW,CAAC,QAAgB,EAAE,MAAc,IAAA,mBAAU,GAAE;IACtE,MAAM,IAAI,GAAG,IAAA,oBAAW,EAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IACxC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,8BAA8B,QAAQ,MAAM,IAAI,2BAA2B,QAAQ,WAAW,CAC/F,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAgB,CAAC;AAClE,CAAC"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `ia-qa-heal baseline` — promote the current mappings to the reference `diff` compares against.
3
+ *
4
+ * Drift is a claim about two moments, so it needs a promoted "before". Without a command for
5
+ * it, everyone invents one, and the obvious invention — copy the current mapping, then diff
6
+ * against the copy — compares a capture to itself and reports `PASS ✅ No drift`. The
7
+ * verdict is green because nothing was compared, which is the one failure mode a CI gate
8
+ * must never have. So the promotion is a command, and `diff` refuses a verdict when it
9
+ * catches the copy anyway (see `assertComparable` in `diff.ts`).
10
+ *
11
+ * Deliberately not a flag on `map`: promoting in the same breath as capturing would rebuild
12
+ * the trap, since the reference could then never be older than what it is compared to. The
13
+ * separation *is* the feature — `map` records what the app is now, `baseline` says it is
14
+ * also what the app is supposed to be. That second claim is a human judgement, and it gets
15
+ * its own verb.
16
+ *
17
+ * Copies `.json` only. `diff` reads nothing else, and the markdown contracts in `mapping/`
18
+ * already describe the app as it is today — a frozen second copy of them would be prose that
19
+ * silently goes stale, and twice the diff to review on every promotion.
20
+ */
21
+ export declare function runBaseline(args?: string[]): Promise<void>;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runBaseline = runBaseline;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const config_1 = require("../config");
40
+ /**
41
+ * `ia-qa-heal baseline` — promote the current mappings to the reference `diff` compares against.
42
+ *
43
+ * Drift is a claim about two moments, so it needs a promoted "before". Without a command for
44
+ * it, everyone invents one, and the obvious invention — copy the current mapping, then diff
45
+ * against the copy — compares a capture to itself and reports `PASS ✅ No drift`. The
46
+ * verdict is green because nothing was compared, which is the one failure mode a CI gate
47
+ * must never have. So the promotion is a command, and `diff` refuses a verdict when it
48
+ * catches the copy anyway (see `assertComparable` in `diff.ts`).
49
+ *
50
+ * Deliberately not a flag on `map`: promoting in the same breath as capturing would rebuild
51
+ * the trap, since the reference could then never be older than what it is compared to. The
52
+ * separation *is* the feature — `map` records what the app is now, `baseline` says it is
53
+ * also what the app is supposed to be. That second claim is a human judgement, and it gets
54
+ * its own verb.
55
+ *
56
+ * Copies `.json` only. `diff` reads nothing else, and the markdown contracts in `mapping/`
57
+ * already describe the app as it is today — a frozen second copy of them would be prose that
58
+ * silently goes stale, and twice the diff to review on every promotion.
59
+ */
60
+ async function runBaseline(args = []) {
61
+ const from = (0, config_1.mappingDir)();
62
+ const to = (0, config_1.baselineDir)();
63
+ if (!fs.existsSync(from)) {
64
+ console.error(`❌ No mappings found in ${rel(from)} — run \`ia-qa-heal map\` first.`);
65
+ process.exitCode = 2;
66
+ return;
67
+ }
68
+ const pages = jsonFilesIn(from);
69
+ const layouts = jsonFilesIn(path.join(from, config_1.LAYOUT_DIRNAME));
70
+ if (pages.length === 0 && layouts.length === 0) {
71
+ console.error(`❌ ${rel(from)} holds no mapping files — run \`ia-qa-heal map\` first.`);
72
+ process.exitCode = 2;
73
+ return;
74
+ }
75
+ const replaced = jsonFilesIn(to).length + jsonFilesIn(path.join(to, config_1.LAYOUT_DIRNAME)).length;
76
+ fs.mkdirSync(to, { recursive: true });
77
+ for (const file of pages) {
78
+ fs.copyFileSync(path.join(from, file), path.join(to, file));
79
+ }
80
+ if (layouts.length > 0) {
81
+ fs.mkdirSync(path.join(to, config_1.LAYOUT_DIRNAME), { recursive: true });
82
+ for (const file of layouts) {
83
+ fs.copyFileSync(path.join(from, config_1.LAYOUT_DIRNAME, file), path.join(to, config_1.LAYOUT_DIRNAME, file));
84
+ }
85
+ }
86
+ const total = pages.length + layouts.length;
87
+ console.log(`\n📌 Baseline promoted → ${rel(to)}`);
88
+ console.log(` ${pages.length} ${pages.length === 1 ? 'page' : 'pages'}` +
89
+ (layouts.length > 0 ? ` · ${layouts.length} ${layouts.length === 1 ? 'layout' : 'layouts'}` : '') +
90
+ (replaced > 0 ? ` (replaced a baseline of ${replaced} ${replaced === 1 ? 'file' : 'files'})` : ''));
91
+ // Promoting over a reference that had drift in it accepts that drift as correct, silently
92
+ // and irreversibly outside of git. Saying so is the difference between "I meant that" and
93
+ // "I did not know that was what the command did".
94
+ if (replaced > 0) {
95
+ console.log(` Any drift against the old baseline is now the expected state.`);
96
+ }
97
+ console.log(`\n Commit it — a reference that is not in git cannot be reviewed or reverted:`);
98
+ console.log(` git add ${rel(to).replace(/\\/g, '/')} && git commit -m "baseline: ${total} contracts"`);
99
+ console.log(`\n Then change the app, \`ia-qa-heal map\` again, and \`ia-qa-heal diff\` has two`);
100
+ console.log(` different moments to compare.\n`);
101
+ if (args.length > 0) {
102
+ console.log(` (ignored: ${args.join(' ')} — \`baseline\` takes no arguments)\n`);
103
+ }
104
+ }
105
+ function jsonFilesIn(dir) {
106
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
107
+ return [];
108
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.json') && !f.startsWith('.'));
109
+ }
110
+ function rel(p) {
111
+ return path.relative(process.cwd(), p) || p;
112
+ }
113
+ //# sourceMappingURL=baseline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baseline.js","sourceRoot":"","sources":["../../src/cli/baseline.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,10 @@
1
1
  /**
2
- * `ia-qa-heal diff <baseline.json> <current.json> [--strict] [--json] [--dir]`
2
+ * `ia-qa-heal diff [baseline] [current] [--strict] [--json] [--dir]`
3
3
  *
4
- * Two modes:
5
- * file mode (default) diffs two individual mapping files.
4
+ * Three modes:
5
+ * no argumentsthe everyday case: the promoted `baseline/` against what `map` just
6
+ * wrote to `mapping/`. Implies `--dir`.
7
+ * file mode — diffs two individual mapping files.
6
8
  * directory mode (`--dir`) — both args are directories of mapping files.
7
9
  * Finds matching pairs by filename, diffs each, and produces an aggregate
8
10
  * verdict. Layouts in `_layouts/` are diffed first (once instead of N
@@ -11,6 +13,6 @@
11
13
  * Exit codes (so a pipeline can gate on it):
12
14
  * 0 — PASS, or FIX without `--strict`
13
15
  * 1 — BLOCK (a selector broke with no semantic match), or FIX with `--strict`
14
- * 2 — bad usage / unreadable input
16
+ * 2 — bad usage / unreadable input / nothing to compare
15
17
  */
16
18
  export declare function runDiff(args: string[]): Promise<void>;