npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ia-qa/qa-discovery

v0.11.2

Published

Find out what is in a web app before you test it — every page, form, field and API call it serves, the pages your end-to-end suite never visits, and starting Playwright tests for them. Deterministic and local, with an MCP server for agents.

Readme

@ia-qa/qa-discovery

You have been handed a web app and no documentation. What is in it, and where would testing it start?

ia-qa-discover crawls an app you can reach and writes down what it actually observes: every page it could load, the headings that say what the page is for, every input the app takes, and every API call the browser really made. Deterministic — no LLM decides anything here. Local — it runs a headless browser on your machine and nothing is uploaded, ever. (One separate, optional binary, ia-qa-discover-ai, calls a model with your own key; it is the only thing in this package that sends anything anywhere, and it says so before it does.)

It answers what is there. It does not yet tell you what to test, and it never returns a pass/fail verdict: this is reconnaissance, and pretending otherwise would be the most useful-looking lie a tool like this could tell.

Where this is going. Reconnaissance is step one of a longer goal: reproduce what a senior QA engineer does when handed an unfamiliar app — work out what it is for, where it would hurt most if it broke, and produce a prioritised test plan you can question line by line. Every stage after this one reads the capture below and must cite it, because a plan nobody can interrogate is a plan nobody can trust. ROADMAP.md, shipped in this package, has the arc and what this will deliberately never become. The tutorial is at ia-qa.com/devtools/qa-discovery/tutorial.

npx -p @ia-qa/qa-discovery ia-qa-discover scan https://your-app.example.com --save --report --open

The qa-discovery report run on ia-qa.com itself: 14 pages captured with their titles, headings, input fields and API calls, followed by the API origins the app really contacted and which fields no test could locate durably

▶ See the full report — one self-contained HTML file, written by --report. That is ia-qa.com scanned by its own tool, and it found something: all 37 of its input fields carry no data-testid, no id and no name, so any test written against them can only use a positional selector.

Which verb answers which question

| Your question | Verb | |---|---| | what is even in this app? | scan — pages, headings, forms, fields, real API calls | | my app is behind a login | login — a visible browser, you log in, the session is reused | | most of the inputs are behind a menu or a tab | scan --deep — opens them one level and captures what they reveal | | what does my test suite not test? | coverage — the gap, ranked; needs @ia-qa/self-healing and one watched run | | what changed since last time? | history | | what is each entry point for, and how sensitive? | ia-qa-discover-ai classify (optional, your own key) | | what does somebody come to this page to do? | ia-qa-discover-ai plan (optional, your own key) |

Driving this with an AI agent? One command gives it the whole doctrine — the verb for each question, what every refusal means, and what it must never do:

ia-qa-discover skill --print      # read it
ia-qa-discover skill --install    # drop it in .claude/skills/, where agents actually look

ia-qa-discover <verb> --help for flags. Nothing here emits a verdict and nothing here gates CI: a page count is not coverage, and a gap is a decision for a human.

Same operations for an AI agent — one contract served twice, never a simplified view for one of them. ia-qa-discover-mcp exposes scan_app, coverage_map, classify_app, plan_pages and discovery_history. login is deliberately not a tool: it waits for a person at a browser.

New here? The step-by-step tutorial walks the whole thing from an app you have never seen, with no jargon — first scan, login wall, and "what does my suite not test" (same text as TUTORIAL.md, shipped in this package). This README is the short reference.


What a real session looks like

Every line below is genuine output from scanning a real React app (11 routes, most of them behind a login). Nothing here is a mock-up.

1. First run, anonymous. It finds three pages — and refuses to let that read as success:

$ ia-qa-discover scan https://app.example.com --save --report
Discovering pages from https://app.example.com…
🌐 Using bundled Chromium (shared Playwright cache)
  visited / (2 links)
  visited /terms (1 links)
  visited /policy/cookies (0 links)
Found 3 page(s). Capturing…
  ✔ home
  ✔ terms
  ✔ policy-cookies

✔ 3 of 3 page(s) captured
  + added:   home, policy-cookies, terms

⚠  This app has a login (home), and no session was used.
   What you have is the PUBLIC surface only — whatever is behind the login was never reached.
   Run `ia-qa-discover login` to log in by hand once, then scan again.

Written to .ia-qa-discovery/capture/surface.json
Readable index: .ia-qa-discovery/capture/_overview.md
HTML report:    ia-qa-discover-report.html

2. Log in by hand, once — and only if you have not already. If you logged into this app with ia-qa-heal login, there is nothing to do here: scan picks that session up and says so. Nobody logs into the same app twice on the same machine.

A visible browser opens; you do whatever the app asks. Nothing to confirm afterwards — the window is watched, and the session is saved the moment it actually opens the app.

$ ia-qa-discover login

🔑 ia-qa-discover login
   Opens a visible browser at https://app.example.com/.
   Log in there however your app asks — SSO, MFA, a consent screen, all of it.
   Nothing is typed for you, and no credential is read or stored by this tool.

   Once the session actually opens the app, it is saved to:
     .ia-qa-discovery/session.json
   That file holds live session cookies: anyone who has it is logged in as you.

👀 Log in in the browser window. I am watching it — when the session actually opens
   the app, I save it and carry on by myself. Nothing to come back and confirm here.

✅ Session saved and checked — 7 cookies, 1 origin with local storage.
   Reopened https://app.example.com/dashboard with it in a clean browser: no login wall.

What "checked" means here is the same thing it means in @ia-qa/self-healing, because it is the same code: the session is replayed in a clean browser and only written once it reaches somewhere the logged-out one could not. An app that keeps its token in IndexedDB leaves nothing a session file can hold — that used to print a green tick over an empty file written on top of one that worked.

3. Scan again — and hit the second way a page count lies. The app navigates with React Router's useNavigate(), so there is not one <a href> to follow:

$ ia-qa-discover scan
🔓 Reusing the saved session (.ia-qa-discovery/session.json).
  visited / (0 links)
Found 1 page(s). Capturing…
  ✔ home

✔ 1 of 1 page(s) captured

⚠  No internal links were found, so discovery could not see past the entry URL.
   "1 of 1 found" here means ONE page — not the whole app.
   Discovery follows <a href>. An app that navigates programmatically (React Router's
   navigate(), a button with an onClick handler) exposes no href for a crawl to follow.
   Declare the routes you know in `.ia-qa-discovery/config.json` → "pages", and scan again.

4. Declare the routes (they were sitting in the router file) and get the real surface:

$ ia-qa-discover scan --report
🔓 Reusing the saved session (.ia-qa-discovery/session.json).
Found 9 page(s) (9 declared in config.pages). Capturing…
  ✔ dashboard        ✔ admin-dashboard   ✔ feature-manager
  ✔ user-dashboard   ✔ billing           ✔ marketplace
  ✔ smart-tools      ✔ terms             ✔ policy-cookies

✔ 9 of 9 page(s) captured
  + added:   admin-dashboard, billing, dashboard, feature-manager, marketplace, …

🔓 Scanned with the saved session (.ia-qa-discovery/session.json).

What that scan then told us about the app — three things nobody had noticed, each traced to an observation:

  • All 9 pages share one <title>. The tab, the browser history and a screen reader announcing a route change cannot tell them apart.
  • GET /api/integration/status → 401 and GET /api/billing/payment-history → 404, on a valid session.
  • Every input field on the login form has no id, no name and no data-testid — so any test written against it can only use a positional selector, which breaks the day a field is inserted above it.

5. Re-run any time. An unchanged app must diff clean — that is the whole point of committing capture/:

$ ia-qa-discover scan
✔ 9 of 9 page(s) captured
  (no change since the last scan)

$ ia-qa-discover history
2026-09-04T12:19:39Z  9/9 pages  +9 -1 ~0   300df55 (main)
2026-09-04T12:33:48Z  9/9 pages  +0 -0 ~9   300df55 (main)
2026-09-04T12:35:06Z  9/9 pages  +0 -0 ~0   (no change)  300df55 (main)

What you get

.ia-qa-discovery/
  config.json              # baseUrl, crawl + capture settings, declared pages
  history.jsonl            # one line per scan — the trend no single run can show
  session.json             # only if you ran `login`. Live cookies: gitignored automatically
  capture/
    surface.json           # the manifest — machine-readable index, the entry point for tooling
    _overview.md           # the same scan, readable — start here as a human (or an LLM)
    _shared-calls.json     # calls that fire on most pages (session refresh, telemetry)
    <page>.json            # one capture per page
ia-qa-discover-report.html # optional branded HTML dossier (--report)

capture/ is meant to be committed. It diffs cleanly in git, and re-running the scan tells you what moved.

One page's capture

{
  "schema": "qa-discovery-page@1",
  "page": "checkout",
  "url": "https://app.example.com/checkout",
  "source": "declared",              // "crawl" | "sitemap" | "declared"
  "capturedAt": "2026-09-04T12:00:00.000Z",
  "http": { "status": 200, "redirected": false },
  "meta": { "title": "Checkout", "description": "…", "lang": "en" },
  "headings": [{ "level": 1, "text": "Checkout" }],
  "forms": [{
    "selector": "form#checkout",
    "method": null,                  // null = the DOM states none (a JS-handled submit)
    "action": null,                  // never synthesized from the page URL
    "hasSubmit": true,
    "fields": [
      { "type": "email", "name": "email", "selector": "#email",
        "stableSelector": true, "required": true }
    ]
  }],
  "looseFields": [                   // inputs NOT inside any <form> — most React apps
    { "type": "search", "name": "q", "selector": "#sidebar > input:nth-of-type(1)",
      "stableSelector": false, "required": false, "context": "main › Filters" }
  ],
  "apiCalls": [
    { "method": "GET", "urlPattern": "/api/cart?page=2", "sameOrigin": true,
      "origin": "https://app.example.com", "status": 200, "resourceType": "fetch", "count": 1 }
  ]
}

Three fields carry a promise worth knowing about:

  • stableSelector: false — the field has no data-testid, no id, no name. The selector given is positional: it resolves today and breaks the moment a field is inserted above it. That is a fact about the app, not a caveat about this tool, and it is worth fixing before a suite is written against it.
  • ambiguousSelector: true — rarer, and stronger: no selector could be made to resolve to exactly one element. Nothing downstream may act on that field. Uniqueness is verified in the page, never assumed.
  • method: null / action: null — the form states neither. A form with no action submits to the current URL per the HTML spec, but a React form usually has none because submission never reaches the network. Reporting the page URL there would invent a POST target the app never declared.

Commands

scan [url]

Crawl and capture. With --save, the URL is remembered and later runs are just ia-qa-discover scan.

| Flag | | |---|---| | --depth <n> | crawl link-depth (default 2) | | --max <n> | page cap (default 60) | | --no-reveal | do not open menus/dropdowns looking for hidden links | | --strict-host | treat www. and the apex as different hosts | | --session <file> | reuse an existing Playwright storageState | | --no-network | skip API/network capture this run | | --network-threshold <n> | share threshold for promoting a call, 0-1 (default 0.6) | | --save | persist this baseUrl to config.json | | --report [file.html] | write the HTML dossier (default ia-qa-discover-report.html) | | --open | open that report in a browser | | --json | the whole result as one parseable document on stdout |

login

Opens a visible browser, you log in by hand, and the session is saved for scan to reuse.

This verb models nothing on purpose. A declarative auth block describes one shape of login — a username field, a password field, a submit button — and every app whose login is not that shape is out of reach: federated SSO, MFA, a consent screen, a magic link, a device check. Since you perform the login yourself, all of them work.

It refuses under CI and without a TTY: it waits for a person at a browser, so a pipeline that runs it just hangs. An agent cannot run this — it must ask you to. For automation, point --session at a storageState your own setup already writes.

And often you do not need it at all: a session saved by ia-qa-heal login is picked up when this project has none of its own, announced on the scan (sessionSource: "healing") and never written into your config. Nobody logs into the same app twice on the same machine.

The same goes for pages: when this project declares none, the pages .ia-qa/config.json declares (the ones ia-qa-heal and ia-qa-pal tour) are scanned too, announced on the scan and never written into your config. A crawl follows <a href> only, so an app that opens its pages with buttons would otherwise be scanned as its landing page. A declaration with steps is a state reached by clicking, not a URL, and is left out.

The file it produces holds live cookies — whoever has it is logged in as you. The path is announced before the browser opens, .ia-qa-discovery/.gitignore is written on the way out, and nothing here uploads it.

coverage

What your test suite does not test. The one thing neither package can say alone: this one knows what exists, @ia-qa/self-healing knows where your suite actually went.

npx ia-qa-discover coverage

Reads local files only — no browser, no network, no argument. It compares the pages this scan found against the pages your suite was observed visiting, and ranks the gap by what each page takes as input: credentials, form submissions, required fields. The factors are the ranking — there is no score, because a single number invites tuning and hides that a page ranks high for a reason you may consider irrelevant.

One factor comes from the other package: how often a page has actually changed, read from healing's run history. A page that changes every week and that no test visits is where bugs are born; a page untouched for six months does not need a new test — and no scan of an app can see the difference. Runs where more than half the mapped pages drifted at once are held out and counted: that describes a stale baseline or a changed capture, not volatile pages, and including them made every page carry the factor, which ranks nothing. Below three comparable runs the factor is simply absent, and the report says why.

How much a page holds — its field and call counts — is shown beside the factors and never counted with them. Describing a page and ranking it are two jobs: merged, 1 input · 1 API call out-ranked a page that had actually changed three times.

It also reports depth: how many of each visited page's contracted elements your tests actually name. Named is not asserted — a test that clicks a button names it without checking anything, and nothing on disk can tell the two apart, so this never says "tested".

It needs a suite that was watched running. That means @ia-qa/self-healing installed, ia-qa-heal map, and one ia-qa-heal run. Without it you get a refusal that names the command, not a number: a contract means somebody configured a page, and only a watched run means a test went there. "No measurement" and "zero coverage" are different sentences, and only one of them would be true.

It joins two moments, and says which one is stale. The map compares a suite run against a scan, and those happen at different times — so both dates are printed, and when they fall on different days the consequence is spelled out. The two errors run opposite ways: a suite run older than the scan makes "not tested" include did not exist yet, which is no gap in your suite at all; a scan older than the run means your tests may already visit pages nobody scanned, so the gap is wider on paper than in reality. Stating the gap without the stale half is how a number gets quoted in a meeting with none of its caveats.

No percentage, ever. A 34% reads as code coverage — a ratio over a denominator a compiler guarantees. Here the denominator is uncertain by construction: a login wall, an app that navigates without <a href>, the page cap, states nothing opened. Counts and page names only, always beside what the scan could not see.

Writes .ia-qa-discovery/coverage-map.json (committable, diffable) and refreshes _overview.md. --report [file.html] [--open] for the branded dossier, --json for the map on stdout.

Closing a line. A gap list that never shrinks stops being read. Declare pages you deliberately do not test in outOfScope in config.json — same glob dialect as self-healing's volatile:

{ "outOfScope": ["legal-*", "styleguide"] }

They stay counted and named as out of scope, never silently dropped, and nothing is ever added to that list on its own.

generate

A starting suite for the pages nothing tests — written so healing can repair it.

npx ia-qa-discover generate

Writing test code is not the scarce thing; playwright codegen has done it for years. What it cannot do is decide what to record, and what nobody does is make the result survive the app changing. With no argument this generates one spec per page in the coverage gap — the pages that exist and that no test visits — and every locator it writes names an element the healing contract holds. So when a label changes, the file is rewritten instead of going red.

That loop is not a claim: it is executed on every commit, on this verb's own output — generated, drifted, repaired, resolving again, with no human edit.

What it refuses to write, and why that is the feature. Healing rewrites calls that state a role — getByRole, getByLabel, getByPlaceholder, By.linkText — and nothing else. getByText names a string, not an element, so nothing can prove the test meant the renamed button rather than a heading that never moved. And one such line does not merely stay unrepaired: it holds the verdict at BLOCK, which stops the repairable lines beside it from being repaired too. So a field that cannot be named by role, label or placeholder is left out, never written in a weaker form.

Left out for the same reason: a field with no test id, id or name. Its only selector is positional, and it addresses a different element the moment a field is inserted above it — untestable by anyone, not only by this tool. Each omission is named at the top of the file it was omitted from, because a silent gap reads as a finished test.

A page it did not actually capture is left out too. If the scan was shown a login form, or the app answered 404, then the capture is of that — and a spec written from it asserts a login or an error page under a feature's name, in a file that lands in your suite and gets run. It stays green for exactly as long as the app stays broken. The sibling package already refuses the same thing from the other side: map will not write a contract named dashboard that describes a login form. So those pages are held out of the default selection, counted and named with the reason, and the remedy offered (a session gets the scan past a wall). Naming a page generates for it anyway — the rule shapes a default, it does not overrule you — and --include-login-walls / --include-error-pages do it in bulk.

The assertions are yours. An application declares what it accepts — required, type, min, max — and never what it promises. "A refused card shows the right message" lives in someone's head, so the navigation and the actions are generated and what the app should do is a TODO with the question written out.

Files land in .ia-qa-discovery/generated/ and nothing is added to your suite. An existing file is never replaced without --force. If the output directory is outside healing's testPaths, the verb says so and names both remedies — out of reach means never repaired, which would quietly make the whole promise false.

--out <dir> to write them where your suite lives, --json for a machine-readable summary, or name pages explicitly to generate for them anyway.

history

The trend no single scan can reconstruct: pages, forms, API surface, and what changed run over run.

Global

--config <dir> resolves .ia-qa-discovery/ somewhere else (also IAQA_DISCOVERY_CONFIG_DIR).


A page count is not coverage

This is the failure mode this tool works hardest to avoid, because every version of it looks like success. Three checks exist for it, and each one fires in the terminal, in _overview.md and in the HTML report:

The login wall. An app whose catch-all route renders the login page answers HTTP 200 at every URL. Without a check, a scan reports "3 of 3 pages captured" having captured the same wall three times. Detection is a password field observed in the DOM — inside a <form> or not — and only one the page shows when you arrive. A field you had to click to reveal is the opposite of a wall: under --deep, an authenticated page with a business interaction behind a disclosure (change your password, confirm an action) was reclassified as a login wall, measured on a real portal with a valid session. The capture records which fields were revealed, so the signal ignores those.

Where the page list comes from. Three sources, merged and deduped by path, and the report says which contributed what:

  1. sitemap.xml (falling back to robots.txt) — one HTTP GET, no browser, and the only source that does not care how the app navigates. Read first, and its hits also seed the crawl. --no-sitemap skips it. Apex and www. are folded together, so an apex sitemap against a www. baseUrl works — that mismatch is the most common way the two silently disagree.
  2. The crawl — follows <a href> from the entry URL, same-origin GET only, never a URL that acts (/logout, /delete…).
  3. config.pages — what you declare.

⚠️ A sitemap describes the public surface. It is written for search crawlers, and crawlers are not logged in — so on an app whose real surface is behind a login, expect it to declare the marketing pages and nothing else. Measured on one: the sitemap listed 3 pages; the app had 11. Behind a wall, login plus config.pages is still the answer.

And the cap is a budget, not a census: if more pages are known than --max allows, the scan says how many it left out rather than quietly shrinking the denominator.

Href blindness. Discovery follows <a href>. An app that navigates programmatically — React Router's useNavigate(), a button with an onClick handler — exposes no href at all, so the crawl reaches exactly the entry URL and reports "1 of 1 found", i.e. 100%. Measured on a real app: 11 routes, zero <Link>, one page found. When zero internal links are harvested, the scan says so and points at config.pages.

Declared pages. The way out of href blindness — the routes are usually sitting in your router file in plain sight:

{
  "baseUrl": "https://app.example.com",
  "pages": [
    { "name": "billing",     "url": "/billing" },
    { "name": "marketplace", "url": "/marketplace" }
  ]
}

Declared pages are scanned in addition to whatever the crawl finds, deduped by path — declaring some does not turn discovery off.

The loaded state is one state. A page shows some of its input surface on load and hides the rest behind a click — a dialog, a tab, an accordion, a "create" button. Measured on a real app: one page carried 64 clickable controls and 2 visible fields, so a load-only capture described about 3% of its entry points while looking complete.

Every scan therefore counts those controls and says so, whether or not you explore. --deep then opens them one level down and captures what they reveal, each field carrying the click that reaches it:

$ ia-qa-discover scan --deep
🔍 --deep opened 120 control(s): 2 field(s) found that are not visible at load.
   ⚠ 2 page(s) hit the click budget, so their exploration is partial.

| Field | Type | Where / how to reach | Locatable | | --- | --- | --- | --- | | (unnamed) | text | 🔍 click “$ search_tools…Ctrl+K” | ⚠ positional |

Three things keep it safe and honest:

  • Every non-GET request is blocked for the duration of the walk, routed on the page — a click cannot mutate anything server-side. A name denylist is the second layer, for handlers that issue no request at all (a "log out" that just clears storage).
  • One level only. @ia-qa/self-healing measured its own equivalent: depth 1 finished in 59 s over 43 clicks; depth 2 took 183 s over 150 clicks and never completed at any budget. A permanently partial capture is a false green wearing a feature's clothes.
  • A selector may not name two elements. Exploration unions captures taken in different states, and a positional selector can address one element at load and another after a click. A revealed field whose selector is already spoken for is dropped and counted — reached but not captured is a coverage loss, and this tool states those rather than emit a selector that means two things.

--deep roughly doubles scan time and is deterministic in practice: two consecutive runs over an unchanged app diff clean.


Using it with @ia-qa/self-healing

They are two halves of the same loop, and neither replaces the other.

| | @ia-qa/qa-discovery | @ia-qa/self-healing | |---|---|---| | Question | What is in this app? | My tests broke — did a selector move? | | Needs | a URL | an existing test suite | | Output | app surface, no verdict | PASS / FIX / BLOCK, a CI gate | | Verb | scan | map |

⚠️ scan and map are not the same operation. map writes a page contract — every interactive element by role and accessible name, for repairing broken locators. scan writes a surface — pages, headings, inputs, API calls, for deciding what to test. Do not expect one to produce the other.

scan vs ia-qa-heal discover — the one that actually gets confused

Self-healing has its own crawling verb, and the names do not make the difference obvious. They answer different questions, and if yours is the first one you do not need this package:

| | ia-qa-heal discover | ia-qa-discover scan | |---|---|---| | Answers | Which routes are missing from my config.pages? | What does this app take as input, and where would testing start? | | Gives you | { name, url } candidates | the surface behind each URL | | Then what | --apply appends to config.json | you know what there is to test |

Measured on the same four pages of ia-qa.com: discover returns 4 {name, url} pairs; scan returns those four pages plus 159 headings, 3 input fields with their captured labels, 9 observed API calls, and the verdict that none of the three fields can be located durably. That is not a richer rendering of the same data — it is a different dataset.

So: if you only want to fill config.pages, use ia-qa-heal discover and stop there. Reach for scan when you need to know what the pages contain, or when you have no suite at all — which is the case self-healing's own README opens by excluding.

Discovery → healing. Once you know the routes, hand them to healing and start writing tests against contracts:

ia-qa-discover scan https://app.example.com --save   # find the surface
ia-qa-heal init                                       # then map it for test maintenance

config.pages here has the same { name, url } shape as self-healing's, and both packages resolve page names through the same function (pageNameFromUrl), so a page called checkout in one is checkout in the other. The two sets of artifacts line up 1:1.

Healing → discovery — and the gap is computed, not just described. Healing knows what your suite covers; discovery knows what exists. When a project already has .ia-qa/mapping/, scan ends by stating the difference:

🔗 This project also uses `ia-qa-heal`: it holds contracts for 3 page(s), this scan found 4.
   2 page(s) exist here with no contract: `all-tools`, `resources`
   That is the gap between what the app has and what the suite holds — not a verdict:
   a page with no contract may be deliberately out of scope. You decide which.

Comparable only because both packages resolve page names through the same pageNameFromUrl. It is not a verdict and this package gates nothing — an uncovered page may be out of scope on purpose. A contracted page this scan did not reach is reported as a limit of the scan (--max, crawl reach, session), never as a page that disappeared. On a project with no .ia-qa/, nothing is printed at all.

Sessions are interchangeable. Both consume a Playwright storageState, so a session from either login verb works for the other: ia-qa-discover scan --session ../.ia-qa/session.json.


MCP server

npx -y -p @ia-qa/qa-discovery ia-qa-discover-mcp

A dependency-free JSON-RPC 2.0 stdio server exposing the same operations the CLI runs — a human and an agent get an identical contract, never a simplified view for one of them.

  • scan_app — crawl and capture. Check loginWall and hrefBlind in the result before reporting coverage.
  • classify_app — the optional BYOK layer below, for an agent. The key is read from an environment variable named in the call (api_key_env); a raw key is never a tool argument.
  • plan_pages — the other BYOK question: what somebody comes to each page to do. Same key handling, and it sends less — no selectors, no observed API calls. Defaults to the pages coverage_map says no test visits, in that tool's order. Ranks nothing, gates nothing; a failed page is a failed call, never a finding about the app.
  • coverage_map — what the suite does not test. Returns measured: false with a typed reason when no run was ever watched: report that as no measurement, never as "the suite covers nothing".
  • discovery_history — the trend.

login is deliberately not a tool: it waits for a person at a browser, which an agent cannot be. Every path and URL an agent supplies is checked against the project root and the configured origin.


ia-qa-discover-ai — optional, BYOK, and the only thing here that sends anything

Everything above is deterministic and never leaves your machine. This one binary is the exception, and it is a separate binary precisely so that boundary is a thing you install rather than a flag you might forget.

ia-qa-discover-ai classify --dry-run          # see what would be sent, and to whom — no key, no call
ia-qa-discover-ai classify --dry-run --json   # …with the exact prompt of every page
ia-qa-discover-ai classify

ia-qa-discover-ai plan --dry-run       # the other question, and a smaller payload
ia-qa-discover-ai plan --report --open

The --dry-run --json prompts come from the same builders the real calls use, and a test compares them with what the request carries, prompt for prompt — which is what lets ia-qa-pal ui show them before anything is sent.

Two verbs, two questions, and plan sends strictly less than classify: no selectors and no observed API calls ever leave for it. Each announces its own payload, derived from the function that builds it, so neither can promise less than it sends.

It reads the capture offline — it opens no browser and never touches your app again — and asks a model what each entry point is for (authentication, payment, search, data-entry…) and how sensitive what it handles is. The vocabulary is closed and versioned: a label outside it is rejected before anything else is checked.

Every claim cites the capture, and every citation is resolved and checked. A classification whose evidence does not exist, or does not say what it was claimed to say, is dropped before you see it, and the drop is reported. That proves the premise, never the conclusion — a model can cite a real password field and still be wrong about what the page is for — so read the why and the confidence, not the label alone.

Three things it will not do: it is not a gate and emits no verdict; a refused entry point is an answer needing a person, not a retry; and a failed call is reported as a failed call, never as "this page has nothing on it".

Configure it in .ia-qa-discovery/config.json — the file holds the name of the variable, never the key:

"ai": { "provider": "anthropic", "model": "claude-haiku-4-5",
        "apiKey": { "source": "env", "key": "ANTHROPIC_API_KEY" } }

// or any OpenAI-compatible endpoint — DeepSeek, Groq, Mistral, OpenRouter, vLLM,
// or a model on your own machine, in which case nothing leaves it at all:
"ai": { "provider": "openai-compatible", "model": "deepseek-chat",
        "baseUrl": "https://api.deepseek.com/v1",
        "apiKey": { "source": "keychain", "key": "DEEPSEEK_KEY" } }

// Azure OpenAI — the resource endpoint, and the DEPLOYMENT name as the model:
"ai": { "provider": "azure-openai", "model": "my-gpt4o-deployment",
        "baseUrl": "https://<resource>.openai.azure.com",
        "apiKey": { "source": "env", "key": "AZURE_OPENAI_API_KEY" } }
// …or the portal's full Target URI as baseUrl: the deployment it names is the one called.

// a model on this machine takes no key at all:
"ai": { "provider": "openai-compatible", "model": "llama3.1",
        "baseUrl": "http://localhost:11434/v1" }

"mode": "local" in .ia-qa/config.json — the one setting the whole toolkit reads — locks this binary and the classify_app / plan_pages MCP tools: nothing is sent, whatever the ai block says (hybrid, the default, is what this section describes). The model and the key are resolved by the same function in every package, CLI and MCP alike.

An env reference whose variable is unset is answered by this machine's credential store under the same name (ia-qa-heal secret set, or the store form in ia-qa-pal ui); a variable set in the shell always wins, so CI is unchanged. .env files are never read.

Before the first request it prints what leaves and where it goes:

🌐 Sending 3 pages to api.anthropic.com  (anthropic · claude-haiku-4-5) — your key, your account.
   Leaves this machine: each page's URL, title and description, up to 25 headings,
   every observed API call (method, normalised path, status), each form's method and
   action URL, and for every field its name, type, label, whether it is required, the
   text around it and the click path that reveals it. Headings, labels, that surrounding
   text and those click labels are live text from your app.
   Does NOT leave: your CSS selectors. Measured on 57 real fields, a selector adds no
   word that the name, label or type does not already carry — only its position.
   Query values are stripped at capture EXCEPT an allowlist that includes `q` — a search
   term reaches the model as typed (`capture.safeQueryParams` in config.json narrows it).
   Does NOT leave: page HTML, screenshots, cookies or your session file, your test files,
   your API key (sent as a header to api.anthropic.com only).

Secret-bearing query values never reach that payload, and never reach the capture either: a URL you scan is visited as you gave it, but what gets recorded — surface.json, _overview.md, history.jsonl, each page file — keeps the query keys and drops their values (token, password, session, auth, jwt, email, key… — a deny rule that overrides the allowlist, so widening safeQueryParams cannot reopen it). Scanning a password-reset or magic link is the ordinary case; scan says which values it dropped. A page URL you declared yourself in config.json is the one copy this tool will not rewrite — it is your file — so scan warns instead.

Read that list before pointing it at an authenticated or private app. The result lands in .ia-qa-discovery/classification.json and in _overview.md, which is rewritten so the reading appears next to the capture it was made from.

plan — what somebody comes to this page to do

classify answers what an entry point is, against a closed vocabulary. plan answers a different question, on a different axis: what is a person trying to do here, and what can they no longer do if it breaks — in the words of whoever uses the app, not a developer's.

It exists because that axis is the one nothing else in this ecosystem can reach. The deterministic half reports what an app accepts — fields, types, required, what moved. Nothing in a DOM states that this form is how a locked-out customer gets back in. Neither layer is a subset of the other, which is why one never ranks above the other.

ia-qa-discover-ai plan                 # the pages `coverage` says no test visits, in its order
ia-qa-discover-ai plan --all           # every readable page, tested or not
ia-qa-discover-ai plan --report --open # the same branded dossier `scan --report` writes

By default it reads exactly the untested pages from ia-qa-discover coverage, in the order that verb ranked them — so an interrupted run has read the ones that mattered most. With no suite tracked here there is no untested list to narrow to, and it reads every page and says so rather than refusing: a project with no tests is the one this package exists for.

Four properties, and each is tested:

  • It sends less than classify — no selectors, no observed API calls. The most sensitive half of a capture, and technical noise for a question that is not technical.
  • Every reading cites the capture and is dropped if the citation does not resolve, exactly like a classification. A path the prompt never offered is refused before resolution, so citing an API call — which this verb does not send — cannot happen.
  • The shell is not a page's content. Headings repeated across the app (a footer's Legal, Contact, Tools) are dropped from the payload, using the same threshold autoLayout uses. Measured before the change: a page whose whole interest was 🗂️ Environment Manager had its reading propped up by three footer links — citations that resolve, and support nothing. A page whose every heading is shared keeps them all: the exclusion narrows the evidence, it never makes a page unreadable.
  • At least one citation must carry the subject — a level-1 or level-2 heading, a form, or a field. A reading standing entirely on nav labels is dropped. The rule is skipped on a page that offers no such anchor, because a rule nobody can satisfy would punish a page for its own markup.
  • It ranks nothing and gates nothing. The order is coverage's measurement; this adds a labelled line beside it, never a row to it.
  • The origin travels with the artifact. plan.json carries the model and the timestamp, one module renders the terminal, _overview.md and the HTML report, a page re-captured after it was read is marked stale, and the report's footer stops saying "no LLM".

A model that cannot tell answers unclear: kept, labelled, never silently dropped — the capture may genuinely not say, and a proposal nobody can anchor is still worth reading as long as it is marked as one.

Its honest limit, measured on a real run of 8 pages: the reading is worth most where the deterministic half is blind — a page with no form at all, whose headings name the stake — and thins out on content pages, where "somebody comes here to read the articles" repeats the capture back. Read the confidence and the citations, not the sentence alone. After the two rules above, that same run produced 0 citations pointing at the footer or the nav, against three on a single page before them.


Library

import { scanApp, readHistory } from '@ia-qa/qa-discovery';

const result = await scanApp({ url: 'https://app.example.com', write: false });
console.log(result.pagesCaptured, result.loginWall, result.hrefBlind);

Requirements

Node ≥ 18, and Playwright (peerDependency) with a Chromium available — npx playwright install chromium, or point at a browser you already have via IAQA_BROWSER_CHANNEL=chrome.

License

MIT