@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.
- package/README.md +158 -4
- package/ROADMAP.md +80 -0
- package/TUTORIAL.md +53 -6
- package/dist/ai/models.d.ts +29 -0
- package/dist/ai/models.js +60 -0
- package/dist/ai/models.js.map +1 -0
- package/dist/ai/resolver.d.ts +85 -0
- package/dist/ai/resolver.js +190 -0
- package/dist/ai/resolver.js.map +1 -0
- package/dist/cli-ai/index.d.ts +2 -0
- package/dist/cli-ai/index.js +474 -0
- package/dist/cli-ai/index.js.map +1 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +78 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/playwright/healer.d.ts +17 -4
- package/dist/playwright/healer.js +23 -4
- package/dist/playwright/healer.js.map +1 -1
- package/package.json +5 -3
|
@@ -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,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
|