@ia-qa/self-healing 1.0.0 → 1.1.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.
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.aiResolve = aiResolve;
4
+ exports.prefilter = prefilter;
5
+ exports.buildPrompt = buildPrompt;
6
+ exports.parseModelJson = parseModelJson;
7
+ const match_1 = require("../browser/match");
8
+ const DEFAULTS = { minConfidence: 0.7, topN: 15, timeoutMs: 15000, temperature: 0 };
9
+ /**
10
+ * Resolve a broken `target` to one of the live `candidates`, or null.
11
+ *
12
+ * Returns null — defer to the deterministic path / a human — whenever there is
13
+ * nothing to choose from, the model is not confident enough, the reply is
14
+ * unparseable, or the network fails. It never throws on any of those.
15
+ */
16
+ async function aiResolve(target, candidates, opts) {
17
+ const minConfidence = opts.minConfidence ?? DEFAULTS.minConfidence;
18
+ const topN = opts.topN ?? DEFAULTS.topN;
19
+ const shortlist = prefilter(target, candidates, topN);
20
+ if (shortlist.length === 0)
21
+ return null;
22
+ const raw = await callModel(buildPrompt(target, shortlist), opts);
23
+ if (!raw)
24
+ return null;
25
+ const { candidateIndex, confidence, rationale } = raw;
26
+ if (!Number.isInteger(candidateIndex) || candidateIndex < 0 || candidateIndex >= shortlist.length) {
27
+ return null;
28
+ }
29
+ if (typeof confidence !== 'number' || confidence < minConfidence)
30
+ return null;
31
+ const candidate = shortlist[candidateIndex];
32
+ return { selector: candidate.selector, confidence, rationale: String(rationale ?? ''), candidate };
33
+ }
34
+ /**
35
+ * Keep only same-role candidates, ranked by name similarity to the target, capped
36
+ * at `topN`. Reuses the deterministic engine's pure metric so the shortlist the
37
+ * model sees is the same neighbourhood the heuristic would have searched — the LLM
38
+ * is asked to break a tie the heuristic couldn't, not to search a different space.
39
+ *
40
+ * A target with no accessible name (icon-only) has nothing for Dice to rank on;
41
+ * every same-role candidate scores 0, so the shortlist is just "same role, first N".
42
+ * That is the vision case (phase 2), and text-only will usually — correctly — not
43
+ * clear the confidence floor on it.
44
+ */
45
+ function prefilter(target, candidates, topN) {
46
+ const wanted = (0, match_1.normalize)(target.name);
47
+ return candidates
48
+ .filter((c) => c.role === target.role)
49
+ .map((c) => ({ c, score: (0, match_1.diceSimilarity)((0, match_1.normalize)(c.name), wanted) }))
50
+ .sort((a, b) => b.score - a.score)
51
+ .slice(0, topN)
52
+ .map((x) => x.c);
53
+ }
54
+ function describe(el) {
55
+ const parts = [`role="${el.role}"`, `name=${JSON.stringify(el.name || '')}`];
56
+ if (el.context)
57
+ parts.push(`context=${JSON.stringify(el.context)}`);
58
+ if (el.hint)
59
+ parts.push(`hint=${JSON.stringify(el.hint)}`);
60
+ return parts.join(' ');
61
+ }
62
+ function buildPrompt(target, shortlist) {
63
+ const list = shortlist.map((c, i) => ` ${i}. ${describe(c)}`).join('\n');
64
+ return (`A UI end-to-end test targeted an element that no longer resolves. The element ` +
65
+ `was identified by its ARIA role and accessible name. The page changed; below are ` +
66
+ `the current interactive elements of the same role.\n\n` +
67
+ `Decide which current element is THE SAME element as the original — same purpose, ` +
68
+ `likely renamed or restyled — not merely similar. If none is clearly the same, be ` +
69
+ `unconfident.\n\n` +
70
+ `ORIGINAL (broken):\n ${describe(target)}\n\n` +
71
+ `CURRENT CANDIDATES (choose by index):\n${list}\n\n` +
72
+ `Reply with ONLY a JSON object, nothing else, in exactly this key order:\n` +
73
+ `{"rationale": "<one sentence: why this candidate is the same element, or why none is>", ` +
74
+ `"confidence": <number 0-1>, "candidateIndex": <integer index into the list above>}\n` +
75
+ `Set confidence to 0 if no candidate is the same element.`);
76
+ }
77
+ /**
78
+ * One provider round-trip, fully defensive: a strict timeout (LLMs hang), any
79
+ * non-2xx (429 rate limit, 5xx) or network error, and any unparseable body all
80
+ * resolve to null instead of throwing. The caller treats null as "no suggestion".
81
+ */
82
+ async function callModel(prompt, opts) {
83
+ const fetchFn = opts.fetchFn ?? globalThis.fetch;
84
+ if (typeof fetchFn !== 'function')
85
+ return null;
86
+ const timeoutMs = opts.timeoutMs ?? DEFAULTS.timeoutMs;
87
+ const temperature = opts.temperature ?? DEFAULTS.temperature;
88
+ // The newest models reject sampling params entirely: Anthropic Opus 4.8 / Sonnet 5 /
89
+ // Fable 5 and OpenAI reasoning models (o-series, gpt-5) 400 on `temperature`. Rather
90
+ // than maintain a per-model capability matrix, send temperature and — only on a 400 —
91
+ // retry once without it. Any model that rejects sampling still works.
92
+ let res = await attempt(fetchFn, opts, prompt, temperature, timeoutMs);
93
+ if (res && res.status === 400) {
94
+ res = await attempt(fetchFn, opts, prompt, undefined, timeoutMs);
95
+ }
96
+ if (!res || !res.ok)
97
+ return null;
98
+ let text;
99
+ try {
100
+ const data = await res.json();
101
+ text = extractText(opts.provider, data);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ return parseModelJson(text);
107
+ }
108
+ /** One hardened round-trip. Returns the Response (even non-2xx, so the caller can see 400), or null on network/timeout. */
109
+ async function attempt(fetchFn, opts, prompt, temperature, timeoutMs) {
110
+ const req = buildRequest(opts.provider, opts.model, opts.apiKey, prompt, temperature);
111
+ const controller = new AbortController();
112
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
113
+ try {
114
+ return await fetchFn(req.url, { method: 'POST', headers: req.headers, body: JSON.stringify(req.body), signal: controller.signal });
115
+ }
116
+ catch {
117
+ return null; // network error or aborted timeout
118
+ }
119
+ finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+ function buildRequest(provider, model, apiKey, prompt, temperature) {
124
+ if (provider === 'anthropic') {
125
+ const body = { model, max_tokens: 512, messages: [{ role: 'user', content: prompt }] };
126
+ if (temperature !== undefined)
127
+ body.temperature = temperature;
128
+ return {
129
+ url: 'https://api.anthropic.com/v1/messages',
130
+ headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
131
+ body,
132
+ };
133
+ }
134
+ if (provider === 'google') {
135
+ const generationConfig = { responseMimeType: 'application/json' };
136
+ if (temperature !== undefined)
137
+ generationConfig.temperature = temperature;
138
+ return {
139
+ // Key in the query string is Google's documented scheme for the Generative Language API.
140
+ url: `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`,
141
+ headers: { 'content-type': 'application/json' },
142
+ body: { contents: [{ parts: [{ text: prompt }] }], generationConfig },
143
+ };
144
+ }
145
+ const body = { model, messages: [{ role: 'user', content: prompt }] };
146
+ if (temperature !== undefined)
147
+ body.temperature = temperature;
148
+ return {
149
+ url: 'https://api.openai.com/v1/chat/completions',
150
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
151
+ body,
152
+ };
153
+ }
154
+ function extractText(provider, data) {
155
+ if (provider === 'anthropic') {
156
+ const block = Array.isArray(data?.content) ? data.content.find((b) => b?.type === 'text') : null;
157
+ return String(block?.text ?? '');
158
+ }
159
+ if (provider === 'google') {
160
+ const parts = data?.candidates?.[0]?.content?.parts;
161
+ return Array.isArray(parts) ? parts.map((p) => p?.text ?? '').join('') : '';
162
+ }
163
+ return String(data?.choices?.[0]?.message?.content ?? '');
164
+ }
165
+ /**
166
+ * Parse the model's reply. Models drool Markdown fences and prose around JSON, so
167
+ * we take the outermost `{…}` and parse that. Any failure → null.
168
+ */
169
+ function parseModelJson(text) {
170
+ const match = text.match(/\{[\s\S]*\}/);
171
+ if (!match)
172
+ return null;
173
+ let obj;
174
+ try {
175
+ obj = JSON.parse(match[0]);
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ if (!obj || typeof obj !== 'object')
181
+ return null;
182
+ if (typeof obj.confidence !== 'number' || !Number.isInteger(obj.candidateIndex))
183
+ return null;
184
+ return {
185
+ rationale: typeof obj.rationale === 'string' ? obj.rationale : '',
186
+ confidence: obj.confidence,
187
+ candidateIndex: obj.candidateIndex,
188
+ };
189
+ }
190
+ //# sourceMappingURL=resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolver.js","sourceRoot":"","sources":["../../src/ai/resolver.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,474 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const prompts_1 = __importDefault(require("prompts"));
43
+ const match_1 = require("../browser/match");
44
+ const fixEngine_1 = require("../fixEngine");
45
+ const ingest_1 = require("../ingest");
46
+ const config_1 = require("../config");
47
+ const resolver_1 = require("../ai/resolver");
48
+ const models_1 = require("../ai/models");
49
+ const PROVIDERS = ['anthropic', 'openai', 'google'];
50
+ /**
51
+ * `ia-qa-heal-ai` — the OPTIONAL, BYOK AI add-on to the deterministic healer.
52
+ *
53
+ * A third binary (beside `ia-qa-heal` and `ia-qa-heal-mcp`) in the same package —
54
+ * its own command and `--help`, one install, zero drift. It is NOT a standalone
55
+ * tool: it consumes the page contract + the diff the deterministic engine
56
+ * produces, and only speaks for the rows that engine gives up on (`lost` /
57
+ * `ambiguous`). Everything it proposes is a SUGGESTION a human confirms — never a
58
+ * CI auto-fix. The deterministic `ia-qa-heal` works fully without it.
59
+ */
60
+ const HELP = `ia-qa-heal-ai — optional AI add-on to the deterministic healer (BYOK)
61
+
62
+ Deterministic healing (\`ia-qa-heal\`) refuses to guess: a semantic rename it
63
+ cannot see ("Submit" → "Confirm order") comes back as \`lost\`. This add-on hands
64
+ ONLY those \`lost\`/\`ambiguous\` rows to your own LLM (BYOK) and proposes a match.
65
+ It is a SUGGESTION, confirmed by you — never a CI gate. Most useful right after
66
+ \`ia-qa-heal diff\`.
67
+
68
+ Setup — the easy way: run \`ia-qa-heal-ai init\` and pick your provider + model from
69
+ a list; it writes the "ai" block to .ia-qa/config.json for you. Providers: OpenAI
70
+ (GPT-4/5), Anthropic (Claude), Google (Gemini) — bring your own key.
71
+
72
+ Usage:
73
+ ia-qa-heal-ai init Interactive: pick provider + model → writes .ia-qa/config.json
74
+ ia-qa-heal-ai models List supported providers + models (any custom id also works)
75
+ ia-qa-heal-ai suggest Suggest for baseline/ vs mapping/ (all pages)
76
+ ia-qa-heal-ai suggest <before.json> <after.json> [tests…]
77
+ Suggest for one mapping pair
78
+ ia-qa-heal-ai suggest --dir <base-dir> <cur-dir> [tests…]
79
+ Suggest across all matching pairs
80
+ --apply after review, rewrite the suggested selectors in your test files.
81
+ Interactive confirmation required — REFUSES without a TTY (never in CI).
82
+ --json machine-readable output (no writes)
83
+ ia-qa-heal-ai help Show this help
84
+ `;
85
+ async function main() {
86
+ const [, , command, ...rest] = process.argv;
87
+ switch (command) {
88
+ case 'init':
89
+ await runInit();
90
+ break;
91
+ case 'models':
92
+ console.log('\n' + (0, models_1.renderModelList)());
93
+ break;
94
+ case 'suggest':
95
+ await runSuggest(rest);
96
+ break;
97
+ case 'help':
98
+ case '--help':
99
+ case '-h':
100
+ case undefined:
101
+ console.log(HELP);
102
+ break;
103
+ default:
104
+ console.error(`Unknown command "${command}".\n\n${HELP}`);
105
+ process.exit(1);
106
+ }
107
+ }
108
+ async function runInit() {
109
+ // The AI block is an add-on to an existing config (baseUrl + pages).
110
+ let config;
111
+ try {
112
+ config = (0, config_1.loadConfig)();
113
+ }
114
+ catch (e) {
115
+ console.error(`❌ ${e instanceof Error ? e.message : String(e)}\n\n` +
116
+ ' `ia-qa-heal-ai init` only adds the "ai" block to an existing config. Run `ia-qa-heal init` first.');
117
+ process.exitCode = 2;
118
+ return;
119
+ }
120
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
121
+ console.error('❌ `init` is interactive and needs a terminal. In a non-TTY, add the block by hand:\n' +
122
+ ' "ai": { "provider": "openai", "model": "gpt-4o-mini",\n' +
123
+ ' "apiKey": { "source": "env", "key": "OPENAI_API_KEY" } }\n' +
124
+ ' Run `ia-qa-heal-ai models` for the full list of providers and models.');
125
+ process.exitCode = 2;
126
+ return;
127
+ }
128
+ const providerAns = await (0, prompts_1.default)({
129
+ type: 'select',
130
+ name: 'provider',
131
+ message: 'AI provider (bring your own key)?',
132
+ choices: models_1.AI_PROVIDERS.map((p) => ({ title: `${p.label} (key env: ${p.keyEnv})`, value: p.id })),
133
+ });
134
+ const provider = providerAns.provider;
135
+ if (!provider)
136
+ return void console.log(' Aborted — nothing written.');
137
+ const info = (0, models_1.findProvider)(provider);
138
+ const modelAns = await (0, prompts_1.default)({
139
+ type: 'select',
140
+ name: 'model',
141
+ message: `${info.label} model?`,
142
+ choices: [
143
+ ...info.models.map((m) => ({ title: `${m.id}${m.note ? ` — ${m.note}` : ''}`, value: m.id })),
144
+ { title: 'Custom… (type any model id your provider accepts)', value: '__custom__' },
145
+ ],
146
+ });
147
+ let model = modelAns.model;
148
+ if (!model)
149
+ return void console.log(' Aborted — nothing written.');
150
+ if (model === '__custom__') {
151
+ const custom = await (0, prompts_1.default)({ type: 'text', name: 'model', message: 'Model id:' });
152
+ model = custom.model;
153
+ if (!model)
154
+ return void console.log(' Aborted — nothing written.');
155
+ }
156
+ const keyAns = await (0, prompts_1.default)({
157
+ type: 'text',
158
+ name: 'keyEnv',
159
+ message: 'Env var that will hold your API key:',
160
+ initial: info.keyEnv,
161
+ });
162
+ const keyEnv = keyAns.keyEnv;
163
+ if (!keyEnv)
164
+ return void console.log(' Aborted — nothing written.');
165
+ config.ai = { provider, model, apiKey: { source: 'env', key: keyEnv } };
166
+ const file = (0, config_1.saveConfig)(config);
167
+ console.log(`\n✅ Wrote the AI add-on config → ${rel(file)}`);
168
+ console.log(` provider ${provider} · model ${model} · key from $${keyEnv} (read at runtime, never on disk)\n`);
169
+ console.log(` Next: export ${keyEnv}=… then ia-qa-heal diff and ia-qa-heal-ai suggest\n`);
170
+ }
171
+ async function runSuggest(args) {
172
+ const apply = args.includes('--apply');
173
+ const json = args.includes('--json');
174
+ const dirMode = args.includes('--dir');
175
+ const rest = args.filter((a) => !a.startsWith('--'));
176
+ const opts = await resolveAiOptions();
177
+ if (!opts)
178
+ return; // resolveAiOptions already printed guidance + set exit code
179
+ const { pairs, testPaths } = collectPairs(rest, dirMode);
180
+ if (pairs.length === 0) {
181
+ // collectPairs printed the reason + set the exit code
182
+ return;
183
+ }
184
+ if (!json) {
185
+ console.log(`\n🤖 ia-qa-heal-ai — AI suggestions for what deterministic healing gave up on`);
186
+ console.log(` provider ${opts.provider} · model ${opts.model} · confidence ≥ ${opts.minConfidence}\n`);
187
+ }
188
+ const suggestions = [];
189
+ const unresolved = [];
190
+ for (const { name, before, after } of pairs) {
191
+ const report = (0, match_1.diffMappings)(before, after);
192
+ const targets = report.rows.filter((r) => r.status === 'lost' || r.status === 'ambiguous');
193
+ for (const row of targets) {
194
+ const target = {
195
+ role: row.role,
196
+ name: row.name,
197
+ selector: row.selector,
198
+ context: row.context,
199
+ hint: row.hint,
200
+ };
201
+ if (!json)
202
+ process.stderr.write(` … asking about ${label(target)} on ${name}\n`);
203
+ const resolution = await (0, resolver_1.aiResolve)(target, after.elements, opts);
204
+ if (resolution) {
205
+ suggestions.push({ page: name, target, status: row.status, reboundTo: row.reboundTo, resolution });
206
+ }
207
+ else {
208
+ unresolved.push({ page: name, target, status: row.status });
209
+ }
210
+ }
211
+ }
212
+ if (json) {
213
+ console.log(JSON.stringify({
214
+ suggestions: suggestions.map((s) => ({
215
+ page: s.page,
216
+ status: s.status,
217
+ role: s.target.role,
218
+ name: s.target.name,
219
+ selector: s.target.selector,
220
+ suggested_selector: s.resolution.selector,
221
+ suggested_name: s.resolution.candidate.name,
222
+ confidence: s.resolution.confidence,
223
+ rationale: s.resolution.rationale,
224
+ rebound_to: s.reboundTo,
225
+ })),
226
+ unresolved: unresolved.map((u) => ({ page: u.page, status: u.status, role: u.target.role, name: u.target.name, selector: u.target.selector })),
227
+ }, null, 2));
228
+ process.exitCode = 0;
229
+ return;
230
+ }
231
+ printSuggestions(suggestions, unresolved);
232
+ if (!apply) {
233
+ if (suggestions.length > 0) {
234
+ console.log(' Review the above. Re-run with --apply to rewrite the accepted ones in your tests (interactive).\n');
235
+ }
236
+ process.exitCode = 0;
237
+ return;
238
+ }
239
+ await runApply(suggestions, testPaths);
240
+ }
241
+ /**
242
+ * Build the resolver options from `.ia-qa/config.json`'s optional `ai` block,
243
+ * resolving the BYOK key at runtime (never read from disk). Returns null and sets
244
+ * the exit code with guidance when the block or the key is missing.
245
+ */
246
+ async function resolveAiOptions() {
247
+ let ai;
248
+ try {
249
+ ai = (0, config_1.loadConfig)().ai;
250
+ }
251
+ catch (e) {
252
+ console.error(`❌ ${e instanceof Error ? e.message : String(e)}`);
253
+ process.exitCode = 2;
254
+ return null;
255
+ }
256
+ if (!ai) {
257
+ console.error('❌ No "ai" block in .ia-qa/config.json — this add-on is opt-in.\n\n' +
258
+ ' Add one (the key is a SecretRef, resolved at runtime, never written to disk):\n' +
259
+ ' "ai": { "provider": "anthropic", "model": "claude-...",\n' +
260
+ ' "apiKey": { "source": "env", "key": "ANTHROPIC_API_KEY" } }\n\n' +
261
+ ' The deterministic `ia-qa-heal` works fully without this.');
262
+ process.exitCode = 2;
263
+ return null;
264
+ }
265
+ if (!PROVIDERS.includes(ai.provider)) {
266
+ console.error(`❌ Unknown ai.provider "${ai.provider}". Use one of: ${PROVIDERS.join(', ')}.\n` +
267
+ ' Run `ia-qa-heal-ai init` to pick from a list, or `ia-qa-heal-ai models` to see them.');
268
+ process.exitCode = 2;
269
+ return null;
270
+ }
271
+ let apiKey;
272
+ try {
273
+ // Same SecretRef resolution as every other secret in the package: value pulled
274
+ // from env / aws-ssm at runtime, never read from disk (invariant 7).
275
+ apiKey = await (0, config_1.resolveSecret)(ai.apiKey);
276
+ }
277
+ catch (e) {
278
+ console.error(`❌ ${e instanceof Error ? e.message : String(e)}`);
279
+ process.exitCode = 2;
280
+ return null;
281
+ }
282
+ const minConfidence = ai.minConfidence ?? 0.7;
283
+ return { provider: ai.provider, model: ai.model, apiKey, minConfidence };
284
+ }
285
+ /**
286
+ * Resolve the CLI positionals into a list of before/after mapping pairs + the test
287
+ * paths for `--apply`. Mirrors `ia-qa-heal fix`'s shapes so the two feel like one
288
+ * tool: no args → baseline/ vs mapping/ (every page); two files → one pair;
289
+ * `--dir a b` → every matching filename in the two dirs.
290
+ */
291
+ function collectPairs(rest, dirMode) {
292
+ if (dirMode) {
293
+ const [baseDir, curDir, ...given] = rest;
294
+ if (!baseDir || !curDir) {
295
+ console.error('Usage: ia-qa-heal-ai suggest --dir <baseline-dir> <current-dir> [tests…]');
296
+ process.exitCode = 2;
297
+ return { pairs: [], testPaths: [] };
298
+ }
299
+ return { pairs: pairDirs(baseDir, curDir), testPaths: given.length > 0 ? given : defaultTestPaths() };
300
+ }
301
+ if (rest.length === 0) {
302
+ const base = (0, config_1.baselineDir)();
303
+ if (!fs.existsSync(base)) {
304
+ console.error(`❌ No baseline at ${rel(base)} to compare against. Run \`ia-qa-heal map\` then \`ia-qa-heal baseline\` first,\n` +
305
+ ` or pass two mapping files: ia-qa-heal-ai suggest <before.json> <after.json>`);
306
+ process.exitCode = 2;
307
+ return { pairs: [], testPaths: [] };
308
+ }
309
+ return { pairs: pairDirs(base, (0, config_1.mappingDir)()), testPaths: defaultTestPaths() };
310
+ }
311
+ const [beforeFile, afterFile, ...given] = rest;
312
+ if (!beforeFile || !afterFile) {
313
+ console.error('Usage: ia-qa-heal-ai suggest <before.json> <after.json> [tests…]');
314
+ process.exitCode = 2;
315
+ return { pairs: [], testPaths: [] };
316
+ }
317
+ const before = readMapping(beforeFile);
318
+ const after = readMapping(afterFile);
319
+ const name = path.basename(afterFile).replace(/\.json$/, '');
320
+ return { pairs: [{ name, before, after }], testPaths: given.length > 0 ? given : defaultTestPaths() };
321
+ }
322
+ /** Pair mapping files by filename across two directories (pages only; layouts skipped — nameless drift is not the AI's job). */
323
+ function pairDirs(baseDir, curDir) {
324
+ if (!fs.existsSync(baseDir) || !fs.statSync(baseDir).isDirectory()) {
325
+ console.error(`❌ "${baseDir}" is not a directory.`);
326
+ process.exitCode = 2;
327
+ return [];
328
+ }
329
+ if (!fs.existsSync(curDir) || !fs.statSync(curDir).isDirectory()) {
330
+ console.error(`❌ "${curDir}" is not a directory.`);
331
+ process.exitCode = 2;
332
+ return [];
333
+ }
334
+ const pairs = [];
335
+ const files = fs.readdirSync(baseDir).filter((f) => f.endsWith('.json') && !f.startsWith('.') && !f.startsWith('_'));
336
+ for (const file of files) {
337
+ const curFile = path.join(curDir, file);
338
+ if (!fs.existsSync(curFile))
339
+ continue;
340
+ try {
341
+ pairs.push({
342
+ name: file.replace(/\.json$/, ''),
343
+ before: readMapping(path.join(baseDir, file)),
344
+ after: readMapping(curFile),
345
+ });
346
+ }
347
+ catch (e) {
348
+ console.error(`⚠ Skipping ${file}: ${e instanceof Error ? e.message : String(e)}`);
349
+ }
350
+ }
351
+ return pairs;
352
+ }
353
+ async function runApply(suggestions, testPaths) {
354
+ if (suggestions.length === 0) {
355
+ console.log(' Nothing to apply — no confident suggestion.\n');
356
+ process.exitCode = 0;
357
+ return;
358
+ }
359
+ // Invariant: an AI suggestion is only ever written after a human says yes, at a
360
+ // real terminal. No TTY ⇒ this is CI or a pipe ⇒ refuse. This is what keeps the
361
+ // AI out of the gate.
362
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
363
+ console.error('\n❌ --apply needs an interactive terminal to confirm each rewrite — refusing in a non-TTY\n' +
364
+ ' (CI/pipe). An AI suggestion must never be applied unattended. Run it locally, or use the\n' +
365
+ ' deterministic `ia-qa-heal fix` in CI.');
366
+ process.exitCode = 2;
367
+ return;
368
+ }
369
+ if (testPaths.length === 0) {
370
+ console.error('\n❌ No test paths to rewrite. Pass them after the mappings, or run `ia-qa-heal ingest` first.');
371
+ process.exitCode = 2;
372
+ return;
373
+ }
374
+ const { go } = await (0, prompts_1.default)({
375
+ type: 'confirm',
376
+ name: 'go',
377
+ message: `Apply ${suggestions.length} AI suggestion${suggestions.length === 1 ? '' : 's'} to your test files? (review above)`,
378
+ initial: false,
379
+ });
380
+ if (!go) {
381
+ console.log(' Aborted — nothing written.\n');
382
+ process.exitCode = 0;
383
+ return;
384
+ }
385
+ const rewrites = new Map();
386
+ for (const s of suggestions) {
387
+ if (s.resolution.selector !== s.target.selector && !rewrites.has(s.target.selector)) {
388
+ rewrites.set(s.target.selector, s.resolution.selector);
389
+ }
390
+ }
391
+ const files = (0, fixEngine_1.collectFiles)(testPaths, (p) => console.error(`⚠ Skipping "${p}" — not found.`));
392
+ if (files.length === 0) {
393
+ console.error(`No test files found under: ${testPaths.join(', ')}`);
394
+ process.exitCode = 2;
395
+ return;
396
+ }
397
+ const result = (0, fixEngine_1.applyRewrites)(files, rewrites, false);
398
+ for (const edit of result.edits) {
399
+ console.log(` ✎ edited ${path.relative(process.cwd(), edit.file)}`);
400
+ for (const r of edit.replacements) {
401
+ const lines = r.lines && r.lines.length > 0 ? ` (line${r.lines.length === 1 ? '' : 's'} ${r.lines.join(', ')})` : '';
402
+ console.log(` ${r.count}× ${r.from} → ${r.to}${lines}`);
403
+ }
404
+ }
405
+ console.log(`\n Applied ${result.totalReplacements} replacement${result.totalReplacements === 1 ? '' : 's'} in ${result.edits.length} file${result.edits.length === 1 ? '' : 's'}.`);
406
+ console.log(' These are AI suggestions — review with `git diff` before you commit. Nothing was committed.\n');
407
+ process.exitCode = 0;
408
+ }
409
+ function printSuggestions(suggestions, unresolved) {
410
+ if (suggestions.length === 0 && unresolved.length === 0) {
411
+ console.log(' ✅ Nothing for the AI to do — deterministic healing left no lost/ambiguous rows.\n');
412
+ return;
413
+ }
414
+ for (const s of suggestions) {
415
+ const pct = Math.round(s.resolution.confidence * 100);
416
+ console.log(` 💡 ${s.status.padEnd(9)} ${label(s.target)} (${s.page})`);
417
+ console.log(` ${s.target.selector} → ${s.resolution.selector}`);
418
+ console.log(` proposes ${s.resolution.candidate.role} "${s.resolution.candidate.name}" · confidence ${pct}%`);
419
+ console.log(` ↳ ${s.resolution.rationale}`);
420
+ if (s.reboundTo) {
421
+ console.log(` ⚠️ the old selector currently resolves onto "${s.reboundTo}" — green today, clicking the wrong element.`);
422
+ }
423
+ console.log('');
424
+ }
425
+ for (const u of unresolved) {
426
+ console.log(` 🚫 ${u.status.padEnd(9)} ${label(u.target)} (${u.page}) · no confident AI match — stays for a human.`);
427
+ }
428
+ if (unresolved.length > 0)
429
+ console.log('');
430
+ }
431
+ function label(el) {
432
+ if (el.name)
433
+ return `${el.role} "${el.name}"`;
434
+ if (el.hint)
435
+ return `${el.role} ⋯${el.hint}`;
436
+ return `${el.role} "(no name)"`;
437
+ }
438
+ function defaultTestPaths() {
439
+ const usage = (0, ingest_1.loadUsage)();
440
+ if (!usage)
441
+ return [];
442
+ return (0, ingest_1.usageFiles)(usage);
443
+ }
444
+ function rel(p) {
445
+ return path.relative(process.cwd(), p) || p;
446
+ }
447
+ function readMapping(file) {
448
+ let raw;
449
+ try {
450
+ raw = fs.readFileSync(file, 'utf8');
451
+ }
452
+ catch {
453
+ console.error(`❌ Cannot read "${file}".`);
454
+ process.exit(2);
455
+ }
456
+ let parsed;
457
+ try {
458
+ parsed = JSON.parse(raw);
459
+ }
460
+ catch (e) {
461
+ console.error(`❌ "${file}" is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
462
+ process.exit(2);
463
+ }
464
+ if (!parsed || !Array.isArray(parsed.elements)) {
465
+ console.error(`❌ "${file}" has no "elements" array — is it a mapping file?`);
466
+ process.exit(2);
467
+ }
468
+ return parsed;
469
+ }
470
+ main().catch((err) => {
471
+ console.error(`\n❌ ${err instanceof Error ? err.message : String(err)}`);
472
+ process.exit(1);
473
+ });
474
+ //# sourceMappingURL=index.js.map