@ia-qa/self-healing 1.6.6 → 1.6.8

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.
Files changed (61) hide show
  1. package/README.md +118 -2
  2. package/TUTORIAL.md +138 -3
  3. package/assets/logoKawaiiGreenTiny.png +0 -0
  4. package/dist/cli/args.js +4 -2
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/diff.d.ts +6 -1
  7. package/dist/cli/diff.js +114 -4
  8. package/dist/cli/diff.js.map +1 -1
  9. package/dist/cli/emit.d.ts +71 -0
  10. package/dist/cli/emit.js +150 -0
  11. package/dist/cli/emit.js.map +1 -0
  12. package/dist/cli/fix.js +36 -0
  13. package/dist/cli/fix.js.map +1 -1
  14. package/dist/cli/history.d.ts +12 -0
  15. package/dist/cli/history.js +95 -0
  16. package/dist/cli/history.js.map +1 -0
  17. package/dist/cli/index.js +53 -1
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/cli/init.js +32 -0
  20. package/dist/cli/init.js.map +1 -1
  21. package/dist/cli/map.js +60 -0
  22. package/dist/cli/map.js.map +1 -1
  23. package/dist/cli/run.js +77 -7
  24. package/dist/cli/run.js.map +1 -1
  25. package/dist/cli/ui.d.ts +16 -0
  26. package/dist/cli/ui.js +275 -0
  27. package/dist/cli/ui.js.map +1 -0
  28. package/dist/evidence.d.ts +130 -0
  29. package/dist/evidence.js +232 -0
  30. package/dist/evidence.js.map +1 -0
  31. package/dist/history.d.ts +145 -0
  32. package/dist/history.js +230 -0
  33. package/dist/history.js.map +1 -0
  34. package/dist/htmlReport.d.ts +47 -0
  35. package/dist/htmlReport.js +76 -1
  36. package/dist/htmlReport.js.map +1 -1
  37. package/dist/junit.d.ts +53 -0
  38. package/dist/junit.js +150 -0
  39. package/dist/junit.js.map +1 -0
  40. package/dist/mcp/server.js +41 -0
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/ui/icon.d.ts +41 -0
  43. package/dist/ui/icon.js +115 -0
  44. package/dist/ui/icon.js.map +1 -0
  45. package/dist/ui/lock.d.ts +40 -0
  46. package/dist/ui/lock.js +138 -0
  47. package/dist/ui/lock.js.map +1 -0
  48. package/dist/ui/page.d.ts +1 -0
  49. package/dist/ui/page.js +584 -0
  50. package/dist/ui/page.js.map +1 -0
  51. package/dist/ui/registry.d.ts +62 -0
  52. package/dist/ui/registry.js +139 -0
  53. package/dist/ui/registry.js.map +1 -0
  54. package/dist/ui/server.d.ts +67 -0
  55. package/dist/ui/server.js +604 -0
  56. package/dist/ui/server.js.map +1 -0
  57. package/dist/ui/shortcut.d.ts +90 -0
  58. package/dist/ui/shortcut.js +176 -0
  59. package/dist/ui/shortcut.js.map +1 -0
  60. package/package.json +2 -1
  61. package/skills/ia-qa-heal/SKILL.md +27 -0
package/README.md CHANGED
@@ -16,7 +16,29 @@ One command. `ia-qa-heal run` runs your suite, watches it break, diffs the live
16
16
 
17
17
  *Real commands against a live site, real stdout — only the typing speed is synthesised.*
18
18
 
19
- ## Quick start
19
+ ## Easiest start — have your agent set it up
20
+
21
+ If you already work with Claude Code, Cursor, Copilot or another coding agent, don't configure this
22
+ by hand. Paste this to it:
23
+
24
+ ```text
25
+ Install and set up @ia-qa/self-healing in this project.
26
+ 1. First run `npx -y -p @ia-qa/self-healing ia-qa-heal skill --print` and follow it.
27
+ That output is the authoritative doc — do not guess any command or flag from memory.
28
+ 2. Register the MCP server so you can drive it yourself:
29
+ command `npx`, args ["-y", "-p", "@ia-qa/self-healing", "ia-qa-heal-mcp"].
30
+ 3. Read my e2e tests and my router, propose the page list for .ia-qa/config.json,
31
+ and wait for me to confirm before mapping anything.
32
+ 4. Then explain to me, in five lines: what map / baseline / diff / fix each do,
33
+ and which locators you will never rewrite without asking me.
34
+ ```
35
+
36
+ Step 1 matters more than it looks: this package is newer than most models' training data, so an agent
37
+ left to improvise will invent flags that don't exist. `skill --print` hands it the real instructions —
38
+ including [the four prohibitions](#working-with-an-ai-agent--ia-qa-heal-skill) — before it touches
39
+ anything. Step 4 is how you check it actually read them instead of guessing.
40
+
41
+ ## Quick start — by hand
20
42
 
21
43
  ```bash
22
44
  npx -y -p @ia-qa/self-healing ia-qa-heal init # wizard → .ia-qa/config.json
@@ -37,7 +59,7 @@ npx ia-qa-heal init
37
59
 
38
60
  An agent that asks a browser where a button went is doing by hand what `.ia-qa/mapping/*.json` already answers offline: every interactive element's **role, accessible name and selector**, captured. A `link` the suite still calls a `button` shows up in one `grep`.
39
61
 
40
- `ia-qa-heal skill` installs that reflex — plus the rules that must not be broken — as a skill your agent loads:
62
+ `ia-qa-heal skill` installs that reflex — plus the rules that must not be broken — as a skill your agent loads (this is what step 1 of the [paste-block above](#easiest-start--have-your-agent-set-it-up) makes it read):
41
63
 
42
64
  ```bash
43
65
  ia-qa-heal skill # where it would go, and whether it is current — writes nothing
@@ -213,6 +235,56 @@ Run again with --apply to add the uncovered pages to config.pages.
213
235
 
214
236
  Then `ia-qa-heal map` captures their contracts as usual. Discovery never maps, heals, or edits a test — it only ever edits `config.json`, and only with `--apply`.
215
237
 
238
+ ## The console — `ia-qa-heal ui`
239
+
240
+ The report tells you what drifted. The console lets you **act on it**.
241
+
242
+ ```bash
243
+ npx ia-qa-heal ui
244
+ ```
245
+
246
+ …or, for someone who should never have to open a terminal at all:
247
+
248
+ ```bash
249
+ npx ia-qa-heal ui --shortcut # once per project
250
+ ```
251
+
252
+ That puts an icon on the desktop — the ia-qa logo, wired to *this* project. Double-click it: the console starts and the browser opens on it. No URL is ever typed or copied, which is also how the session token stays out of a human's hands. Click it twice and it reuses the console it already opened. **Closing the browser tab stops the server**, so a click never leaves a process running behind your back (`--keep-alive` opts out).
253
+
254
+ It opens a local page with three things the terminal cannot give you:
255
+
256
+ - **The rows waiting on a human** — every `ambiguous`, `lost` and `rebound` row, each with a **highlighted crop** of the candidates as they look on the page right now (needs `map --screenshots`). When five identical "Copy" buttons exist, the row's selector tells you nothing; the picture tells you which one. Alongside them, the `file:line` of every test that will break.
257
+ - **The verbs as buttons** — capture, ingest, diff, audit, check, preview/apply the rewrites — with the output streamed back.
258
+ - **The run history as charts** — verdicts over time, mean coverage, and which pages actually churn.
259
+
260
+ **How it is kept safe**, because a server on a QA's machine is not free:
261
+
262
+ | | |
263
+ |---|---|
264
+ | **Loopback only** | bound to `127.0.0.1`, never `0.0.0.0` — in a container that would be a published port |
265
+ | **A one-time token** | in the URL, required on every request. Without it, *any* page open in your browser could `fetch` the console and read the contracts of your authenticated app, or trigger an action |
266
+ | **Host pinning** | requests whose `Host` is not loopback are refused, which is the DNS-rebinding case a token alone does not cover |
267
+ | **No new authority** | buttons spawn this package's own CLI with a fixed argv from an allowlist — never a string from the request. The console can do **nothing the CLI would refuse**: an `ambiguous` locator is never rewritten from here either |
268
+
269
+ Nothing is uploaded, there is no telemetry and no external asset — the page is inline HTML/CSS/JS and every byte it shows came off your disk. It refuses to start under CI, where an interactive surface would just hang; use `--json`, `--junit` or the exit code there.
270
+
271
+ ### Several projects — the switcher
272
+
273
+ The header carries a **Project** dropdown as soon as there is more than one. It is governed by a single rule:
274
+
275
+ > **A project is reachable only if you opened a console in it.**
276
+
277
+ Nothing scans your disk. `~/.ia-qa/projects.json` is a consent list, and there are exactly two ways to consent: run `ia-qa-heal ui` in a folder, or pick **+ Add another project…** in the dropdown and give the path. Switching between listed projects resolves the requested directory against that list *exactly* — a folder outside it cannot be named. Entries whose project has been deleted are dropped rather than offered, and actions are handed `--config <active project>` explicitly rather than inheriting anything.
278
+
279
+ Adding from the console is a deliberate action by whoever holds the session token — the same token that already authorizes *Repair my test files*. What the list defends against is a **drive-by**: another page in your browser reaching localhost. That is stopped by the token and the `Host` check, not by making you walk to a terminal.
280
+
281
+ ```bash
282
+ ia-qa-heal ui --forget # clear the whole list
283
+ ia-qa-heal ui --forget ../shop # drop one project
284
+ ```
285
+
286
+ Forgetting only removes a folder from the switcher — it deletes nothing in the project, and opening a console there again re-adds it. `IAQA_HOME` relocates the list (useful on a shared machine).
287
+
216
288
  ## Visualize the app — `ia-qa-heal graph`
217
289
 
218
290
  Turn the mappings you already have into a **navigation graph** (page → page, derived from the `href` on each page's links — the same engine `map` uses for `_overview.md`). Read-only: it never rewrites your mapping files, it just renders them.
@@ -611,6 +683,50 @@ jobs:
611
683
 
612
684
  `diff` with no arguments compares the committed `.ia-qa/baseline/` against what `map` just re-captured — which is why the baseline has to be in git. Add `--report drift-report.html` to attach a branded HTML report to the build, and `--strict` to fail on FIX too. The full workflow (report upload, artifact, `--strict`) is in **[TUTORIAL.md → Putting it in CI](TUTORIAL.md#8-putting-it-in-ci)**.
613
685
 
686
+ ### Trend charts for free — `--junit`
687
+
688
+ `diff --junit` and `run --junit` write the verdict as **JUnit XML**, which Jenkins, GitLab, CircleCI, Azure DevOps and the GitHub Actions reporters all ingest natively — including the per-build history graph. You get the trend without this package rendering anything in CI.
689
+
690
+ ```bash
691
+ npx ia-qa-heal diff --junit reports/selector-drift.xml
692
+ ```
693
+
694
+ It is language-agnostic by construction: what it describes is your **contract**, not your suite, so a Cypress/JS, Selenium/Python or Playwright/.NET project produces identically shaped XML.
695
+
696
+ | contract row | JUnit | why |
697
+ |---|---|---|
698
+ | `ok` | pass | the locator still finds the same element |
699
+ | `renamed` | pass + `system-out` | the selector still works; the label moved |
700
+ | `healable` | failure `type="healable"` | broken today — auto-repairable, but broken |
701
+ | `ambiguous` · `lost` · `rebound` | failure, typed | broken, and no deterministic repair exists |
702
+ | name drift | failure `type="name-drift-*"` | a name-anchored locator names nothing now |
703
+ | page not compared | **skipped** | not measured — reporting it as a pass would be the false green this tool exists to prevent |
704
+
705
+ > ⚠️ **Publish it under its own file pattern**, separate from your suite's results. Merged into one trend, the two sets of numbers stop meaning anything.
706
+
707
+ `run --json` (and `diff --json`) give the same verdict as one JSON document on stdout. For `run`, every other line moves to stderr so a pipe stays parseable.
708
+
709
+ ### The trend, locally — `ia-qa-heal history`
710
+
711
+ Every `diff` and `run` appends one line to `.ia-qa/history.jsonl`: verdict, counters, capture source, page coverage and the git commit. It is the one thing no single invocation can reconstruct afterwards, so it is recorded as it happens.
712
+
713
+ ```
714
+ 📈 Run history · 6 recorded
715
+ 6 comparable runs captured by `map` · 67% ended with drift · 100% mean coverage
716
+
717
+ ⛔ BLOCK 8/9/26, 6:25 PM 3 drifted 2/2 pages
718
+ ✅ PASS 8/9/26, 6:26 PM 0 drifted 2/2 pages
719
+
720
+ 🔥 Where the churn lives
721
+ checkout 13 drifted elements across 4 runs
722
+ ```
723
+
724
+ Runs captured **differently** — a `map` baseline versus a live suite capture — are held **out** of the trend and named, never averaged in: the two read a page differently, so charting both would invent a trend out of a capture artifact. Same for a run that covered far fewer pages than the rest.
725
+
726
+ The file is small, deterministic text (unlike `_shots/`), so committing it is defensible if you want the trend to survive across machines and CI.
727
+
728
+ Agents get the same numbers through the MCP tool **`heal_history`** — one `summarize()`, three renderings (terminal, console charts, MCP).
729
+
614
730
  ## Map artifacts
615
731
 
616
732
  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.
package/TUTORIAL.md CHANGED
@@ -12,9 +12,9 @@
12
12
  3. [Before you start](#3-before-you-start)
13
13
  4. [Path A — I use an AI agent (easiest)](#4-path-a--i-use-an-ai-agent-easiest)
14
14
  5. [Path B — I use a terminal](#5-path-b--i-use-a-terminal)
15
- 6. [Your first real workflow](#6-your-first-real-workflow-the-whole-point)
15
+ 6. [Your first real workflow](#6-your-first-real-workflow-the-whole-point) — including [the console, if you don't want a terminal](#step-68--no-terminal-open-the-console)
16
16
  7. [Reading the verdict](#7-reading-the-verdict)
17
- 8. [Putting it in CI](#8-putting-it-in-ci)
17
+ 8. [Putting it in CI](#8-putting-it-in-ci) — [JUnit trends](#82-trend-charts-for-free--junit) · [the run history](#83-the-trend--ia-qa-heal-history)
18
18
  9. [Optional: AI suggestions (BYOK)](#9-optional-ai-suggestions-for-the-lost-ones-byok)
19
19
  10. [Troubleshooting](#10-troubleshooting)
20
20
  11. [FAQ](#11-faq)
@@ -83,7 +83,30 @@ Whatever it uses, it tells you: `Browser: system browser (channel "chrome")`.
83
83
 
84
84
  *Use this if you have Claude Code, Claude Desktop, Cursor, or another MCP-compatible agent. You'll talk to your app in plain English.*
85
85
 
86
- ### Step 4.1Add the tool to your agent
86
+ ### Step 4.0The shortcut: let your agent do the whole setup
87
+
88
+ The steps below are the manual version. If your agent can edit files in your project (Claude Code, Cursor, Copilot in your IDE), just paste this to it and skip straight to step 4.4 below:
89
+
90
+ ```text
91
+ Install and set up @ia-qa/self-healing in this project.
92
+ 1. First run `npx -y -p @ia-qa/self-healing ia-qa-heal skill --print` and follow it.
93
+ That output is the authoritative doc — do not guess any command or flag from memory.
94
+ 2. Register the MCP server so you can drive it yourself:
95
+ command `npx`, args ["-y", "-p", "@ia-qa/self-healing", "ia-qa-heal-mcp"].
96
+ 3. Read my e2e tests and my router, propose the page list for .ia-qa/config.json,
97
+ and wait for me to confirm before mapping anything.
98
+ 4. Then explain to me, in five lines: what map / baseline / diff / fix each do,
99
+ and which locators you will never rewrite without asking me.
100
+ ```
101
+
102
+ Two things to know about that prompt, because they are the difference between it working and it wasting your afternoon:
103
+
104
+ - **Step 1 is not optional politeness.** This package is newer than most models' training data. An agent left to improvise invents flags that don't exist, they fail, and you conclude the tool is broken. `skill --print` hands it the real instructions — including the four things it must never do on its own — before it touches anything.
105
+ - **Step 4 is your check.** If the five-line explanation is vague or mentions a verb that isn't `map`, `baseline`, `diff` or `fix`, it didn't read the skill. Tell it to run step 1 again.
106
+
107
+ You will still have to **restart your agent** (step 4.2 below) after it writes the MCP config — it can't load a server it registered mid-conversation.
108
+
109
+ ### Step 4.1 — Add the tool to your agent (manual)
87
110
 
88
111
  Find your agent's MCP config file and add the `ia-qa-self-healing` block below.
89
112
 
@@ -134,6 +157,8 @@ Then point the config at the built file (use the **absolute** path):
134
157
 
135
158
  ### Step 4.1b — Teach your agent how to use it (recommended)
136
159
 
160
+ *Skip this if you used step 4.0 — that prompt already made your agent do it.*
161
+
137
162
  The MCP block above gives your agent four *tools*. It does not give it the **judgment** to use them: when to read the contract instead of opening a browser, which verb answers which question, and — most importantly — which locators it must never rewrite on its own.
138
163
 
139
164
  ```bash
@@ -434,6 +459,49 @@ Three optional config keys make it better (`ia-qa-heal init` asks for them):
434
459
 
435
460
  `run` keeps every judgment where it was: **BLOCK stops for you**, healable rewrites are shown as a dry run and applied only after you confirm (`--yes` for CI), and nothing is ever committed. **After applying the fix it re-runs your suite once more — without `IAQA_CAPTURE`, so it checks rather than re-maps — and reports whether the tests pass now.** The exit code follows that verdict (`0` green, `1` still failing), so CI catches a fix that rewrote the selector but left the test red. `--no-verify` skips the re-run; with `--report`, the HTML also lists the applied `old → new` edits (file + lines) and the verify verdict.
436
461
 
462
+ ### Step 6.8 — No terminal? Open the console
463
+
464
+ Everything above is a command line. If the person who has to *decide* is a manual QA, that is a
465
+ wall — and the decision is the one thing the tool cannot take from them.
466
+
467
+ ```bash
468
+ ia-qa-heal ui
469
+ ```
470
+
471
+ A local page opens in your browser with three things a terminal cannot give you:
472
+
473
+ - **The rows waiting on a human.** Each `ambiguous` / `lost` / `rebound` row, with a **highlighted
474
+ picture** of each candidate as it looks on the page right now (needs `map --screenshots`), the
475
+ `file:line` of every test that will break, and — the part that matters — **a sentence saying what
476
+ to do about it**. When five identical "Copy" buttons exist, `div:nth-of-type(4) > … > button`
477
+ tells you nothing; the picture tells you which one.
478
+ - **The verbs as buttons**, in plain words: *Look at the app again*, *Check for changes*,
479
+ *Repair my test files ✎*. Every result is a sentence, not an exit code, with the raw output one
480
+ click away.
481
+ - **The run history as charts** — see [§8.3](#83-the-trend-ia-qa-heal-history).
482
+
483
+ Once, per project, so nobody ever types a command again:
484
+
485
+ ```bash
486
+ ia-qa-heal ui --shortcut
487
+ ```
488
+
489
+ That puts an icon on the desktop, bound to *this* project. Double-click → the console starts and
490
+ the browser opens on it. `ia-qa-heal init` offers to do this for you at the end of setup.
491
+
492
+ **What it is not:** it is not a website, and nothing is hosted. The server binds `127.0.0.1` only,
493
+ every request needs a one-time token that lives in the URL the tool opened, and requests whose
494
+ `Host` is not loopback are refused. Closing the browser tab stops the server, so a click never
495
+ leaves a process running behind your back. It can do **nothing the CLI would refuse** — an
496
+ `ambiguous` locator is never rewritten from the console either. It refuses to start under CI, where
497
+ it would simply hang.
498
+
499
+ Working on several projects? The header carries a **Project** dropdown. Pick
500
+ **+ Add another project…** and paste the folder's path — it must already contain a `.ia-qa`
501
+ directory (run `ia-qa-heal init` there once if not). Everything you add is remembered, so it is one
502
+ click from then on, and `ia-qa-heal ui --forget [dir]` takes it back out (deleting nothing inside
503
+ the project).
504
+
437
505
  ---
438
506
 
439
507
  ## 7. Reading the verdict
@@ -523,6 +591,67 @@ misconfigured job fails loudly instead of passing without comparing anything.
523
591
 
524
592
  Add `--strict` if you want **FIX** to fail the build too (forces tests to be updated, not just healed).
525
593
 
594
+ ### 8.2 Trend charts for free — `--junit`
595
+
596
+ Your CI already knows how to chart a test run over time. Give it the verdict in the format it
597
+ speaks and you get the history graph without this tool drawing anything:
598
+
599
+ ```bash
600
+ npx ia-qa-heal diff --junit reports/selector-drift.xml
601
+ ```
602
+
603
+ Jenkins, GitLab, CircleCI, Azure DevOps and the GitHub Actions reporters all ingest JUnit XML —
604
+ including the per-build trend. It is **language-agnostic by construction**: what it describes is
605
+ your *contract*, not your suite, so a Cypress/JS, Selenium/Python or Playwright/.NET project
606
+ produces identically shaped XML.
607
+
608
+ | Your contract | In the CI |
609
+ |---|---|
610
+ | `ok` | pass |
611
+ | `renamed` | pass, with the label change in the output |
612
+ | `healable` | failure, typed `healable` (broken today — repairable, but broken) |
613
+ | `ambiguous` · `lost` · `rebound` | failure, typed |
614
+ | a renamed label your tests use | failure, typed `name-drift-*` |
615
+ | a page that could not be compared | **skipped** |
616
+
617
+ That last row is the one to notice: a page left out of the verdict is **not** a pass, and reporting
618
+ it as `skipped` puts the coverage gap in your CI's own summary instead of only in our log.
619
+
620
+ > ⚠️ **Publish it under its own file pattern**, separate from your suite's results. Merged into one
621
+ > trend, the two sets of numbers stop meaning anything.
622
+
623
+ `--json` gives the same verdict as one document on stdout (`run --json` moves every human line to
624
+ stderr, so a pipe stays parseable).
625
+
626
+ ### 8.3 The trend — `ia-qa-heal history`
627
+
628
+ Every `diff` and `run` appends one line to `.ia-qa/history.jsonl`: verdict, counts, how the capture
629
+ was taken, how many pages it covered, and the git commit. It is the one thing no single run can
630
+ reconstruct afterwards — so it is recorded as it happens, and the runs you have not made yet are
631
+ the only ones you can still get.
632
+
633
+ ```
634
+ $ ia-qa-heal history
635
+
636
+ 📈 Run history · 7 recorded
637
+ 7 comparable runs captured by `map` · 71% ended with drift · 100% mean coverage
638
+
639
+ ⛔ BLOCK 8/9/26, 6:25 PM 3 drifted 2/2 pages
640
+ ✅ PASS 8/9/26, 6:26 PM 0 drifted 2/2 pages
641
+
642
+ 🔥 Where the churn lives
643
+ checkout 16 drifted elements across 5 runs
644
+ ```
645
+
646
+ Runs captured **differently** — a `map` baseline versus a live suite capture — are held **out** of
647
+ the trend and named, never quietly averaged in: the two read a page differently, so charting both
648
+ would invent a trend out of a capture artifact. Same for a run that covered far fewer pages than
649
+ the rest.
650
+
651
+ The file is small, deterministic text (unlike `_shots/`), so committing it is defensible if you
652
+ want the trend to survive across machines and CI. Agents get the same numbers through the
653
+ `heal_history` MCP tool.
654
+
526
655
  ---
527
656
 
528
657
  ## 9. Optional: AI suggestions for the `lost` ones (BYOK)
@@ -655,6 +784,12 @@ npx ia-qa-heal check # is the app sound? dead links · unna
655
784
  npx ia-qa-heal graph --format markdown # the navigation graph, one card per page (RAG chunk / Confluence)
656
785
  npx ia-qa-heal run # the whole loop: capture (your suite, IAQA_CAPTURE=1) → diff → confirm → fix → re-run to verify
657
786
 
787
+ npx ia-qa-heal ui # the local console: decide with pictures, verbs as buttons, history as charts
788
+ npx ia-qa-heal ui --shortcut # once per project: a desktop icon that opens it in one click
789
+ npx ia-qa-heal ui --forget [dir] # drop a project from the console's switcher
790
+ npx ia-qa-heal history [--json] # the trend: verdicts over time, coverage, which pages churn
791
+ npx ia-qa-heal diff --junit reports/x.xml # JUnit XML — your CI charts the trend natively
792
+
658
793
  npx ia-qa-heal skill --install # teach your AI agent the rules (→ .claude/skills/ia-qa-heal/)
659
794
  ```
660
795
 
Binary file
package/dist/cli/args.js CHANGED
@@ -19,14 +19,16 @@ exports.KNOWN_FLAGS = {
19
19
  discover: ['--sitemap', '--crawl', '--no-reveal', '--strict-host', '--apply', '--max', '--depth'],
20
20
  map: ['--screenshots'],
21
21
  baseline: [],
22
- diff: ['--strict', '--json', '--dir', '--report', '--open'],
22
+ diff: ['--strict', '--json', '--junit', '--dir', '--report', '--open'],
23
23
  fix: ['--dry-run', '--locators', '--dir'],
24
24
  ingest: [],
25
25
  audit: ['--strict', '--json'],
26
26
  check: ['--strict', '--json', '--offline'],
27
27
  graph: ['--format', '--kind', '--out', '--open'],
28
- run: ['--yes', '--no-verify', '--locators', '--report', '--open'],
28
+ run: ['--yes', '--no-verify', '--locators', '--report', '--junit', '--json', '--open'],
29
29
  skill: ['--install', '--user', '--dir', '--print', '--force'],
30
+ history: ['--json', '--limit'],
31
+ ui: ['--port', '--no-open', '--shortcut', '--keep-alive', '--forget'],
30
32
  };
31
33
  function rejectUnknownFlags(command, args) {
32
34
  const known = exports.KNOWN_FLAGS[command];
@@ -1 +1 @@
1
- {"version":3,"file":"args.js","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":";;;AA2BA,gDAeC;AA1CD;;;;;;;;;;;GAWG;AACU,QAAA,WAAW,GAAsC;IAC5D,IAAI,EAAE,EAAE;IACR,QAAQ,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,CAAC;IACjG,GAAG,EAAE,CAAC,eAAe,CAAC;IACtB,QAAQ,EAAE,EAAE;IACZ,IAAI,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IAC3D,GAAG,EAAE,CAAC,WAAW,EAAE,YAAY,EAAE,OAAO,CAAC;IACzC,MAAM,EAAE,EAAE;IACV,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC7B,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,CAAC;IAC1C,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC;IAChD,GAAG,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,CAAC;IACjE,KAAK,EAAE,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC;CAC9D,CAAC;AAEF,SAAgB,kBAAkB,CAAC,OAAe,EAAE,IAAc;IAChE,MAAM,KAAK,GAAG,mBAAW,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO;IAChC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QACjC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,SAAS;QAChC,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CACb,eAAe,OAAO,0FAA0F;gBAC9G,4GAA4G;gBAC5G,8FAA8F,CACjG,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,qBAAqB,OAAO,8CAA8C,CAAC,CAAC;IAChH,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"args.js","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":";;;AA6BA,gDAeC;AA5CD;;;;;;;;;;;GAWG;AACU,QAAA,WAAW,GAAsC;IAC5D,IAAI,EAAE,EAAE;IACR,QAAQ,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,CAAC;IACjG,GAAG,EAAE,CAAC,eAAe,CAAC;IACtB,QAAQ,EAAE,EAAE;IACZ,IAAI,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IACtE,GAAG,EAAE,CAAC,WAAW,EAAE,YAAY,EAAE,OAAO,CAAC;IACzC,MAAM,EAAE,EAAE;IACV,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC7B,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,CAAC;IAC1C,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC;IAChD,GAAG,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC;IACtF,KAAK,EAAE,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC;IAC7D,OAAO,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC;IAC9B,EAAE,EAAE,CAAC,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,UAAU,CAAC;CACtE,CAAC;AAEF,SAAgB,kBAAkB,CAAC,OAAe,EAAE,IAAc;IAChE,MAAM,KAAK,GAAG,mBAAW,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO;IAChC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QACjC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,SAAS;QAChC,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CACb,eAAe,OAAO,0FAA0F;gBAC9G,4GAA4G;gBAC5G,8FAA8F,CACjG,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,qBAAqB,OAAO,8CAA8C,CAAC,CAAC;IAChH,CAAC;AACH,CAAC"}
@@ -1,6 +1,6 @@
1
1
  import { Usage } from '../ingest';
2
2
  import { NameDriftFinding } from '../nameDrift';
3
- import { FixOutcome } from '../htmlReport';
3
+ import { FixOutcome, ReportEvidence } from '../htmlReport';
4
4
  /**
5
5
  * `ia-qa-heal diff [baseline] [current] [--strict] [--json] [--dir]`
6
6
  *
@@ -53,6 +53,11 @@ export declare function openInBrowser(file: string): void;
53
53
  */
54
54
  export declare function pageUrlMap(): Map<string, string> | undefined;
55
55
  export declare function pageShotMap(): Map<string, string> | undefined;
56
+ export declare function buildEvidence(reports: Array<{
57
+ name: string;
58
+ report: Report;
59
+ isLayout: boolean;
60
+ }>, currentDir: string): ReportEvidence | undefined;
56
61
  /** Render the drift report to `reportPath` and announce it. Never throws on a bad write path to callers of the gate — reporting is a side effect, not the verdict. */
57
62
  export declare function writeDriftReport(reportPath: string, agg: DirReport, reports: Array<{
58
63
  name: string;
package/dist/cli/diff.js CHANGED
@@ -38,6 +38,7 @@ exports.openHint = openHint;
38
38
  exports.openInBrowser = openInBrowser;
39
39
  exports.pageUrlMap = pageUrlMap;
40
40
  exports.pageShotMap = pageShotMap;
41
+ exports.buildEvidence = buildEvidence;
41
42
  exports.writeDriftReport = writeDriftReport;
42
43
  exports.runDiff = runDiff;
43
44
  exports.printNameDrift = printNameDrift;
@@ -55,7 +56,9 @@ const captureMerge_1 = require("../captureMerge");
55
56
  const nameHint_1 = require("../nameHint");
56
57
  const nameDrift_1 = require("../nameDrift");
57
58
  const htmlReport_1 = require("../htmlReport");
59
+ const evidence_1 = require("../evidence");
58
60
  const summary_1 = require("./summary");
61
+ const emit_1 = require("./emit");
59
62
  /**
60
63
  * `ia-qa-heal diff [baseline] [current] [--strict] [--json] [--dir]`
61
64
  *
@@ -177,10 +180,91 @@ function pageShotMap() {
177
180
  }
178
181
  return shots.size > 0 ? shots : undefined;
179
182
  }
183
+ /**
184
+ * Highlighted crops for every row a human has to decide, assembled from what
185
+ * `map --screenshots` left on disk.
186
+ *
187
+ * Only `ambiguous` and `rebound` rows are considered — the ones where the tool
188
+ * has stopped and handed over. Everything degrades to nothing: no screenshots,
189
+ * no rects, an unreadable mapping, or a page over budget all produce a report
190
+ * identical to the one this function did not exist for.
191
+ *
192
+ * The budget is separate from (and smaller than) the page-thumbnail budget
193
+ * because these images serve a decision rather than an illustration, and a
194
+ * report nobody can open in a browser helps no one. Pages are taken
195
+ * most-decisions-first, so the budget buys the most answers.
196
+ */
197
+ const EVIDENCE_BUDGET_BYTES = 4 * 1024 * 1024;
198
+ function buildEvidence(reports, currentDir) {
199
+ const needing = reports
200
+ // A layout is compared as a set across pages; it has no single screenshot to
201
+ // crop from, so there is nothing here to show for it.
202
+ .filter((r) => !r.isLayout)
203
+ .map(({ name, report }) => ({
204
+ name,
205
+ rows: report.rows.filter((r) => r.status === 'ambiguous' || r.rebound === true),
206
+ }))
207
+ .filter((p) => p.rows.length > 0)
208
+ .sort((a, b) => b.rows.length - a.rows.length);
209
+ if (needing.length === 0)
210
+ return undefined;
211
+ const css = [];
212
+ const rows = {};
213
+ let spent = 0;
214
+ for (const p of needing) {
215
+ const shot = (0, evidence_1.loadPageShot)(p.name);
216
+ if (!shot)
217
+ continue;
218
+ let after;
219
+ try {
220
+ after = JSON.parse(fs.readFileSync(path.join(currentDir, `${p.name}.json`), 'utf8'));
221
+ if (!Array.isArray(after.elements))
222
+ continue;
223
+ }
224
+ catch {
225
+ continue;
226
+ }
227
+ const cls = `pg-${p.name.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
228
+ const pageRows = {};
229
+ let usesImage = false;
230
+ for (const r of p.rows) {
231
+ const ev = (0, evidence_1.rowEvidence)(r, after, shot);
232
+ if (!ev)
233
+ continue;
234
+ if (ev.nowAt || ev.candidates.length > 0)
235
+ usesImage = true;
236
+ pageRows[r.selector] = {
237
+ ...(ev.nowAt ? { nowAt: { ...ev.nowAt, crop: { cls, ...ev.nowAt.crop } } } : {}),
238
+ candidates: ev.candidates.map((c) => ({ ...c, crop: { cls, ...c.crop } })),
239
+ ...(ev.note ? { note: ev.note } : {}),
240
+ };
241
+ }
242
+ if (Object.keys(pageRows).length === 0)
243
+ continue;
244
+ if (usesImage) {
245
+ if (spent + shot.dataUri.length > EVIDENCE_BUDGET_BYTES)
246
+ continue;
247
+ css.push(`.${cls}{background-image:url("${shot.dataUri}")}`);
248
+ spent += shot.dataUri.length;
249
+ }
250
+ rows[p.name] = pageRows;
251
+ }
252
+ if (Object.keys(rows).length === 0)
253
+ return undefined;
254
+ return { css: css.join('\n'), rows };
255
+ }
180
256
  /**
181
257
  * When a directory of contracts was captured. Read off any one of them — `map`
182
258
  * stamps them all in the same run.
183
259
  */
260
+ function isDirectory(p) {
261
+ try {
262
+ return fs.statSync(p).isDirectory();
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
184
268
  function capturedAtOf(dir) {
185
269
  try {
186
270
  const file = fs.readdirSync(dir).find((f) => f.endsWith('.json') && !f.startsWith('.') && !f.startsWith('_'));
@@ -209,6 +293,9 @@ function writeDriftReport(reportPath, agg, reports, baselineDirPath, currentDirP
209
293
  pageShots: pageShotMap(),
210
294
  baselineCapturedAt: capturedAtOf(baselineDirPath),
211
295
  currentCapturedAt: capturedAtOf(currentDirPath),
296
+ // Single-file mode passes two files, not two directories; the evidence for a
297
+ // page lives beside its mapping either way.
298
+ evidence: buildEvidence(reports, isDirectory(currentDirPath) ? currentDirPath : path.dirname(currentDirPath)),
212
299
  });
213
300
  const dest = path.resolve(reportPath);
214
301
  fs.writeFileSync(dest, html, 'utf8');
@@ -229,7 +316,8 @@ function refuseToCompare(reason, human, hint, json) {
229
316
  }
230
317
  async function runDiff(args) {
231
318
  (0, captureMerge_1.mergeCapturesAnnounced)();
232
- const { reportPath: explicitReport, rest: argsNoReport } = takeReportFlag(args);
319
+ const { reportPath: explicitReport, rest: afterReport } = takeReportFlag(args);
320
+ const { value: junitPath, rest: argsNoReport } = (0, emit_1.takeValueFlag)(afterReport, '--junit', emit_1.JUNIT_DEFAULT_PATH);
233
321
  const strict = argsNoReport.includes('--strict');
234
322
  const json = argsNoReport.includes('--json');
235
323
  const open = argsNoReport.includes('--open');
@@ -262,7 +350,7 @@ async function runDiff(args) {
262
350
  return;
263
351
  }
264
352
  if (dirMode) {
265
- await runDirDiff(files[0], files[1], { strict, json, reportPath, open });
353
+ await runDirDiff(files[0], files[1], { strict, json, reportPath, open, junitPath });
266
354
  return;
267
355
  }
268
356
  const [baseline, current] = files.map((f) => readMapping(f));
@@ -284,10 +372,19 @@ async function runDiff(args) {
284
372
  printHuman(report, files, usage);
285
373
  printNameDrift(report.nameDrift);
286
374
  }
375
+ const single = [{ name: path.basename(files[1]).replace(/\.json$/, ''), report, isLayout: false }];
376
+ const singleAgg = aggregateReports(single);
287
377
  if (reportPath) {
288
- const single = [{ name: path.basename(files[1]).replace(/\.json$/, ''), report, isLayout: false }];
289
- writeDriftReport(reportPath, aggregateReports(single), single, files[0], files[1], usage, open);
378
+ writeDriftReport(reportPath, singleAgg, single, files[0], files[1], usage, open);
290
379
  }
380
+ if (junitPath) {
381
+ (0, emit_1.writeJUnit)(junitPath, singleAgg, single, {
382
+ baseUrl: (0, config_1.configuredBaseUrl)(),
383
+ baselineName: path.basename(files[0]),
384
+ currentName: path.basename(files[1]),
385
+ });
386
+ }
387
+ (0, emit_1.recordDriftRun)('diff', singleAgg, { baseUrl: (0, config_1.configuredBaseUrl)() });
291
388
  const failing = report.verdict === 'BLOCK' || (strict && report.verdict === 'FIX');
292
389
  process.exitCode = failing ? 1 : 0;
293
390
  if (!json)
@@ -388,6 +485,19 @@ async function runDirDiff(baselineDir, currentDir, opts) {
388
485
  if (opts.reportPath) {
389
486
  writeDriftReport(opts.reportPath, aggregate, reports, baselineDir, currentDir, usage, opts.open);
390
487
  }
488
+ if (opts.junitPath) {
489
+ (0, emit_1.writeJUnit)(opts.junitPath, aggregate, reports, {
490
+ baseUrl: (0, config_1.configuredBaseUrl)(),
491
+ baselineName: path.basename(baselineDir),
492
+ currentName: path.basename(currentDir),
493
+ });
494
+ }
495
+ (0, emit_1.recordDriftRun)('diff', aggregate, {
496
+ currentDir,
497
+ reports,
498
+ stale,
499
+ baseUrl: (0, config_1.configuredBaseUrl)(),
500
+ });
391
501
  const failing = aggregate.verdict === 'BLOCK' || (opts.strict && aggregate.verdict === 'FIX');
392
502
  process.exitCode = failing ? 1 : 0;
393
503
  if (!opts.json)