@ia-qa/self-healing 0.6.0 → 0.8.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
@@ -9,9 +9,11 @@ Self-healing E2E toolkit: a **local MCP server** for AI agents, a guided **CLI**
9
9
  ## Quick start
10
10
 
11
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 diff before.json after.json # → PASS / FIX / BLOCK
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
15
17
  ```
16
18
 
17
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:
@@ -69,22 +71,29 @@ Only if all four fail does it error, listing the three fixes. `map_app` exposes
69
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.
70
72
 
71
73
  Alongside the page contracts, `map` rebuilds the app index and the navigation graph — see [Map artifacts](#map-artifacts).
72
- 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:
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.
75
+
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 purpose — deliberately *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.
77
+
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:
73
80
  ```
74
81
  ⛔ BLOCK
75
82
  before.json → after.json · 3 ok · 2 healable · 1 lost · 2 new
76
83
  🔧 healable button "Se connecter" button#login-btn → button.btn-primary (exact name)
77
84
  ⛔ lost button "Supprimer le compte" button#delete → (no match)
78
85
  ```
79
- 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`.
80
89
 
81
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).
82
91
 
83
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.
84
- 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.
85
94
 
86
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.
87
- 5. **Runtime healing** — in your Playwright tests:
96
+ 6. **Runtime healing** — in your Playwright tests:
88
97
  ```ts
89
98
  import { aiClick, aiFill } from '@ia-qa/self-healing';
90
99
 
@@ -101,10 +110,13 @@ Beyond one contract per page, every `map` run rebuilds two views of the whole ap
101
110
 
102
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.
103
112
 
104
- 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:
113
+ The graph draws **what a page table cannot tell you: pages that link to each other selectively**. Four things are deliberately left out, on one principle — a picture should not spend ink on what a sentence already says:
105
114
  - **shell links** (navbar, footer) — in the overview, listed once, instead of an edge from every page to every navbar destination;
106
115
  - **isolated pages** — a box with no arrow;
107
- - **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.
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
+ - **meshes** — when nearly every mapped page links to nearly every other (the related-tools-widget shape: 16 links between 5 pages), the whole drawing carries one fact — "these pages cross-link" — so the overview states it and no graph is written.
118
+
119
+ Pages **nothing links to** are called out as entry points (orange border in the `.svg` and the Mermaid graph): the way in — a login, a home — or a page only the shell reaches. The map cannot tell which; you can.
108
120
 
109
121
  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.
110
122
 
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>;
package/dist/cli/diff.js CHANGED
@@ -36,12 +36,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.runDiff = runDiff;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
+ const aom_1 = require("../aom");
39
40
  const match_1 = require("../browser/match");
41
+ const config_1 = require("../config");
40
42
  /**
41
- * `ia-qa-heal diff <baseline.json> <current.json> [--strict] [--json] [--dir]`
43
+ * `ia-qa-heal diff [baseline] [current] [--strict] [--json] [--dir]`
42
44
  *
43
- * Two modes:
44
- * file mode (default) diffs two individual mapping files.
45
+ * Three modes:
46
+ * no argumentsthe everyday case: the promoted `baseline/` against what `map` just
47
+ * wrote to `mapping/`. Implies `--dir`.
48
+ * file mode — diffs two individual mapping files.
45
49
  * directory mode (`--dir`) — both args are directories of mapping files.
46
50
  * Finds matching pairs by filename, diffs each, and produces an aggregate
47
51
  * verdict. Layouts in `_layouts/` are diffed first (once instead of N
@@ -50,16 +54,35 @@ const match_1 = require("../browser/match");
50
54
  * Exit codes (so a pipeline can gate on it):
51
55
  * 0 — PASS, or FIX without `--strict`
52
56
  * 1 — BLOCK (a selector broke with no semantic match), or FIX with `--strict`
53
- * 2 — bad usage / unreadable input
57
+ * 2 — bad usage / unreadable input / nothing to compare
54
58
  */
55
59
  async function runDiff(args) {
56
60
  const strict = args.includes('--strict');
57
61
  const json = args.includes('--json');
58
- const dirMode = args.includes('--dir');
59
- const files = args.filter((a) => !a.startsWith('--'));
62
+ let dirMode = args.includes('--dir');
63
+ let files = args.filter((a) => !a.startsWith('--'));
64
+ // No arguments: compare the promoted reference to what `map` just captured. This is what
65
+ // everyone means by "diff", and leaving it unspoken is what pushed people into scripting
66
+ // their own baseline out of a `cp` — the loop this default exists to close.
67
+ if (files.length === 0) {
68
+ const base = (0, config_1.baselineDir)();
69
+ if (!fs.existsSync(base)) {
70
+ console.error(`❌ No baseline to compare against — ${rel(base)} does not exist.\n\n` +
71
+ ` Drift is a claim about two moments, and only one has been captured. Promote it:\n` +
72
+ ` ia-qa-heal map capture the app as it is now\n` +
73
+ ` ia-qa-heal baseline promote that capture to the reference\n\n` +
74
+ ` Then change the app, run \`ia-qa-heal map\` again, and \`ia-qa-heal diff\` has two\n` +
75
+ ` different moments to compare.`);
76
+ process.exitCode = 2;
77
+ return;
78
+ }
79
+ dirMode = true;
80
+ files = [base, (0, config_1.mappingDir)()];
81
+ }
60
82
  if (files.length !== 2) {
61
83
  const mode = dirMode ? '<baseline-dir> <current-dir>' : '<baseline.json> <current.json>';
62
- console.error(`Usage: ia-qa-heal diff ${mode} [--strict] [--json]${dirMode ? '' : ' [--dir]'}`);
84
+ console.error(`Usage: ia-qa-heal diff [${mode}] [--strict] [--json]${dirMode ? '' : ' [--dir]'}\n` +
85
+ ` ia-qa-heal diff (no arguments: baseline/ against mapping/)`);
63
86
  process.exitCode = 2;
64
87
  return;
65
88
  }
@@ -68,6 +91,11 @@ async function runDiff(args) {
68
91
  return;
69
92
  }
70
93
  const [baseline, current] = files.map((f) => readMapping(f));
94
+ if ((0, aom_1.isSameCapture)(baseline, current)) {
95
+ console.error(sameCaptureMessage(baseline.capturedAt));
96
+ process.exitCode = 2;
97
+ return;
98
+ }
71
99
  const report = (0, match_1.diffMappings)(baseline, current);
72
100
  if (json) {
73
101
  console.log(JSON.stringify(report, null, 2));
@@ -93,6 +121,10 @@ async function runDirDiff(baselineDir, currentDir, opts) {
93
121
  process.exit(2);
94
122
  }
95
123
  const reports = [];
124
+ // Pairs where both sides came out of the same `map` run. They cannot show drift, so they
125
+ // are kept out of the verdict rather than counted as `ok` — N ok that were never compared
126
+ // is exactly the false green this guard exists to stop.
127
+ const stale = [];
96
128
  // Diff layouts first
97
129
  const layoutsDir = path.join(baselineDir, '_layouts');
98
130
  if (fs.existsSync(layoutsDir)) {
@@ -106,6 +138,10 @@ async function runDirDiff(baselineDir, currentDir, opts) {
106
138
  try {
107
139
  const baseline = readMapping(baselineFile);
108
140
  const current = readMapping(currentFile);
141
+ if ((0, aom_1.isSameCapture)(baseline, current)) {
142
+ stale.push(`_layouts/${name}`);
143
+ continue;
144
+ }
109
145
  const report = (0, match_1.diffMappings)(baseline, current);
110
146
  reports.push({ name: `_layouts/${name}`, report, isLayout: true });
111
147
  }
@@ -128,6 +164,10 @@ async function runDirDiff(baselineDir, currentDir, opts) {
128
164
  try {
129
165
  const baseline = readMapping(baselineFile);
130
166
  const current = readMapping(currentFile);
167
+ if ((0, aom_1.isSameCapture)(baseline, current)) {
168
+ stale.push(name);
169
+ continue;
170
+ }
131
171
  const report = (0, match_1.diffMappings)(baseline, current);
132
172
  reports.push({ name, report, isLayout: false });
133
173
  }
@@ -135,6 +175,17 @@ async function runDirDiff(baselineDir, currentDir, opts) {
135
175
  console.error(`⚠ Skipping ${name}: ${e instanceof Error ? e.message : String(e)}`);
136
176
  }
137
177
  }
178
+ if (reports.length === 0 && stale.length > 0) {
179
+ console.error(`❌ Nothing to compare: ${stale.length === 1 ? 'the only pair is' : `all ${stale.length} pairs are`} ` +
180
+ `the same capture on both sides.\n\n` +
181
+ ` ${rel(baselineDir)} and ${rel(currentDir)} hold the same \`map\` run, so every file\n` +
182
+ ` would be compared against a copy of itself. A PASS would mean "nothing was compared",\n` +
183
+ ` not "nothing changed".\n\n` +
184
+ ` Change the app and run \`ia-qa-heal map\` again — then the baseline has something to be\n` +
185
+ ` a baseline against.`);
186
+ process.exitCode = 2;
187
+ return;
188
+ }
138
189
  if (reports.length === 0) {
139
190
  console.error('❌ No matching mapping pairs found.');
140
191
  process.exitCode = 2;
@@ -142,6 +193,8 @@ async function runDirDiff(baselineDir, currentDir, opts) {
142
193
  }
143
194
  // Aggregate verdict
144
195
  const aggregate = aggregateReports(reports);
196
+ if (stale.length > 0)
197
+ aggregate.staleExcluded = stale;
145
198
  if (opts.json) {
146
199
  console.log(JSON.stringify(aggregate, null, 2));
147
200
  }
@@ -217,6 +270,12 @@ function printAggregate(agg, reports, baselineDir, currentDir) {
217
270
  `${pr.counts.ok} ok · ${pr.counts.healable} heal · ${pr.counts.lost} lost · ${pr.counts.added} new`);
218
271
  }
219
272
  console.log('');
273
+ if (agg.staleExcluded?.length) {
274
+ const n = agg.staleExcluded.length;
275
+ console.log(` ⏸ ${n} ${n === 1 ? 'contract' : 'contracts'} left out of the verdict — same capture on both`);
276
+ console.log(` sides, so ${n === 1 ? 'it cannot' : 'they cannot'} show drift: ${agg.staleExcluded.join(', ')}`);
277
+ console.log(` Re-map ${n === 1 ? 'it' : 'them'} to bring ${n === 1 ? 'it' : 'them'} back into the gate.`);
278
+ }
220
279
  if (agg.totals.lost > 0) {
221
280
  console.log(' ⛔ Some selectors are lost — a human must fix or delete the test.');
222
281
  }
@@ -225,6 +284,20 @@ function printAggregate(agg, reports, baselineDir, currentDir) {
225
284
  }
226
285
  console.log('');
227
286
  }
287
+ function sameCaptureMessage(capturedAt) {
288
+ return (`❌ Both sides are the same capture (capturedAt ${capturedAt}).\n\n` +
289
+ ` A capture cannot drift from itself, so there is no verdict to give. A PASS here would\n` +
290
+ ` mean "nothing was compared" — not "nothing changed" — and a gate that reports green\n` +
291
+ ` without looking is worse than no gate at all.\n\n` +
292
+ ` Drift needs two moments:\n` +
293
+ ` ia-qa-heal baseline promote this capture to the reference\n` +
294
+ ` …change the app…\n` +
295
+ ` ia-qa-heal map capture it again\n` +
296
+ ` ia-qa-heal diff now there are two moments to compare`);
297
+ }
298
+ function rel(p) {
299
+ return path.relative(process.cwd(), p) || p;
300
+ }
228
301
  function readMapping(file) {
229
302
  let raw;
230
303
  try {