@ia-qa/self-healing 1.6.16 → 1.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.
Files changed (60) hide show
  1. package/README.md +120 -1
  2. package/TUTORIAL.md +31 -4
  3. package/dist/browser/resolve.d.ts +20 -0
  4. package/dist/browser/resolve.js +78 -0
  5. package/dist/browser/resolve.js.map +1 -0
  6. package/dist/cli/args.js +4 -3
  7. package/dist/cli/args.js.map +1 -1
  8. package/dist/cli/audit.d.ts +40 -0
  9. package/dist/cli/audit.js +77 -32
  10. package/dist/cli/audit.js.map +1 -1
  11. package/dist/cli/baseline.js +16 -2
  12. package/dist/cli/baseline.js.map +1 -1
  13. package/dist/cli/check.d.ts +50 -1
  14. package/dist/cli/check.js +63 -25
  15. package/dist/cli/check.js.map +1 -1
  16. package/dist/cli/diff.d.ts +21 -1
  17. package/dist/cli/diff.js +143 -25
  18. package/dist/cli/diff.js.map +1 -1
  19. package/dist/cli/discover.js +9 -0
  20. package/dist/cli/discover.js.map +1 -1
  21. package/dist/cli/fix.js +33 -0
  22. package/dist/cli/fix.js.map +1 -1
  23. package/dist/cli/index.js +53 -2
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/cli/ingest.d.ts +7 -1
  26. package/dist/cli/ingest.js +17 -8
  27. package/dist/cli/ingest.js.map +1 -1
  28. package/dist/cli/login.d.ts +28 -0
  29. package/dist/cli/login.js +179 -0
  30. package/dist/cli/login.js.map +1 -0
  31. package/dist/cli/map.d.ts +19 -3
  32. package/dist/cli/map.js +191 -17
  33. package/dist/cli/map.js.map +1 -1
  34. package/dist/config.d.ts +68 -0
  35. package/dist/config.js +94 -1
  36. package/dist/config.js.map +1 -1
  37. package/dist/crawl.d.ts +2 -0
  38. package/dist/crawl.js +13 -4
  39. package/dist/crawl.js.map +1 -1
  40. package/dist/fixEngine.d.ts +23 -0
  41. package/dist/fixEngine.js +27 -0
  42. package/dist/fixEngine.js.map +1 -1
  43. package/dist/ingest.d.ts +35 -0
  44. package/dist/ingest.js +32 -0
  45. package/dist/ingest.js.map +1 -1
  46. package/dist/launcher.d.ts +7 -0
  47. package/dist/launcher.js +4 -4
  48. package/dist/launcher.js.map +1 -1
  49. package/dist/mcp/server.d.ts +370 -1
  50. package/dist/mcp/server.js +196 -1
  51. package/dist/mcp/server.js.map +1 -1
  52. package/dist/resolution.d.ts +96 -0
  53. package/dist/resolution.js +197 -0
  54. package/dist/resolution.js.map +1 -0
  55. package/dist/ui/page.js +10 -0
  56. package/dist/ui/page.js.map +1 -1
  57. package/dist/ui/server.js +66 -0
  58. package/dist/ui/server.js.map +1 -1
  59. package/package.json +1 -1
  60. package/skills/ia-qa-heal/SKILL.md +25 -1
package/README.md CHANGED
@@ -173,7 +173,63 @@ Everything the tool needs lives in **one JSON file**, written for you by `ia-qa-
173
173
  - **"My diff BLOCKs on links that just… rotate (a news feed, a blog widget)?"** → That is content churn, not UI drift: the feed's links entered the contract as if they were buttons, and the next capture caught the feed mid-rotation. Name what rotates in `"volatile"` (see above) and it never enters a contract again — the diff even prints a hint pointing here when a BLOCK is made of lost external links.
174
174
  - **"The same rows come back as `renamed` every run — a cart counter, a call total, a clock."** → Only the *label* is moving, so only the label should stop being compared: put the pattern in `"nameMask"` (`"Cart (*)"`), not in `"volatile"`. `volatile` would delete the element from the contract — counter, button, href and coverage together — so a real selector break on it would go unnoticed forever; a mask keeps all of that gating and exempts the name alone. The diff prints the exact config line to paste when it spots the pattern. Two masks it refuses to apply, and says so: one that would hide a label your tests locate by name (that is a red suite behind a green gate), and one that would leave two elements of the same role wearing one name (a locator coin flip the mask itself created).
175
175
  - **"How do I add a page behind a click, not a URL?"** → add `steps` to that page (see `billing` above): a list of `{ "click": { "role": "...", "name": "..." } }`. Named by role + accessible name, exactly like the contract — never a CSS selector.
176
- - **"My app spans several domains behind a single sign-on (federated SSO) — does `map` cover it?"** → **Not with one `auth` block.** `auth` runs **one** login against **one** domain, before the first page. A federated IdP that re-challenges each project/domain with its own scope (`?projectName=…`, a different sub-domain per portal) is never satisfied by that single login — the second domain bounces back to a login the first one never covered, and the page maps empty (map now warns when an authenticated target returns 0 elements). Two ways through: **one `.ia-qa/` per domain/portal**, each selected with `--config <dir>` (see **Monorepos** below), or **`ia-qa-heal run` with `IAQA_CAPTURE=1`** so your own suite — which already handles the full SSO dance (cookies, redirects, per-project scope) — does the auth and the contract is captured during that real run. For a multi-portal app, capture-during-run is the intended path; `map --auth` is for single-domain apps.
176
+ - **"My app spans several domains behind a single sign-on (federated SSO) — does `map` cover it?"** → **Not with one `auth` block.** `auth` runs **one** login against **one** domain, before the first page. A federated IdP that re-challenges each project/domain with its own scope (`?projectName=…`, a different sub-domain per portal) is never satisfied by that single login — the second domain bounces back to a login the first one never covered, and the page maps empty (map now warns when an authenticated target returns 0 elements). Two ways through: **one `.ia-qa/` per domain/portal**, each selected with `--config <dir>` (see **Monorepos** below), or **`ia-qa-heal run` with `IAQA_CAPTURE=1`** so your own suite — which already handles the full SSO dance (cookies, redirects, per-project scope) — does the auth and the contract is captured during that real run. And since 1.7.0 there is a third, which is the general one: **`ia-qa-heal login`** — you log in once in a visible browser, whatever the app asks, and `map` reuses that session (see below). For a multi-portal app, capture-during-run is the intended path in CI; `map --auth` is for single-domain apps.
177
+
178
+ ### Behind any login — `ia-qa-heal login`
179
+
180
+ `auth` fills a form: a username field, a password field, a submit button. That is *one shape of
181
+ login*, and plenty of apps do not have it — federated SSO, MFA, a consent screen, a magic link, a
182
+ device check. No list of selectors gets through any of those, so for those apps "add an `auth`
183
+ block" is not an answer at all.
184
+
185
+ ```bash
186
+ ia-qa-heal login # opens a visible browser at your first configured page
187
+ # you log in there, however your app asks
188
+ ia-qa-heal map # reuses that session — no auth block needed
189
+ ```
190
+
191
+ It models nothing about your login, which is exactly why it works anywhere: the human does
192
+ whatever the app requires, and the browser session is saved to `.ia-qa/session.json` in
193
+ Playwright's `storageState` shape. `map` and `discover --crawl` pick it up automatically and say
194
+ so; it takes precedence over `auth` when both exist (you ran `login` *because* the form fill
195
+ could not get through — re-running it silently would be the tool arguing with you).
196
+
197
+ - **That file is a secret.** It holds live cookies: whoever has it is logged in as you. It never
198
+ leaves your machine — nothing here uploads it — and `login` writes `.ia-qa/.gitignore` so a
199
+ commit cannot carry it.
200
+ - **It needs a person.** It refuses without a TTY and under CI, and an AI agent driving the CLI
201
+ cannot perform it: the agent hands the step to you by name.
202
+ - **It expires.** When the session dies, `map` hits the login wall again and says to re-run
203
+ `login` — it never silently maps a login form under the name of your dashboard.
204
+ - **It is not a CI mechanism.** In a pipeline, use `auth` (reproducible, secrets from env/SSM) or
205
+ capture-during-run (`IAQA_CAPTURE=1` + `testCommand`, your own suite logs itself in).
206
+
207
+ `--url <path>` opens a page other than the first one in `config.pages`.
208
+
209
+ #### You already have a session — use it
210
+
211
+ An authenticated Playwright suite usually writes a `storageState` in `globalSetup`. Point at
212
+ it and skip logging in a second time:
213
+
214
+ ```bash
215
+ ia-qa-heal map --session playwright/.auth/user.json # a flag
216
+ IAQA_SESSION=playwright/.auth/user.json ia-qa-heal map # or an env var
217
+ ```
218
+
219
+ ```jsonc
220
+ // …or in .ia-qa/config.json, so every verb picks it up:
221
+ "session": "playwright/.auth/user.json"
222
+ ```
223
+
224
+ Precedence: `--session` → `IAQA_SESSION` → `config.session` → the file `login` wrote. Relative
225
+ paths resolve from the project root, and `--session` works on `map`, `discover` and `login`
226
+ (there it says *where to write*, so a refresh updates the file you already point at).
227
+
228
+ **A session you named and that cannot be read stops the run** — it never falls through to
229
+ "no session". Silently ignoring it would capture the login page under your pages' names, which
230
+ is the one failure this tool treats as worse than stopping. Same for a file that is not a
231
+ storageState: it says so, instead of failing later inside the browser.
232
+
177
233
 
178
234
  ### Monorepos — one config per portal (`--config <dir>`)
179
235
 
@@ -337,6 +393,24 @@ capture → ingest → diff → dry-run of the fixes → your confirmation → f
337
393
  - **After the fix, `run` re-runs your suite once more (without `IAQA_CAPTURE`) to confirm the rewrite is actually green** — a selector fix is only worth anything if the tests pass after it. The exit code follows that re-run: `0` if the suite passes, `1` if it still fails (the rewrite landed but something else is red). `--no-verify` applies the fix and skips the re-run; the re-run is also skipped, with a reason, when nothing was rewritten or there is no `testCommand`.
338
394
  - With `--report`, the HTML for a `run` additionally carries the **applied** `old → new` edits (file and line numbers) and the verify verdict — the file-level before/after the `diff` report only promised.
339
395
 
396
+ ### Already ran your suite with `IAQA_CAPTURE=1`? Just `diff`
397
+
398
+ `run` is a convenience, not the only door. If the capture already happened — your suite ran with
399
+ `IAQA_CAPTURE=1`, in CI or by hand — the verdict is one offline command away:
400
+
401
+ ```bash
402
+ IAQA_CAPTURE=1 npx playwright test # your suite, as usual
403
+ ia-qa-heal diff # merges .capture/ and compares — no browser, no re-run
404
+ ```
405
+
406
+ `diff`, `fix` and `baseline` all merge the staging directory (`.ia-qa/mapping/.capture/`) on the
407
+ way in and say so. **Do not run `map` to "finish" a capture**: `map` opens its own browser and
408
+ starts over, without whatever session your suite had.
409
+
410
+ And on a slow suite, `run` runs it **twice** — once to capture, once to verify the fix. `run
411
+ --no-verify` keeps the first only; the exit code then reflects the diff rather than a re-proved
412
+ green suite.
413
+
340
414
  ### Capture during your test run — `@ia-qa/self-healing/capture`
341
415
 
342
416
  One line at the top of a spec (or wrap your own extended `test` with `withCapture`):
@@ -402,6 +476,51 @@ Statically scans your tests/POMs → `.ia-qa/usage.json`. Two halves, because su
402
476
 
403
477
  Purely static: no execution, no network. A locator built by concatenation or holding `${…}` is invisible here — and invisible to `fix` too, which is why it is skipped rather than guessed at.
404
478
 
479
+ ### Page Objects written in CSS — `_resolved/`
480
+
481
+ Your Page Object says `.btn-primary`. The contract says `#pay-now`. Both name the same
482
+ button, and until they are **bound** every drift row reads `not referenced by your tests` —
483
+ technically true, practically useless, and the reason a CSS-anchored suite used to get
484
+ nothing out of a diff.
485
+
486
+ So `map` binds them: with `usage.json` present (run `ia-qa-heal ingest` first), each
487
+ selector your tests write is evaluated against the page and recorded in
488
+ `.ia-qa/mapping/_resolved/<page>.json`. `baseline` promotes those alongside the contracts,
489
+ and `diff` compares the two moments.
490
+
491
+ What that buys you — an example that no other check in this package can catch:
492
+
493
+ ```
494
+ 🔧 FIX
495
+ 2 ok · 0 renamed · 0 healable · 0 lost ← the contract did not drift at all
496
+
497
+ 🔗 1 selector your tests write no longer reaches what it used to:
498
+ 🔧 .btn-primary → #pay-now
499
+ the element is still there, only its address moved.
500
+ tests/checkout.spec.js:1, tests/checkout.spec.js:4
501
+ ```
502
+
503
+ The button never moved; someone renamed a CSS class. The element diff is spotless, the
504
+ suite is broken, and `ia-qa-heal fix` rewrites the literal in your files.
505
+
506
+ Four outcomes, and only one of them is repaired automatically:
507
+
508
+ | | |
509
+ |---|---|
510
+ | the string now reaches **nothing**, and the element is still there | 🔧 rewritten |
511
+ | the string now reaches a **different** element | ⛔ never rewritten — your test still passes and acts on the wrong thing |
512
+ | the string now reaches **several** elements | ⛔ a coin flip, a human decides |
513
+ | the element it named is **gone** | ⛔ nothing to rewrite towards |
514
+
515
+ And the case that stays quiet: a selector matching something the contract does not hold —
516
+ a row, a cell, a container. That is most of a real Page Object, it is proof the selector
517
+ **works**, and calling it "not found" would bury the four rows above under hundreds of
518
+ false ones.
519
+
520
+ No `usage.json` ⇒ no bindings ⇒ nothing changes. Same rule as name drift: **no inventory,
521
+ no escalation.** Today the bindings are captured by `map`; capture-during-run is specified
522
+ in `SPEC-selector-resolution.md` and not built yet.
523
+
405
524
  ### The rename your suite actually depends on
406
525
 
407
526
  A relabelled element is classified `renamed`: the selector still resolves, the click still lands on the right thing. **For a CSS-anchored suite that is harmless. For a name-anchored one it is fatal** — `getByRole('button', { name: 'Save' })` stops matching the moment the button says "Enregistrer", and a selector-only diff would exit 0 while your suite goes red.
package/TUTORIAL.md CHANGED
@@ -248,14 +248,41 @@ It asks a few questions (your app's URL, whether there's a login, which pages to
248
248
 
249
249
  - **🔑 Credentials** — you store the *name* of an environment variable, never the password itself. The real value stays in your `.env` / shell / AWS. **Loading it is your job:** the CLI reads `process.env` and never opens a `.env` file, so a key sitting only in `.env` will not be seen — run `map` under `npx dotenv-cli --` (or Node's `--env-file-if-exists`, 20.12+). Only `map` logs in; `diff`, `baseline`, `audit` and `check` read the mapping and need no credentials. If a variable is missing, the tool stops and names the exact variable to set — it never guesses and never writes a secret to disk.
250
250
  - **🌐 URLs to visit** — the list of pages to map. A page that lives behind a click instead of its own URL (a tab or modal an app swaps in) is reached by naming the control to click, not by a URL.
251
- - **🔐 One login** — the wizard sets up a single login sequence. **Want to test as several users (admin, then guest)?** Two options: keep a separate `.ia-qa/` config per role — select each with `--config <dir>`, the same flag a monorepo uses to keep one config per app/portal — **or** skip the login wiring entirely and use `ia-qa-heal run` (Step 6.7) — it captures the map *while your own test suite runs*, so whatever logins your tests already do, for however many users (and across however many domains), are covered automatically.
251
+ - **🔐 One login** — the wizard sets up a single login sequence. **And a form fill only covers a login that *is* a form** — SSO with MFA, a consent screen, a magic link and a device check are out of reach of any list of selectors. For those, `ia-qa-heal login` opens a visible browser, you log in once however the app asks, and the session is reused by `map` (Step 5.2a). Already have a `storageState` from your suite's `globalSetup`? Point at it with `--session <file>`, `IAQA_SESSION`, or `"session"` in the config — no second login. **Want to test as several users (admin, then guest)?** Two options: keep a separate `.ia-qa/` config per role — select each with `--config <dir>`, the same flag a monorepo uses to keep one config per app/portal — **or** skip the login wiring entirely and use `ia-qa-heal run` (Step 6.7) — it captures the map *while your own test suite runs*, so whatever logins your tests already do, for however many users (and across however many domains), are covered automatically.
252
252
 
253
253
  The full annotated `config.json` — every field explained, plus a note for AI agents helping you set it up — is in the package's [README on npm](https://www.npmjs.com/package/@ia-qa/self-healing).
254
254
 
255
255
  > 🔐 **Your password is never written to that file** — only the *name* of the environment variable that holds it.
256
256
 
257
- ### Step 5.2b(optional) Find the pages you're *not* testing: `discover`
257
+ ### Step 5.2aBehind a login? `ia-qa-heal login`
258
+
259
+ Most apps worth mapping are behind a login, and only some of those logins are a form. This verb
260
+ models **nothing** about yours, which is why it works anywhere:
261
+
262
+ ```bash
263
+ npx ia-qa-heal login # a real browser window opens at your first configured page
264
+ # you log in there — SSO, MFA, a consent screen, a magic link, all of it
265
+ npx ia-qa-heal map # reuses that session; no "auth" block needed
266
+ ```
267
+
268
+ Nothing is typed for you and no credential is read or stored by the tool. What is saved is the
269
+ **browser session** (`.ia-qa/session.json`) — live cookies, so treat it like a password: it stays
270
+ on your machine, nothing uploads it, and `login` writes `.ia-qa/.gitignore` so a commit cannot
271
+ carry it. It expires like any session; when it does, `map` says so and tells you to run this again
272
+ rather than quietly mapping the login page under your dashboard's name.
258
273
 
274
+ **Already have a session?** An authenticated Playwright suite usually writes a `storageState` in
275
+ `globalSetup` — point at it instead of logging in twice: `ia-qa-heal map --session
276
+ playwright/.auth/user.json` (or `IAQA_SESSION=…`, or `"session"` in `.ia-qa/config.json`).
277
+
278
+ Two things it deliberately will not do: it **refuses without a terminal and under CI** (it waits
279
+ for a person — twice: at the browser, then at the keyboard), and it is **not a CI mechanism**. In a
280
+ pipeline, use `auth` (secrets from env/SSM) or capture-during-run (Step 6.7), where your own suite
281
+ logs itself in. An AI agent driving the CLI cannot perform this step — it will hand it to you.
282
+
283
+
284
+
285
+ ### Step 5.2b — (optional) Find the pages you're *not* testing: `discover`
259
286
  You list a handful of pages to map — but how many pages does your app actually have? `discover` answers that: it reads your whole site (the sitemap, or a safe crawl) and shows you **what you're not covering yet**. It **only suggests** — it changes nothing until you add `--apply`.
260
287
 
261
288
  ```bash
@@ -265,7 +292,7 @@ npx ia-qa-heal discover --crawl --apply # happy with the list? add the missing
265
292
  ```
266
293
 
267
294
  - **The sitemap way** (default) is instant and touches nothing — it treats `www.yoursite.com` and `yoursite.com` as the same site, so you don't have to match them exactly. Its limit: a sitemap lists your *public* pages and stops at the login.
268
- - **The crawl way** (`--crawl`) goes further: it logs in like `map` does and follows links to find the pages behind the login. It's built to be **safe** — it only ever *reads* pages (never clicks "Delete", "Log out", "Pay"…), and to peek inside a menu or dropdown it just **opens** it and moves on. Nothing is submitted, nothing is changed.
295
+ - **The crawl way** (`--crawl`) goes further: it authenticates exactly like `map` does — the same `auth` block, or the same saved session (`ia-qa-heal login` / `--session`) — and follows links to find the pages behind the login. It's built to be **safe** — it only ever *reads* pages (never clicks "Delete", "Log out", "Pay"…), and to peek inside a menu or dropdown it just **opens** it and moves on. Nothing is submitted, nothing is changed.
269
296
 
270
297
  You'll see your coverage gap:
271
298
 
@@ -455,7 +482,7 @@ Three optional config keys make it better (`ia-qa-heal init` asks for them):
455
482
  ```
456
483
  (Other frameworks / non-JS Selenium: write a capture shard directly — the format is documented in the README. The shard, not the import, is the real interface.)
457
484
 
458
- (Playwright: your own extended `test`? Wrap it: `withCapture(myTest)`.) All are **inert without `IAQA_CAPTURE=1`** — a normal run pays nothing. **This is also the way through a multi-domain / federated-SSO app**: `map`'s single login can't reach every portal, but your suite already logs into each — capture rides that real run, with no `auth` block and without ever touching your credentials. (Monorepo with one `.ia-qa/` per portal? Set `IAQA_CONFIG_DIR=apps/xsp` so the shards land in the right one.) No `testCommand` → `run` falls back to `map`.
485
+ (Playwright: your own extended `test`? Wrap it: `withCapture(myTest)`.) All are **inert without `IAQA_CAPTURE=1`** — a normal run pays nothing. **This is also the way through a multi-domain / federated-SSO app**: `map`'s single login can't reach every portal, but your suite already logs into each — capture rides that real run, with no `auth` block and without ever touching your credentials. (No suite yet, or you want `map` to work on its own? `ia-qa-heal login` is the other way in: one browser, one human login, one reused session — see Step 5.2a.) (Monorepo with one `.ia-qa/` per portal? Set `IAQA_CONFIG_DIR=apps/xsp` so the shards land in the right one.) No `testCommand` → `run` falls back to `map`.
459
486
 
460
487
  - **`testPaths`** (e.g. `["tests/"]`) — feeds `ia-qa-heal ingest`, which inventories the selector literals your tests/POMs actually use into `.ia-qa/usage.json`. `diff` then tells you *used 3× in 2 test files* vs *not referenced by your tests*, and `fix` finds your files on its own.
461
488
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Resolve a Page Object's selector strings against the live DOM.
3
+ *
4
+ * Runs inside the browser (serialized by Playwright into `page.evaluate`), so it stays
5
+ * dependency-free like everything else in `src/browser/`. Unlike its neighbours it is
6
+ * **npm-only**: the web tool imports `match.js`, `contract.js` and `extract.js`, and
7
+ * nothing here is needed there — a page contract has no Page Object.
8
+ *
9
+ * Why this exists: `ingest` inventories the literal strings a suite uses (`.btn-primary`),
10
+ * while the contract stores a selector `extract.js` synthesized (`#pay-now`). The two
11
+ * never intersect, so a CSS-written suite gets "0 drifted selectors referenced by your
12
+ * tests" on every run — true, and useless. The missing fact is that both name the same
13
+ * button, and only a browser can establish it.
14
+ *
15
+ * See SPEC-selector-resolution.md. The statuses are load-bearing, `outside` above all:
16
+ * a Page Object is full of selectors for containers, rows and cells, which are real
17
+ * elements the contract deliberately does not hold. Calling those "not found" would print
18
+ * hundreds of false findings on the first run.
19
+ */
20
+ export function resolveSelectors(args: any): {};
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveSelectors = resolveSelectors;
4
+ /**
5
+ * Resolve a Page Object's selector strings against the live DOM.
6
+ *
7
+ * Runs inside the browser (serialized by Playwright into `page.evaluate`), so it stays
8
+ * dependency-free like everything else in `src/browser/`. Unlike its neighbours it is
9
+ * **npm-only**: the web tool imports `match.js`, `contract.js` and `extract.js`, and
10
+ * nothing here is needed there — a page contract has no Page Object.
11
+ *
12
+ * Why this exists: `ingest` inventories the literal strings a suite uses (`.btn-primary`),
13
+ * while the contract stores a selector `extract.js` synthesized (`#pay-now`). The two
14
+ * never intersect, so a CSS-written suite gets "0 drifted selectors referenced by your
15
+ * tests" on every run — true, and useless. The missing fact is that both name the same
16
+ * button, and only a browser can establish it.
17
+ *
18
+ * See SPEC-selector-resolution.md. The statuses are load-bearing, `outside` above all:
19
+ * a Page Object is full of selectors for containers, rows and cells, which are real
20
+ * elements the contract deliberately does not hold. Calling those "not found" would print
21
+ * hundreds of false findings on the first run.
22
+ */
23
+ function resolveSelectors(args) {
24
+ // One argument: Playwright serializes this function into the page and calls it with a
25
+ // single serializable value. Two parameters would silently arrive as one.
26
+ const pomSelectors = args.pom;
27
+ const contractSelectors = args.contract;
28
+ // Element → the contract selector that names it. Built once; first writer wins, so two
29
+ // contract entries resolving to the same node cannot make a binding look ambiguous.
30
+ const contracted = new Map();
31
+ for (const cs of contractSelectors) {
32
+ try {
33
+ const el = document.querySelector(cs);
34
+ if (el && !contracted.has(el))
35
+ contracted.set(el, cs);
36
+ }
37
+ catch {
38
+ /* a contract selector the browser cannot parse costs its own entry, not the page */
39
+ }
40
+ }
41
+ const out = {};
42
+ for (const pom of pomSelectors) {
43
+ let nodes;
44
+ try {
45
+ nodes = Array.prototype.slice.call(document.querySelectorAll(pom));
46
+ }
47
+ catch {
48
+ // Not parseable as a selector — usually a template string the static scan caught
49
+ // mid-concatenation, occasionally a real bug in the test.
50
+ out[pom] = { status: 'invalid' };
51
+ continue;
52
+ }
53
+ if (nodes.length === 0) {
54
+ // Says nothing about whether the element exists: this page, this state, this moment.
55
+ out[pom] = { status: 'unresolved' };
56
+ continue;
57
+ }
58
+ const mapped = [];
59
+ for (const n of nodes) {
60
+ const cs = contracted.get(n);
61
+ if (cs && mapped.indexOf(cs) === -1)
62
+ mapped.push(cs);
63
+ }
64
+ if (nodes.length === 1 && mapped.length === 1) {
65
+ out[pom] = { status: 'bound', selector: mapped[0] };
66
+ }
67
+ else if (mapped.length === 0) {
68
+ // It matches the page, just not anything under contract — a row, a cell, a heading.
69
+ // Proof the selector works, and never a finding.
70
+ out[pom] = { status: 'outside' };
71
+ }
72
+ else {
73
+ out[pom] = { status: 'ambiguous', count: nodes.length };
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ //# sourceMappingURL=resolve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../src/browser/resolve.js"],"names":[],"mappings":";;AAmBA,4CAoDC;AAvED;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,gBAAgB,CAAC,IAAI;IACnC,sFAAsF;IACtF,0EAA0E;IAC1E,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC;IAC9B,MAAM,iBAAiB,GAAG,IAAI,CAAC,QAAQ,CAAC;IACxC,uFAAuF;IACvF,oFAAoF;IACpF,MAAM,UAAU,GAAG,IAAI,GAAG,EAAE,CAAC;IAC7B,KAAK,MAAM,EAAE,IAAI,iBAAiB,EAAE,CAAC;QACnC,IAAI,CAAC;YACH,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;YACtC,IAAI,EAAE,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;gBAAE,UAAU,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QACxD,CAAC;QAAC,MAAM,CAAC;YACP,oFAAoF;QACtF,CAAC;IACH,CAAC;IAED,MAAM,GAAG,GAAG,EAAE,CAAC;IACf,KAAK,MAAM,GAAG,IAAI,YAAY,EAAE,CAAC;QAC/B,IAAI,KAAK,CAAC;QACV,IAAI,CAAC;YACH,KAAK,GAAG,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC;QACrE,CAAC;QAAC,MAAM,CAAC;YACP,iFAAiF;YACjF,0DAA0D;YAC1D,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;YACjC,SAAS;QACX,CAAC;QAED,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,qFAAqF;YACrF,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC;YACpC,SAAS;QACX,CAAC;QAED,MAAM,MAAM,GAAG,EAAE,CAAC;QAClB,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;YACtB,MAAM,EAAE,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YAC7B,IAAI,EAAE,IAAI,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC;gBAAE,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACvD,CAAC;QAED,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9C,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;QACtD,CAAC;aAAM,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/B,oFAAoF;YACpF,iDAAiD;YACjD,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;QACnC,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC;QAC1D,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
package/dist/cli/args.js CHANGED
@@ -16,12 +16,13 @@ exports.rejectUnknownFlags = rejectUnknownFlags;
16
16
  */
17
17
  exports.KNOWN_FLAGS = {
18
18
  init: [],
19
- discover: ['--sitemap', '--crawl', '--no-reveal', '--strict-host', '--apply', '--max', '--depth'],
20
- map: ['--screenshots'],
19
+ discover: ['--sitemap', '--crawl', '--no-reveal', '--strict-host', '--apply', '--max', '--depth', '--session'],
20
+ login: ['--url', '--session'],
21
+ map: ['--screenshots', '--session'],
21
22
  baseline: [],
22
23
  diff: ['--strict', '--json', '--junit', '--dir', '--report', '--open'],
23
24
  fix: ['--dry-run', '--locators', '--dir'],
24
- ingest: [],
25
+ ingest: ['--json'],
25
26
  audit: ['--strict', '--json'],
26
27
  check: ['--strict', '--json', '--offline', '--concurrency'],
27
28
  graph: ['--format', '--kind', '--out', '--open'],
@@ -1 +1 @@
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,EAAE,eAAe,CAAC;IAC3D,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,EAAE,WAAW,CAAC;CACnF,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":";;;AA8BA,gDAeC;AA7CD;;;;;;;;;;;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,EAAE,WAAW,CAAC;IAC9G,KAAK,EAAE,CAAC,OAAO,EAAE,WAAW,CAAC;IAC7B,GAAG,EAAE,CAAC,eAAe,EAAE,WAAW,CAAC;IACnC,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,CAAC,QAAQ,CAAC;IAClB,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC7B,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,eAAe,CAAC;IAC3D,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,EAAE,WAAW,CAAC;CACnF,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"}
@@ -77,5 +77,45 @@ export declare function containsAsWords(haystack: string, needle: string): boole
77
77
  * reading that cannot manufacture a false positive.
78
78
  */
79
79
  export declare function findDeadLocators(usage: Usage, elements: MappedElement[]): DeadLocator[];
80
+ /**
81
+ * Why an audit produced no verdict. Never a PASS — see `auditProject`.
82
+ */
83
+ export type AuditRefusalReason = 'no-mapping' | 'no-inventory' | 'no-files' | 'no-locators' | 'nothing-judgeable';
84
+ export interface AuditRefusal {
85
+ ok: false;
86
+ reason: AuditRefusalReason;
87
+ /** The composed explanation, identical on every surface that states it. */
88
+ message: string;
89
+ pagesMapped: number;
90
+ scanned: number;
91
+ }
92
+ export interface AuditSuccess {
93
+ ok: true;
94
+ report: AuditReport;
95
+ /** Contract size behind the verdict — how much the audit had to look at. */
96
+ elements: number;
97
+ /** Call sites carrying a dead name, not distinct names. */
98
+ deadSites: number;
99
+ selectorCount: number;
100
+ /** Every name-anchored site found, judged or not — the renderer splits them. */
101
+ allSites: NameSite[];
102
+ }
103
+ export type AuditOutcome = AuditSuccess | AuditRefusal;
104
+ /**
105
+ * The audit itself: read the contract, read the inventory, decide.
106
+ *
107
+ * Extracted from `runAudit` so the terminal and the MCP tool `audit_suite` share
108
+ * one computation *and* one wording. Two surfaces re-deriving the same verdict is
109
+ * the CLI/MCP dispatch split this project treats as a defect — and the refusals
110
+ * are the half most worth sharing, because they are the ones a second
111
+ * implementation would quietly turn into a pass.
112
+ *
113
+ * No console, no `process.exitCode`: the caller renders and decides what a
114
+ * refusal costs it.
115
+ */
116
+ export declare function auditProject(opts?: {
117
+ testPaths?: string[];
118
+ strict?: boolean;
119
+ }): AuditOutcome;
80
120
  export declare function runAudit(args: string[]): Promise<void>;
81
121
  export {};
package/dist/cli/audit.js CHANGED
@@ -37,6 +37,7 @@ exports.auditHeadline = auditHeadline;
37
37
  exports.auditCounts = auditCounts;
38
38
  exports.containsAsWords = containsAsWords;
39
39
  exports.findDeadLocators = findDeadLocators;
40
+ exports.auditProject = auditProject;
40
41
  exports.runAudit = runAudit;
41
42
  const fs = __importStar(require("fs"));
42
43
  const path = __importStar(require("path"));
@@ -172,19 +173,34 @@ function findDeadLocators(usage, elements) {
172
173
  }
173
174
  return dead.sort((a, b) => b.sites.length - a.sites.length);
174
175
  }
175
- async function runAudit(args) {
176
- const strict = args.includes('--strict');
177
- const json = args.includes('--json');
178
- const paths = args.filter((a) => !a.startsWith('--'));
176
+ /**
177
+ * The audit itself: read the contract, read the inventory, decide.
178
+ *
179
+ * Extracted from `runAudit` so the terminal and the MCP tool `audit_suite` share
180
+ * one computation *and* one wording. Two surfaces re-deriving the same verdict is
181
+ * the CLI/MCP dispatch split this project treats as a defect — and the refusals
182
+ * are the half most worth sharing, because they are the ones a second
183
+ * implementation would quietly turn into a pass.
184
+ *
185
+ * No console, no `process.exitCode`: the caller renders and decides what a
186
+ * refusal costs it.
187
+ */
188
+ function auditProject(opts = {}) {
189
+ const paths = opts.testPaths ?? [];
190
+ const strict = !!opts.strict;
179
191
  const dir = (0, config_1.mappingDir)();
180
192
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
181
- console.error(`❌ No mapping to audit against — ${path.resolve(dir)} does not exist.\n\n` +
182
- ` \`audit\` reads one capture of the app and asks whether your suite still names\n` +
183
- ` things that exist in it. Capture it first:\n` +
184
- ` ia-qa-heal map\n\n` +
185
- ` No baseline needed — unlike \`diff\`, this works on the very first run.`);
186
- process.exitCode = 2;
187
- return;
193
+ return {
194
+ ok: false,
195
+ reason: 'no-mapping',
196
+ pagesMapped: 0,
197
+ scanned: 0,
198
+ message: `❌ No mapping to audit against — ${path.resolve(dir)} does not exist.\n\n` +
199
+ ` \`audit\` reads one capture of the app and asks whether your suite still names\n` +
200
+ ` things that exist in it. Capture it first:\n` +
201
+ ` ia-qa-heal map\n\n` +
202
+ ` No baseline needed — unlike \`diff\`, this works on the very first run.`,
203
+ };
188
204
  }
189
205
  // Test paths on the command line re-scan in memory; otherwise the inventory on
190
206
  // disk is used, and only if it exists — auditing against a stale file the user
@@ -202,11 +218,15 @@ async function runAudit(args) {
202
218
  }
203
219
  }
204
220
  if (!usage) {
205
- console.error(`❌ No locator inventory to audit.\n\n` +
206
- ` Run \`ia-qa-heal ingest <test-dir>\` first, add "testPaths" to .ia-qa/config.json,\n` +
207
- ` or pass the test paths directly: \`ia-qa-heal audit tests/\`.`);
208
- process.exitCode = 2;
209
- return;
221
+ return {
222
+ ok: false,
223
+ reason: 'no-inventory',
224
+ pagesMapped: 0,
225
+ scanned: 0,
226
+ message: `❌ No locator inventory to audit.\n\n` +
227
+ ` Run \`ia-qa-heal ingest <test-dir>\` first, add "testPaths" to .ia-qa/config.json,\n` +
228
+ ` or pass the test paths directly: \`ia-qa-heal audit tests/\`.`,
229
+ };
210
230
  }
211
231
  const { pages, elements } = readMappings(dir);
212
232
  const dead = findDeadLocators(usage, elements);
@@ -224,7 +244,7 @@ async function runAudit(args) {
224
244
  : allSites.length === 0 && selectorCount === 0
225
245
  ? 'no-locators'
226
246
  : 'nothing-judgeable';
227
- const human = reason === 'no-files'
247
+ const message = reason === 'no-files'
228
248
  ? `❌ Nothing to audit: no test files found${paths.length > 0 ? ` under ${paths.join(', ')}` : ''}.\n\n` +
229
249
  ` A brand-new project has nothing to check yet — that is not a pass, it is an\n` +
230
250
  ` empty question. The ${pages.length} mapped page${pages.length === 1 ? '' : 's'} in .ia-qa/mapping/*.md already list every\n` +
@@ -239,19 +259,15 @@ async function runAudit(args) {
239
259
  ` \`audit\` answers one question — does a locator still NAME something that exists? —\n` +
240
260
  ` and only role-anchored calls (getByRole, getByLabel, By.linkText) can be held to it.\n` +
241
261
  ` A CSS selector says where, not what: use \`map\` + \`baseline\` + \`diff\` to gate those.`;
242
- console.error(human);
243
- if (json) {
244
- console.log(JSON.stringify({ verdict: null, reason, pagesMapped: pages.length, scanned: usage.scanned }, null, 2));
245
- }
246
- process.exitCode = 2;
247
- return;
262
+ return { ok: false, reason, message, pagesMapped: pages.length, scanned: usage.scanned };
248
263
  }
249
264
  const selectorNames = Object.keys(usage.selectors);
250
265
  const mappedSelectors = new Set(elements.map((e) => e.selector));
251
266
  const unresolvedSelectors = selectorNames.filter((s) => !mappedSelectors.has(s));
252
267
  const verdict = dead.length === 0 ? 'PASS' : dead.every((d) => d.suggestion) ? 'FIX' : 'BLOCK';
253
- if (json) {
254
- console.log(JSON.stringify({
268
+ return {
269
+ ok: true,
270
+ report: {
255
271
  verdict,
256
272
  pagesMapped: pages.length,
257
273
  locatorsJudged: judgedSites.length,
@@ -259,7 +275,36 @@ async function runAudit(args) {
259
275
  deadLocators: dead,
260
276
  unresolvedSelectors,
261
277
  advisory: !strict,
262
- }, null, 2));
278
+ },
279
+ elements: elements.length,
280
+ deadSites,
281
+ selectorCount,
282
+ allSites,
283
+ };
284
+ }
285
+ async function runAudit(args) {
286
+ const strict = args.includes('--strict');
287
+ const json = args.includes('--json');
288
+ const paths = args.filter((a) => !a.startsWith('--'));
289
+ const outcome = auditProject({ testPaths: paths, strict });
290
+ if (!outcome.ok) {
291
+ console.error(outcome.message);
292
+ // The two preconditions have nothing to report; the three "ran but cannot
293
+ // speak" reasons do, and `--json` states them so a caller is not left
294
+ // parsing prose for the difference.
295
+ if (json && outcome.reason !== 'no-mapping' && outcome.reason !== 'no-inventory') {
296
+ console.log(JSON.stringify({ verdict: null, reason: outcome.reason, pagesMapped: outcome.pagesMapped, scanned: outcome.scanned }, null, 2));
297
+ }
298
+ process.exitCode = 2;
299
+ return;
300
+ }
301
+ const { report, elements: elementCount, deadSites, selectorCount, allSites } = outcome;
302
+ const { verdict, deadLocators: dead, unresolvedSelectors } = report;
303
+ const pageCount = report.pagesMapped;
304
+ const judged = report.locatorsJudged;
305
+ const unjudgedSites = report.locatorsNotJudged;
306
+ if (json) {
307
+ console.log(JSON.stringify(report, null, 2));
263
308
  process.exitCode = strict && verdict !== 'PASS' ? 1 : 0;
264
309
  return;
265
310
  }
@@ -270,8 +315,8 @@ async function runAudit(args) {
270
315
  const against = (0, summary_1.againstLine)((0, config_1.configuredBaseUrl)());
271
316
  if (against)
272
317
  console.log(against);
273
- console.log(` ${pages.length} mapped page${pages.length === 1 ? '' : 's'} · ${elements.length} elements · no baseline needed`);
274
- console.log(` ${judgedSites.length} role-anchored locator${judgedSites.length === 1 ? '' : 's'} checked` +
318
+ console.log(` ${pageCount} mapped page${pageCount === 1 ? '' : 's'} · ${elementCount} elements · no baseline needed`);
319
+ console.log(` ${judged} role-anchored locator${judged === 1 ? '' : 's'} checked` +
275
320
  (deadSites > 0 ? ` · ${deadSites} name nothing` : ' · all resolve'));
276
321
  if (dead.length > 0) {
277
322
  console.log(`\n ⛔ ${dead.length} locator name${dead.length === 1 ? '' : 's'} match no mapped element:`);
@@ -306,7 +351,7 @@ async function runAudit(args) {
306
351
  }
307
352
  }
308
353
  if (unresolvedSelectors.length > 0) {
309
- console.log(` ℹ️ ${unresolvedSelectors.length} of ${selectorNames.length} CSS selectors appear in no contract —` +
354
+ console.log(` ℹ️ ${unresolvedSelectors.length} of ${selectorCount} CSS selectors appear in no contract —` +
310
355
  ` expected for containers and text nodes, worth a look for the rest.`);
311
356
  }
312
357
  // The two ways a finding can be wrong, named up front. Both are about what the
@@ -315,13 +360,13 @@ async function runAudit(args) {
315
360
  if (dead.length > 0) {
316
361
  console.log(`\n Before believing a finding, check the capture behind it:` +
317
362
  `\n · unmapped page — the locator may live on a page not in config.pages` +
318
- `\n (\`ia-qa-heal discover\` lists them; ${pages.length} page${pages.length === 1 ? ' is' : 's are'} mapped today)` +
363
+ `\n (\`ia-qa-heal discover\` lists them; ${pageCount} page${pageCount === 1 ? ' is' : 's are'} mapped today)` +
319
364
  `\n · unmapped state — \`map\` captures a page in ONE state. A label that only` +
320
365
  `\n appears after a click (a tab, a mode toggle, an open dialog) is not in the` +
321
366
  `\n contract, and a test that clicks first is right while this says otherwise.`);
322
367
  }
323
368
  else {
324
- console.log(`\n Coverage decides what this verdict is worth: ${pages.length} page${pages.length === 1 ? '' : 's'} mapped.` +
369
+ console.log(`\n Coverage decides what this verdict is worth: ${pageCount} page${pageCount === 1 ? '' : 's'} mapped.` +
325
370
  `\n \`ia-qa-heal discover\` lists what you are not mapping yet.`);
326
371
  }
327
372
  if (!strict && verdict !== 'PASS') {
@@ -330,7 +375,7 @@ async function runAudit(args) {
330
375
  else {
331
376
  console.log('');
332
377
  }
333
- (0, summary_1.summaryLine)('audit', verdict === 'PASS' ? 'ok' : verdict === 'FIX' ? 'fix' : 'block', auditHeadline({ verdict, deadLocators: dead, locatorsJudged: judgedSites.length }), auditCounts({ pagesMapped: pages.length, locatorsNotJudged: unjudgedSites }));
378
+ (0, summary_1.summaryLine)('audit', verdict === 'PASS' ? 'ok' : verdict === 'FIX' ? 'fix' : 'block', auditHeadline({ verdict, deadLocators: dead, locatorsJudged: judged }), auditCounts({ pagesMapped: pageCount, locatorsNotJudged: unjudgedSites }));
334
379
  process.exitCode = strict && verdict !== 'PASS' ? 1 : 0;
335
380
  }
336
381
  //# sourceMappingURL=audit.js.map