@ia-qa/qa-discovery 0.3.0 → 0.5.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 (52) hide show
  1. package/README.md +109 -3
  2. package/ROADMAP.md +37 -4
  3. package/TUTORIAL.md +85 -2
  4. package/dist/ai/classify.d.ts +13 -36
  5. package/dist/ai/classify.js +23 -166
  6. package/dist/ai/classify.js.map +1 -1
  7. package/dist/ai/model.d.ts +93 -0
  8. package/dist/ai/model.js +192 -0
  9. package/dist/ai/model.js.map +1 -0
  10. package/dist/ai/plan.d.ts +224 -0
  11. package/dist/ai/plan.js +0 -0
  12. package/dist/ai/plan.js.map +1 -0
  13. package/dist/cli/args.js +2 -0
  14. package/dist/cli/args.js.map +1 -1
  15. package/dist/cli/generate.d.ts +56 -0
  16. package/dist/cli/generate.js +293 -0
  17. package/dist/cli/generate.js.map +1 -0
  18. package/dist/cli/index.d.ts +1 -1
  19. package/dist/cli/index.js +32 -0
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/cli/skill.d.ts +20 -0
  22. package/dist/cli/skill.js +63 -0
  23. package/dist/cli/skill.js.map +1 -0
  24. package/dist/cli-ai/index.d.ts +3 -2
  25. package/dist/cli-ai/index.js +268 -4
  26. package/dist/cli-ai/index.js.map +1 -1
  27. package/dist/coverage.d.ts +52 -2
  28. package/dist/coverage.js +67 -7
  29. package/dist/coverage.js.map +1 -1
  30. package/dist/coverageView.js +17 -1
  31. package/dist/coverageView.js.map +1 -1
  32. package/dist/generate.d.ts +62 -0
  33. package/dist/generate.js +163 -0
  34. package/dist/generate.js.map +1 -0
  35. package/dist/htmlReport.js +20 -4
  36. package/dist/htmlReport.js.map +1 -1
  37. package/dist/index.d.ts +6 -0
  38. package/dist/index.js +32 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/mcp/server.d.ts +49 -0
  41. package/dist/mcp/server.js +143 -20
  42. package/dist/mcp/server.js.map +1 -1
  43. package/dist/overview.d.ts +9 -0
  44. package/dist/overview.js +9 -4
  45. package/dist/overview.js.map +1 -1
  46. package/dist/overviewFile.js +5 -0
  47. package/dist/overviewFile.js.map +1 -1
  48. package/dist/planView.d.ts +141 -0
  49. package/dist/planView.js +345 -0
  50. package/dist/planView.js.map +1 -0
  51. package/package.json +3 -2
  52. package/skills/ia-qa-discover/SKILL.md +166 -0
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- declare const HELP = "ia-qa-discover \u2014 deterministic surface reconnaissance for a web app\n\nF1 of QA Discovery: crawl an app's same-origin pages and capture what each one\nis structurally about \u2014 title, headings, forms, and observed API calls. No\nLLM, no verdict: this is recon, not a gate. Runs entirely on your machine;\nnothing is uploaded anywhere.\n\n[FOR AI AGENTS] You are driving this for a human. Run `scan` to capture the\napp's surface, then read .ia-qa-discovery/capture/surface.json (the index) and\nthe per-page files it points at \u2014 that is the evidence for anything you\nreport about this app. Never assert a page's purpose or risk from the URL\nalone; ground it in the captured meta/headings/forms/apiCalls. Never read a\npage count as coverage: if scan warns that pages still show a login form, you\nmeasured the wall, not the app. You CANNOT run `login` \u2014 it waits for a human\nat a browser; ask them to run it, then scan again.\n`coverage` answers \"what does their suite NOT test\" \u2014 but only from a suite that was\nwatched running (one `ia-qa-heal run`, or one `ia-qa-pal tour --suite`). If it says there is no\nmeasurement, report that, never \"the suite covers nothing\": a contract means someone\nconfigured a page, only a watched run means a test went there.\n\nUsage:\n ia-qa-discover login Open a visible browser, log in by hand, keep the\n session (.ia-qa-discovery/session.json) for scan\n to reuse. The general answer to a login wall: it\n models nothing, so SSO, MFA, a consent screen or\n a magic link all work \u2014 you do them.\n Needs a terminal, refuses under CI. The file holds\n live cookies: gitignored, stays on this machine.\n --url <path> page to open (default: your baseUrl)\n --session <f> where to write it\n Already have a storageState (a Playwright globalSetup\n writes one)? Skip this verb: point scan at it with\n --session <file> or \"session\" in config.json.\n ia-qa-discover scan [url] Crawl same-origin pages from <url> (or the saved\n baseUrl) and capture each page's structure.\n Writes .ia-qa-discovery/capture/, diffs against\n the previous run, appends history.jsonl.\n --depth <n> crawl link-depth (default 2)\n --max <n> page cap (default 60)\n --deep also open menus, tabs and dialogs one level\n down and capture the fields they reveal. Every\n non-GET request is blocked for the duration,\n so a click cannot mutate anything server-side.\n --deep-budget <n> max controls clicked per page (default 60)\n --no-sitemap do not read sitemap.xml / robots.txt\n --no-reveal do not open menus/dropdowns for hidden links\n --strict-host treat www./apex as different hosts\n --session <file> reuse an existing storageState (login) file\n --no-network skip API/network-call capture this run\n --network-threshold <n> shared-call promotion threshold 0-1 (default 0.6)\n --save persist this baseUrl to config.json\n --json machine-readable summary on stdout\n --report [file.html] branded ia-qa.com HTML dossier of the\n app (default ia-qa-discover-report.html)\n --open open that report in the browser\n ia-qa-discover coverage What your test suite does NOT test. Compares the\n pages this scan found against the pages your suite\n was OBSERVED visiting, ranks the gap by what each\n page takes as input, and writes coverage-map.json.\n Reads local files only \u2014 no browser, no network.\n Needs one watched run of your suite \u2014\n \"ia-qa-heal run\" or \"ia-qa-pal tour --suite\":\n without a watched run there is no measurement, and\n it says so instead of showing a number.\n Declare pages you deliberately do not test in\n \"outOfScope\" in config.json \u2014 they stay counted\n and named, never silently dropped.\n --json machine-readable map on stdout\n --report [file.html] branded HTML dossier with the gap\n (default ia-qa-discover-coverage.html)\n --open open that report in the browser\n ia-qa-discover history Recorded scan runs \u2014 pages/forms/API-surface trend.\n --json --limit <n> (default 500)\n ia-qa-discover version Print the installed version (also --version, -v)\n ia-qa-discover help Show this help\n\nGlobal:\n --config <dir> Resolve .ia-qa-discovery/ under <dir> instead of the\n current directory. Also IAQA_DISCOVERY_CONFIG_DIR=<dir>.\n";
2
+ declare const HELP = "ia-qa-discover \u2014 deterministic surface reconnaissance for a web app\n\nF1 of QA Discovery: crawl an app's same-origin pages and capture what each one\nis structurally about \u2014 title, headings, forms, and observed API calls. No\nLLM, no verdict: this is recon, not a gate. Runs entirely on your machine;\nnothing is uploaded anywhere.\n\n[FOR AI AGENTS] You are driving this for a human. Run `scan` to capture the\napp's surface, then read .ia-qa-discovery/capture/surface.json (the index) and\nthe per-page files it points at \u2014 that is the evidence for anything you\nreport about this app. Never assert a page's purpose or risk from the URL\nalone; ground it in the captured meta/headings/forms/apiCalls. Never read a\npage count as coverage: if scan warns that pages still show a login form, you\nmeasured the wall, not the app. You CANNOT run `login` \u2014 it waits for a human\nat a browser; ask them to run it, then scan again.\n`coverage` answers \"what does their suite NOT test\" \u2014 but only from a suite that was\nwatched running (one `ia-qa-heal run`, or one `ia-qa-pal tour --suite`). If it says there is no\nmeasurement, report that, never \"the suite covers nothing\": a contract means someone\nconfigured a page, only a watched run means a test went there.\n\nUsage:\n ia-qa-discover login Open a visible browser, log in by hand, keep the\n session (.ia-qa-discovery/session.json) for scan\n to reuse. The general answer to a login wall: it\n models nothing, so SSO, MFA, a consent screen or\n a magic link all work \u2014 you do them.\n Needs a terminal, refuses under CI. The file holds\n live cookies: gitignored, stays on this machine.\n --url <path> page to open (default: your baseUrl)\n --session <f> where to write it\n Already have a storageState (a Playwright globalSetup\n writes one)? Skip this verb: point scan at it with\n --session <file> or \"session\" in config.json.\n ia-qa-discover scan [url] Crawl same-origin pages from <url> (or the saved\n baseUrl) and capture each page's structure.\n Writes .ia-qa-discovery/capture/, diffs against\n the previous run, appends history.jsonl.\n --depth <n> crawl link-depth (default 2)\n --max <n> page cap (default 60)\n --deep also open menus, tabs and dialogs one level\n down and capture the fields they reveal. Every\n non-GET request is blocked for the duration,\n so a click cannot mutate anything server-side.\n --deep-budget <n> max controls clicked per page (default 60)\n --no-sitemap do not read sitemap.xml / robots.txt\n --no-reveal do not open menus/dropdowns for hidden links\n --strict-host treat www./apex as different hosts\n --session <file> reuse an existing storageState (login) file\n --no-network skip API/network-call capture this run\n --network-threshold <n> shared-call promotion threshold 0-1 (default 0.6)\n --save persist this baseUrl to config.json\n --json machine-readable summary on stdout\n --report [file.html] branded ia-qa.com HTML dossier of the\n app (default ia-qa-discover-report.html)\n --open open that report in the browser\n ia-qa-discover coverage What your test suite does NOT test. Compares the\n pages this scan found against the pages your suite\n was OBSERVED visiting, ranks the gap by what each\n page takes as input, and writes coverage-map.json.\n Reads local files only \u2014 no browser, no network.\n Needs one watched run of your suite \u2014\n \"ia-qa-heal run\" or \"ia-qa-pal tour --suite\":\n without a watched run there is no measurement, and\n it says so instead of showing a number.\n Declare pages you deliberately do not test in\n \"outOfScope\" in config.json \u2014 they stay counted\n and named, never silently dropped.\n --json machine-readable map on stdout\n --report [file.html] branded HTML dossier with the gap\n (default ia-qa-discover-coverage.html)\n --open open that report in the browser\n ia-qa-discover generate [page\u2026] A starting Playwright suite for the pages nothing\n tests \u2014 one file per page, into its own directory.\n Every locator names an element the healing contract\n holds, so the file is REPAIRED when the app moves\n instead of going red. A field with no test id, id or\n name is left out and says so: its selector would be\n positional, which nobody can test reliably.\n Proposals \u2014 nothing is added to your suite, and the\n assertions stay yours (the app declares what it\n accepts, never what it promises).\n With no page, it generates for the coverage gap.\n --out <dir> where to write (default\n .ia-qa-discovery/generated/)\n --force replace files already there\n --json machine-readable summary on stdout\n ia-qa-discover skill This CLI's agent instructions: the verb for each\n question, what every refusal means, and what an\n agent must never do. Read them before driving\n anything else here.\n --print write them to stdout and stop\n --install write .claude/skills/ia-qa-discover/\n --user into ~/ instead of this project\n --dir <d> into another project root\n --force overwrite an edited copy\n ia-qa-discover history Recorded scan runs \u2014 pages/forms/API-surface trend.\n --json --limit <n> (default 500)\n ia-qa-discover version Print the installed version (also --version, -v)\n ia-qa-discover help Show this help\n\nGlobal:\n --config <dir> Resolve .ia-qa-discovery/ under <dir> instead of the\n current directory. Also IAQA_DISCOVERY_CONFIG_DIR=<dir>.\n";
3
3
  declare function verbHelp(command: string | undefined): string | null;
4
4
  declare function main(): Promise<void>;
5
5
  export { HELP, verbHelp, main };
package/dist/cli/index.js CHANGED
@@ -10,6 +10,8 @@ const scan_1 = require("./scan");
10
10
  const login_1 = require("./login");
11
11
  const history_1 = require("./history");
12
12
  const coverage_1 = require("./coverage");
13
+ const generate_1 = require("./generate");
14
+ const skill_1 = require("./skill");
13
15
  // dist/cli/ → package root; npm ships package.json in every tarball.
14
16
  const VERSION = require('../../package.json').version;
15
17
  const HELP = `ia-qa-discover — deterministic surface reconnaissance for a web app
@@ -83,6 +85,30 @@ Usage:
83
85
  --report [file.html] branded HTML dossier with the gap
84
86
  (default ia-qa-discover-coverage.html)
85
87
  --open open that report in the browser
88
+ ia-qa-discover generate [page…] A starting Playwright suite for the pages nothing
89
+ tests — one file per page, into its own directory.
90
+ Every locator names an element the healing contract
91
+ holds, so the file is REPAIRED when the app moves
92
+ instead of going red. A field with no test id, id or
93
+ name is left out and says so: its selector would be
94
+ positional, which nobody can test reliably.
95
+ Proposals — nothing is added to your suite, and the
96
+ assertions stay yours (the app declares what it
97
+ accepts, never what it promises).
98
+ With no page, it generates for the coverage gap.
99
+ --out <dir> where to write (default
100
+ .ia-qa-discovery/generated/)
101
+ --force replace files already there
102
+ --json machine-readable summary on stdout
103
+ ia-qa-discover skill This CLI's agent instructions: the verb for each
104
+ question, what every refusal means, and what an
105
+ agent must never do. Read them before driving
106
+ anything else here.
107
+ --print write them to stdout and stop
108
+ --install write .claude/skills/ia-qa-discover/
109
+ --user into ~/ instead of this project
110
+ --dir <d> into another project root
111
+ --force overwrite an edited copy
86
112
  ia-qa-discover history Recorded scan runs — pages/forms/API-surface trend.
87
113
  --json --limit <n> (default 500)
88
114
  ia-qa-discover version Print the installed version (also --version, -v)
@@ -158,9 +184,15 @@ async function main() {
158
184
  case 'coverage':
159
185
  await (0, coverage_1.runCoverage)(rest);
160
186
  break;
187
+ case 'generate':
188
+ await (0, generate_1.runGenerate)(rest);
189
+ break;
161
190
  case 'history':
162
191
  await (0, history_1.runHistory)(rest);
163
192
  break;
193
+ case 'skill':
194
+ await (0, skill_1.runSkill)(rest);
195
+ break;
164
196
  case 'version':
165
197
  case '--version':
166
198
  case '-v':
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `ia-qa-discover skill` — install this CLI's agent instructions.
3
+ *
4
+ * The markdown is the payload; the verb is the load-bearing half. Agents load skills from
5
+ * `.claude/skills/`, never from `node_modules`, so a `SKILL.md` that only ships in the
6
+ * tarball is a copy of the doctrine nobody reads.
7
+ *
8
+ * The implementation is `@ia-qa/self-healing`'s, imported rather than copied: three packages
9
+ * needing the identical verb is exactly the case where a third copy starts drifting — and it
10
+ * would drift in the file whose whole subject is "the instructions must reach the agent".
11
+ * Only the name, the binary and this package's root differ.
12
+ */
13
+ export declare const SKILL_NAME = "ia-qa-discover";
14
+ export declare const skillSourcePath: () => string;
15
+ export declare const readSkill: () => string;
16
+ export declare const skillTargetDir: (opts: {
17
+ user?: boolean;
18
+ dir?: string;
19
+ }) => string;
20
+ export declare const runSkill: (args: string[]) => Promise<void>;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runSkill = exports.skillTargetDir = exports.readSkill = exports.skillSourcePath = exports.SKILL_NAME = void 0;
37
+ const path = __importStar(require("path"));
38
+ const self_healing_1 = require("@ia-qa/self-healing");
39
+ /**
40
+ * `ia-qa-discover skill` — install this CLI's agent instructions.
41
+ *
42
+ * The markdown is the payload; the verb is the load-bearing half. Agents load skills from
43
+ * `.claude/skills/`, never from `node_modules`, so a `SKILL.md` that only ships in the
44
+ * tarball is a copy of the doctrine nobody reads.
45
+ *
46
+ * The implementation is `@ia-qa/self-healing`'s, imported rather than copied: three packages
47
+ * needing the identical verb is exactly the case where a third copy starts drifting — and it
48
+ * would drift in the file whose whole subject is "the instructions must reach the agent".
49
+ * Only the name, the binary and this package's root differ.
50
+ */
51
+ exports.SKILL_NAME = 'ia-qa-discover';
52
+ const verb = (0, self_healing_1.createSkillVerb)({
53
+ name: exports.SKILL_NAME,
54
+ cli: 'ia-qa-discover',
55
+ // `dist/cli/` and `src/cli/` are both two levels under the package root, so this resolves
56
+ // from the published build and from the sources under test alike.
57
+ packageRoot: path.join(__dirname, '..', '..'),
58
+ });
59
+ exports.skillSourcePath = verb.skillSourcePath;
60
+ exports.readSkill = verb.readSkill;
61
+ exports.skillTargetDir = verb.skillTargetDir;
62
+ exports.runSkill = verb.runSkill;
63
+ //# sourceMappingURL=skill.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill.js","sourceRoot":"","sources":["../../src/cli/skill.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA6B;AAC7B,sDAAsD;AAEtD;;;;;;;;;;;GAWG;AACU,QAAA,UAAU,GAAG,gBAAgB,CAAC;AAE3C,MAAM,IAAI,GAAG,IAAA,8BAAe,EAAC;IAC3B,IAAI,EAAE,kBAAU;IAChB,GAAG,EAAE,gBAAgB;IACrB,0FAA0F;IAC1F,kEAAkE;IAClE,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,CAAC;CAC9C,CAAC,CAAC;AAEU,QAAA,eAAe,GAAG,IAAI,CAAC,eAAe,CAAC;AACvC,QAAA,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;AAC3B,QAAA,cAAc,GAAG,IAAI,CAAC,cAAc,CAAC;AACrC,QAAA,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC"}
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { CLASSIFICATION_FILENAME, CLASSIFICATION_SCHEMA } from '../ai/classify';
3
3
  export { CLASSIFICATION_SCHEMA, CLASSIFICATION_FILENAME };
4
- declare const HELP = "ia-qa-discover-ai \u2014 optional BYOK classification for a discovery capture\n\nWhat each entry point your app exposes is FOR, and how sensitive what it handles\nis. Reads .ia-qa-discovery/capture/ offline \u2014 it never opens a browser and never\ntouches your application. Your key, your model, your machine.\n\nEvery classification carries a sentence you read and a path into the capture that\nthis tool resolves and checks before keeping it. A claim that cites nothing, cites\nsomething that does not exist, or misdescribes what it cites, is DROPPED and the\ndrop is reported. That check proves the premise, never the conclusion: only you\ncan say whether the reading follows.\n\nNothing here is a gate. `ia-qa-discover` works fully without a key.\n\nUsage:\n ia-qa-discover-ai classify Classify every entry point in the capture.\n --page <name> just this page\n --json machine-readable output\n --dry-run show what would be sent, call nothing\n ia-qa-discover-ai models List the providers and models you can configure\n ia-qa-discover-ai version Print the installed version\n ia-qa-discover-ai help Show this help\n\nConfigure it in .ia-qa-discovery/config.json:\n\n \"ai\": {\n \"provider\": \"anthropic\",\n \"model\": \"claude-haiku-4-5\",\n \"apiKey\": { \"source\": \"env\", \"key\": \"ANTHROPIC_API_KEY\" }\n }\n\nThe key itself never goes in that file \u2014 only the name of the variable holding it.\n\nGlobal:\n --config <dir> Resolve .ia-qa-discovery/ under <dir> instead of the current directory\n";
4
+ declare const HELP = "ia-qa-discover-ai \u2014 optional BYOK classification for a discovery capture\n\nWhat each entry point your app exposes is FOR, and how sensitive what it handles\nis. Reads .ia-qa-discovery/capture/ offline \u2014 it never opens a browser and never\ntouches your application. Your key, your model, your machine.\n\nEvery classification carries a sentence you read and a path into the capture that\nthis tool resolves and checks before keeping it. A claim that cites nothing, cites\nsomething that does not exist, or misdescribes what it cites, is DROPPED and the\ndrop is reported. That check proves the premise, never the conclusion: only you\ncan say whether the reading follows.\n\n`plan` sends strictly LESS than `classify`: no selectors and no observed API calls ever\nleave for it. Each verb announces exactly what goes, before it goes.\n\nNothing here is a gate, and nothing here reorders anything a deterministic verb measured.\n`ia-qa-discover` works fully without a key.\n\nUsage:\n ia-qa-discover-ai classify Classify every entry point in the capture.\n --page <name> just this page\n --json machine-readable output\n --dry-run show what would be sent, call nothing\n ia-qa-discover-ai plan What someone comes to each page to DO, and what\n they cannot do if it breaks \u2014 in business words.\n Reads the pages your suite does not visit, from\n `ia-qa-discover coverage`; every page when there\n is no suite to compare against.\n --all every readable page, tested or not\n --page <name> just this page\n --report [f] branded HTML report (+ --open)\n --json machine-readable output\n --dry-run show what would be sent, call nothing\n ia-qa-discover-ai models List the providers and models you can configure\n ia-qa-discover-ai version Print the installed version\n ia-qa-discover-ai help Show this help\n\nConfigure it in .ia-qa-discovery/config.json:\n\n \"ai\": {\n \"provider\": \"anthropic\",\n \"model\": \"claude-haiku-4-5\",\n \"apiKey\": { \"source\": \"env\", \"key\": \"ANTHROPIC_API_KEY\" }\n }\n\nThe key itself never goes in that file \u2014 only the name of the variable holding it.\n\nGlobal:\n --config <dir> Resolve .ia-qa-discovery/ under <dir> instead of the current directory\n";
5
5
  declare function runClassify(args: string[]): Promise<void>;
6
+ declare function runPlan(args: string[]): Promise<void>;
6
7
  declare function main(): Promise<void>;
7
- export { HELP, main, runClassify };
8
+ export { HELP, main, runClassify, runPlan };
@@ -37,6 +37,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.HELP = exports.CLASSIFICATION_FILENAME = exports.CLASSIFICATION_SCHEMA = void 0;
38
38
  exports.main = main;
39
39
  exports.runClassify = runClassify;
40
+ exports.runPlan = runPlan;
40
41
  const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
42
43
  const self_healing_1 = require("@ia-qa/self-healing");
@@ -45,6 +46,12 @@ const citations_1 = require("../citations");
45
46
  const classify_1 = require("../ai/classify");
46
47
  Object.defineProperty(exports, "CLASSIFICATION_FILENAME", { enumerable: true, get: function () { return classify_1.CLASSIFICATION_FILENAME; } });
47
48
  Object.defineProperty(exports, "CLASSIFICATION_SCHEMA", { enumerable: true, get: function () { return classify_1.CLASSIFICATION_SCHEMA; } });
49
+ const plan_1 = require("../ai/plan");
50
+ const planView_1 = require("../planView");
51
+ const coverage_1 = require("../coverage");
52
+ const scan_1 = require("../cli/scan");
53
+ const htmlReport_1 = require("../htmlReport");
54
+ const sharedCalls_1 = require("../sharedCalls");
48
55
  const taxonomy_1 = require("../taxonomy");
49
56
  const overviewFile_1 = require("../overviewFile");
50
57
  /**
@@ -71,13 +78,27 @@ something that does not exist, or misdescribes what it cites, is DROPPED and the
71
78
  drop is reported. That check proves the premise, never the conclusion: only you
72
79
  can say whether the reading follows.
73
80
 
74
- Nothing here is a gate. \`ia-qa-discover\` works fully without a key.
81
+ \`plan\` sends strictly LESS than \`classify\`: no selectors and no observed API calls ever
82
+ leave for it. Each verb announces exactly what goes, before it goes.
83
+
84
+ Nothing here is a gate, and nothing here reorders anything a deterministic verb measured.
85
+ \`ia-qa-discover\` works fully without a key.
75
86
 
76
87
  Usage:
77
88
  ia-qa-discover-ai classify Classify every entry point in the capture.
78
89
  --page <name> just this page
79
90
  --json machine-readable output
80
91
  --dry-run show what would be sent, call nothing
92
+ ia-qa-discover-ai plan What someone comes to each page to DO, and what
93
+ they cannot do if it breaks — in business words.
94
+ Reads the pages your suite does not visit, from
95
+ \`ia-qa-discover coverage\`; every page when there
96
+ is no suite to compare against.
97
+ --all every readable page, tested or not
98
+ --page <name> just this page
99
+ --report [f] branded HTML report (+ --open)
100
+ --json machine-readable output
101
+ --dry-run show what would be sent, call nothing
81
102
  ia-qa-discover-ai models List the providers and models you can configure
82
103
  ia-qa-discover-ai version Print the installed version
83
104
  ia-qa-discover-ai help Show this help
@@ -143,6 +164,19 @@ async function resolveAi() {
143
164
  // names `ia-qa-heal`, its CLI path and `.ia-qa/config.json`, and sending
144
165
  // someone here to a different tool's config is worse than no help at all.
145
166
  // Everything else (AWS SSM) still delegates: one secret path, not two.
167
+ // The value where a NAME belongs. Refused before the lookup, and never echoed: the
168
+ // "not set" message below would otherwise print the key into the terminal and into any
169
+ // log that captured it. One rule, imported — heal owns it, both packages apply it.
170
+ if (typeof ai.apiKey === 'string' || (0, self_healing_1.looksLikeSecret)(String(ai.apiKey.key ?? ''))) {
171
+ const shown = typeof ai.apiKey === 'string' ? String(ai.apiKey) : String(ai.apiKey.key);
172
+ console.error(`❌ ai.apiKey holds what looks like the key itself (${(0, self_healing_1.maskSecret)(shown)}), not the name of a variable.\n\n` +
173
+ ` ${config_1.CONFIG_DIR}/config.json is committed: a value here is a leaked credential, so it is\n` +
174
+ ` refused rather than used. Put the value in your environment and name it:\n` +
175
+ ` "apiKey": { "source": "env", "key": "${provider.keyEnv}" }\n\n` +
176
+ ` Then rotate the one you pasted — assume it is already in your git history.`);
177
+ process.exitCode = 2;
178
+ return null;
179
+ }
146
180
  if (ai.apiKey.source === 'env' && !process.env[ai.apiKey.key]) {
147
181
  console.error(`❌ Environment variable "${ai.apiKey.key}" is not set, so there is no key to call ${provider.label} with.\n\n` +
148
182
  ` This CLI reads process.env only — it never loads .env files, so a key that lives\n` +
@@ -154,7 +188,7 @@ async function resolveAi() {
154
188
  }
155
189
  try {
156
190
  const apiKey = await (0, self_healing_1.resolveSecret)(ai.apiKey);
157
- return { provider: provider.id, model: ai.model, apiKey };
191
+ return { provider: provider.id, model: ai.model, apiKey, ...(ai.baseUrl ? { baseUrl: ai.baseUrl } : {}) };
158
192
  }
159
193
  catch (e) {
160
194
  console.error(`❌ ${e instanceof Error ? e.message : String(e)}`);
@@ -215,10 +249,10 @@ async function runClassify(args) {
215
249
  }
216
250
  // "Offline" above means no browser, and on its own it reads as "nothing leaves".
217
251
  // stderr in every mode, `--json` included — a security announcement, not decoration.
218
- console.error((0, classify_1.egressNotice)(ai.provider, ai.model, withEntryPoints.length));
252
+ console.error((0, classify_1.egressNotice)(ai.provider, ai.model, withEntryPoints.length, false, ai.baseUrl));
219
253
  const perPage = {};
220
254
  for (const page of withEntryPoints) {
221
- const result = await (0, classify_1.classifyPage)(page, index, { provider: ai.provider, model: ai.model, apiKey: ai.apiKey });
255
+ const result = await (0, classify_1.classifyPage)(page, index, { provider: ai.provider, model: ai.model, apiKey: ai.apiKey, baseUrl: ai.baseUrl });
222
256
  perPage[page.page] = result;
223
257
  if (!json) {
224
258
  if (result.failed) {
@@ -316,6 +350,233 @@ async function runClassify(args) {
316
350
  console.log(`\nWritten to ${file}`);
317
351
  console.log(`Every kept claim cites the capture. Read the "why" and the evidence before acting on it.`);
318
352
  }
353
+ /**
354
+ * Which pages `plan` reads, and why — the one decision this verb makes on its own.
355
+ *
356
+ * The question it answers is "what would it cost if this page broke", which is only worth
357
+ * asking about pages nothing tests. So when a coverage map can be computed, the untested
358
+ * list IS the target, in its order. When it cannot — no suite tracked here, or one never
359
+ * watched running — the honest fallback is every readable page, said out loud: a discovery
360
+ * user with no suite is exactly the population this package exists for, and refusing them
361
+ * the verb because a sibling package is absent would be the cross-reference-as-advertising
362
+ * defect in its most expensive form.
363
+ */
364
+ function planTargets(pages, all) {
365
+ const readable = pages.filter(plan_1.isReadable);
366
+ if (all)
367
+ return { scope: 'all', order: [], targets: readable, because: '--all: every readable page, tested or not.' };
368
+ const config = (() => {
369
+ try {
370
+ return (0, config_1.loadConfig)();
371
+ }
372
+ catch {
373
+ return null;
374
+ }
375
+ })();
376
+ const outcome = (0, coverage_1.computeCoverage)({
377
+ captureDirPath: (0, config_1.captureDir)(),
378
+ healingDirPath: path.join((0, config_1.baseDir)(), coverage_1.HEALING_DIR),
379
+ scope: config?.outOfScope,
380
+ });
381
+ if (!outcome.ok) {
382
+ return {
383
+ scope: 'all',
384
+ order: [],
385
+ targets: readable,
386
+ because: `No coverage map here (${outcome.refusal.reason}), so there is no untested list to narrow to — ` +
387
+ `reading every page instead. \`ia-qa-discover coverage\` explains what would produce one.`,
388
+ };
389
+ }
390
+ const order = outcome.map.uncovered.map((u) => u.page);
391
+ const rank = new Map(order.map((name, i) => [name, i]));
392
+ return {
393
+ scope: 'uncovered',
394
+ order,
395
+ // Sorted into the ranking, not merely filtered by it. The line below claims "in its
396
+ // order", and a run that read them in directory order while saying so would be the
397
+ // smallest possible version of a message that does not describe what happened — it also
398
+ // decides which pages are already read when someone interrupts a long run.
399
+ targets: readable
400
+ .filter((p) => rank.has(p.page))
401
+ .sort((a, b) => rank.get(a.page) - rank.get(b.page)),
402
+ because: `The ${order.length} page(s) \`coverage\` found that no test visits, in its order.`,
403
+ };
404
+ }
405
+ /**
406
+ * The same branded dossier `scan --report` writes, from the verb that produced the reading.
407
+ *
408
+ * Its own function rather than a flag threaded through `scan`: after `plan`, the report is
409
+ * the natural place to look, and telling someone to re-run a crawl to see a file assembled
410
+ * from what is already on disk is the kind of instruction that never gets followed. Nothing
411
+ * is recomputed — `renderScanReport` is handed the capture and the view, and the plan
412
+ * section is rendered by the same module as the terminal.
413
+ */
414
+ function writePlanReport(wanted, view, open) {
415
+ const surface = (0, coverage_1.loadSurface)((0, config_1.captureDir)());
416
+ if (!surface?.manifest?.coverage)
417
+ return null;
418
+ const index = (0, citations_1.loadCaptureIndex)();
419
+ const shared = index.shared ?? {
420
+ schema: sharedCalls_1.SHARED_CALLS_SCHEMA,
421
+ capturedAt: surface.manifest.capturedAt,
422
+ sourcePages: 0,
423
+ threshold: 0,
424
+ calls: [],
425
+ };
426
+ const file = path.resolve(process.cwd(), wanted);
427
+ fs.mkdirSync(path.dirname(file), { recursive: true });
428
+ fs.writeFileSync(file, (0, htmlReport_1.renderScanReport)({
429
+ manifest: surface.manifest,
430
+ pages: surface.pages,
431
+ shared,
432
+ ...surface.manifest.coverage,
433
+ plan: view,
434
+ }), 'utf8');
435
+ if (open)
436
+ (0, scan_1.openInBrowser)(file);
437
+ return file;
438
+ }
439
+ async function runPlan(args) {
440
+ const json = args.includes('--json');
441
+ const dryRun = args.includes('--dry-run');
442
+ const all = args.includes('--all');
443
+ const only = flag(args, '--page');
444
+ const index = (0, citations_1.loadCaptureIndex)();
445
+ if (index.pages.size === 0) {
446
+ console.error(`❌ No capture in ${path.relative(process.cwd(), (0, config_1.captureDir)()) || (0, config_1.captureDir)()}.\n` +
447
+ ` Run \`ia-qa-discover scan\` first — this reads a capture, it does not make one.`);
448
+ process.exitCode = 2;
449
+ return;
450
+ }
451
+ const captured = [...index.pages.values()];
452
+ const { scope, order, targets, because } = planTargets(captured, all);
453
+ const pages = targets.filter((p) => !only || p.page === only);
454
+ if (only && pages.length === 0) {
455
+ const known = captured.map((p) => p.page);
456
+ console.error(known.includes(only)
457
+ ? `❌ Page "${only}" is in the capture but not in this run's targets (${because ?? ''}).\n` +
458
+ ` Add --all to read it anyway.`
459
+ : `❌ No page "${only}" in the capture. Have: ${known.join(', ')}`);
460
+ process.exitCode = 2;
461
+ return;
462
+ }
463
+ if (pages.length === 0) {
464
+ // Not a failure and not an empty document: there is genuinely nothing to ask about.
465
+ const line = scope === 'uncovered'
466
+ ? `Nothing to read: every page this scan found is already visited by your suite.\n` +
467
+ ` Add --all to read the tested pages too.`
468
+ : `Nothing to read: no page in this capture carries a title, a heading or a field to cite.`;
469
+ if (json)
470
+ console.log(JSON.stringify({ schema: plan_1.PLAN_SCHEMA, scope, pages: {}, totals: { read: 0, unclear: 0, dropped: 0, failed: 0 }, written: false }, null, 2));
471
+ else
472
+ console.log(`\n${line}`);
473
+ return;
474
+ }
475
+ const names = captured.map((p) => p.page);
476
+ // Computed once, over EVERY captured page — not over the targets. A heading is the app's
477
+ // shell because the app repeats it, and a narrowed target list would make the same heading
478
+ // look page-specific.
479
+ const shell = (0, plan_1.shellHeadings)(captured);
480
+ if (dryRun) {
481
+ console.log(`Would read ${pages.length} page(s) of the ${captured.length} captured:`);
482
+ for (const p of pages)
483
+ console.log(` ${p.page}`);
484
+ if (because)
485
+ console.log(` (${because})`);
486
+ const declared = ((0, config_1.configExists)() ? (0, config_1.loadConfig)().ai : undefined) ?? {};
487
+ const provider = (0, self_healing_1.findProvider)(declared.provider ?? '');
488
+ if (provider)
489
+ console.log((0, plan_1.planEgressNotice)(provider.id, declared.model ?? '(no model set)', pages.length, true));
490
+ console.log(`Nothing was called. Drop --dry-run to run it.`);
491
+ return;
492
+ }
493
+ const ai = await resolveAi();
494
+ if (!ai)
495
+ return;
496
+ if (!json) {
497
+ console.log(`🤖 ${ai.provider}/${ai.model} · ${pages.length} page(s)`);
498
+ if (because)
499
+ console.log(` ${because}`);
500
+ console.log(` Reading the capture offline. Your app is not touched.`);
501
+ }
502
+ // "Offline" above means no browser, and on its own it reads as "nothing leaves".
503
+ console.error((0, plan_1.planEgressNotice)(ai.provider, ai.model, pages.length, false, ai.baseUrl));
504
+ const perPage = {};
505
+ for (const page of pages) {
506
+ const outcome = await (0, plan_1.readPage)(page, index, { provider: ai.provider, model: ai.model, apiKey: ai.apiKey, baseUrl: ai.baseUrl }, names, shell);
507
+ perPage[page.page] = outcome;
508
+ if (!json) {
509
+ const mark = outcome.status === 'read' ? '✓' : outcome.status === 'unclear' ? '🤔' : outcome.status === 'dropped' ? '✘' : '✘';
510
+ const detail = outcome.status === 'read' || outcome.status === 'unclear'
511
+ ? outcome.reading.purpose
512
+ : outcome.status === 'dropped'
513
+ ? outcome.dropped.reason
514
+ : outcome.reason;
515
+ console.log(` ${page.page.padEnd(22)} ${mark} ${detail}`);
516
+ }
517
+ }
518
+ const totals = {
519
+ read: Object.values(perPage).filter((o) => o.status === 'read').length,
520
+ unclear: Object.values(perPage).filter((o) => o.status === 'unclear').length,
521
+ dropped: Object.values(perPage).filter((o) => o.status === 'dropped').length,
522
+ failed: Object.values(perPage).filter((o) => o.status === 'failed').length,
523
+ };
524
+ // A call that did not happen is not a page with nothing on it. Reported before any total,
525
+ // and it sets a non-zero exit — the same rule `classify` pays: with an invalid key, "0
526
+ // read" must never be indistinguishable from an app with nothing to say.
527
+ if (totals.failed > 0) {
528
+ const failed = Object.entries(perPage).filter(([, o]) => o.status === 'failed');
529
+ console.error(`\n❌ ${totals.failed} of ${pages.length} page(s) could not be read at all:`);
530
+ for (const [name, o] of failed.slice(0, 5))
531
+ console.error(` ${name}: ${o.reason}`);
532
+ if (failed.length > 5)
533
+ console.error(` …and ${failed.length - 5} more.`);
534
+ console.error(` Nothing is wrong with your app or your capture — the model call did not succeed.`);
535
+ process.exitCode = 2;
536
+ if (totals.failed === pages.length) {
537
+ // Nothing to WRITE: a document of failures would overwrite a good previous reading
538
+ // with the record of a bad key. Not writing is not a reason to say nothing, so
539
+ // `--json` still gets its document on stdout.
540
+ if (json) {
541
+ console.log(JSON.stringify({ schema: plan_1.PLAN_SCHEMA, readAt: new Date().toISOString(), model: `${ai.provider}/${ai.model}`, scope, totals, pages: perPage, written: false }, null, 2));
542
+ }
543
+ return;
544
+ }
545
+ }
546
+ const document = {
547
+ schema: plan_1.PLAN_SCHEMA,
548
+ readAt: new Date().toISOString(),
549
+ model: `${ai.provider}/${ai.model}`,
550
+ scope,
551
+ targeted: pages.map((p) => p.page),
552
+ totals,
553
+ pages: perPage,
554
+ };
555
+ const file = path.join((0, config_1.baseDir)(), config_1.CONFIG_DIR, plan_1.PLAN_FILENAME);
556
+ fs.mkdirSync(path.dirname(file), { recursive: true });
557
+ fs.writeFileSync(file, JSON.stringify(document, null, 2) + '\n', 'utf8');
558
+ // `plan.json` is not where anyone looks. `_overview.md` is, and `scan` wrote it before
559
+ // this reading existed — so it is rewritten here, the only moment the reading is not
560
+ // stale. A refusal is reported, never swallowed.
561
+ const refreshed = (0, overviewFile_1.refreshOverview)();
562
+ if (!refreshed.ok)
563
+ console.error(`\n⚠ _overview.md was not updated: ${refreshed.reason}`);
564
+ const view = (0, planView_1.buildPlanView)(document, captured, order);
565
+ const wantedReport = (0, scan_1.reportPath)(args, 'ia-qa-discover-plan.html');
566
+ const reportFile = wantedReport ? writePlanReport(wantedReport, view, args.includes('--open')) : null;
567
+ if (wantedReport && !reportFile) {
568
+ console.error(`\n⚠ No HTML report: this capture predates coverage being recorded in surface.json, so the\n` +
569
+ ` report cannot state what the scan could not see. Re-run \`ia-qa-discover scan\`.`);
570
+ }
571
+ if (json) {
572
+ console.log(JSON.stringify({ ...document, overview: refreshed.ok ? refreshed.file : null, report: reportFile }, null, 2));
573
+ return;
574
+ }
575
+ console.log((0, planView_1.renderPlanTerminal)(view));
576
+ console.log(`\nWritten to ${file}`);
577
+ if (reportFile)
578
+ console.log(`Report: ${reportFile}`);
579
+ }
319
580
  async function main() {
320
581
  const argv = process.argv.slice(2);
321
582
  const ci = argv.indexOf('--config');
@@ -331,6 +592,9 @@ async function main() {
331
592
  case 'classify':
332
593
  await runClassify(rest);
333
594
  break;
595
+ case 'plan':
596
+ await runPlan(rest);
597
+ break;
334
598
  case 'models':
335
599
  console.log((0, self_healing_1.renderModelList)());
336
600
  break;