@ia-qa/self-healing 1.5.7 → 1.6.1

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,329 @@
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.runCheck = runCheck;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const config_1 = require("../config");
40
+ const safety_1 = require("../discovery/safety");
41
+ const checks_1 = require("../checks");
42
+ const summary_1 = require("./summary");
43
+ /**
44
+ * `ia-qa-heal check [--strict] [--json] [--offline]` — is the app you mapped sound?
45
+ *
46
+ * `diff` answers "what moved since the baseline?". This answers the question a
47
+ * diff structurally cannot: "is the current contract healthy at all?" — a green
48
+ * diff over a page whose every link 404s is still a green diff.
49
+ *
50
+ * Four checks, each admitted under one rule: it must be able to fail for a reason
51
+ * a contract diff would not already catch. "The button still exists" is rejected
52
+ * by that rule — `diff` proves it better and without a browser. What survives:
53
+ *
54
+ * dead links the app answered 4xx/5xx (needs the network — see below)
55
+ * unnamed an interactive element with no accessible name
56
+ * ambiguous two elements sharing role + name: locators for them are a coin flip
57
+ * orphans a mapped page nothing links to
58
+ *
59
+ * **Network.** The link check requests URLs from your own `baseUrl` and nothing
60
+ * else — same site only, GET only, and never a URL that *acts* (`/logout`,
61
+ * `/orders/1/delete`…): the denylist is the one `discover --crawl` already uses,
62
+ * because a GET on those is not a check, it is a logout or a deletion. The run
63
+ * announces the host and the count before the first request, and `--offline`
64
+ * drops that half entirely for a purely local run.
65
+ *
66
+ * Exit codes: 0 — PASS, or WARN without `--strict`; 1 — FAIL (a dead link), or
67
+ * WARN with `--strict`; 2 — nothing to check.
68
+ */
69
+ const CONCURRENCY = 6;
70
+ const TIMEOUT_MS = 10_000;
71
+ function readMappings(dir) {
72
+ const out = [];
73
+ const read = (file, page, isLayout) => {
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
77
+ }
78
+ catch {
79
+ return;
80
+ }
81
+ if (!parsed || !Array.isArray(parsed.elements))
82
+ return;
83
+ out.push({ page, url: parsed.url, elements: parsed.elements, isLayout });
84
+ };
85
+ for (const f of fs.readdirSync(dir)) {
86
+ if (!f.endsWith('.json') || f.startsWith('.') || f.startsWith('_'))
87
+ continue;
88
+ read(path.join(dir, f), f.replace(/\.json$/, ''), false);
89
+ }
90
+ const layouts = path.join(dir, '_layouts');
91
+ if (fs.existsSync(layouts)) {
92
+ for (const f of fs.readdirSync(layouts).filter((x) => x.endsWith('.json'))) {
93
+ read(path.join(layouts, f), `_layouts/${f.replace(/\.json$/, '')}`, true);
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+ /** GET the URL and report its status. A transport error is a finding too. */
99
+ async function probe(url) {
100
+ const ac = new AbortController();
101
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
102
+ try {
103
+ // GET, not HEAD: too many app servers answer HEAD with 405 while the page is
104
+ // perfectly fine, and a false dead link is the one thing this must not emit.
105
+ const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: ac.signal });
106
+ return { status: res.status };
107
+ }
108
+ catch (e) {
109
+ const msg = e instanceof Error ? e.message : String(e);
110
+ return { status: null, error: ac.signal.aborted ? `no response in ${TIMEOUT_MS / 1000}s` : msg };
111
+ }
112
+ finally {
113
+ clearTimeout(timer);
114
+ }
115
+ }
116
+ async function probeAll(targets, onTick) {
117
+ const dead = [];
118
+ let index = 0;
119
+ let done = 0;
120
+ const worker = async () => {
121
+ for (;;) {
122
+ const i = index++;
123
+ if (i >= targets.length)
124
+ return;
125
+ const t = targets[i];
126
+ const { status, error } = await probe(t.url);
127
+ if (status === null || (0, checks_1.isDeadStatus)(status)) {
128
+ dead.push({ ...t, status, error });
129
+ }
130
+ onTick(++done);
131
+ }
132
+ };
133
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
134
+ return dead.sort((a, b) => (b.from.length - a.from.length) || a.url.localeCompare(b.url));
135
+ }
136
+ async function runCheck(args) {
137
+ const strict = args.includes('--strict');
138
+ const json = args.includes('--json');
139
+ const offline = args.includes('--offline');
140
+ const dir = (0, config_1.mappingDir)();
141
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
142
+ console.error(`❌ Nothing to check — ${path.resolve(dir)} does not exist.\n\n` +
143
+ ` \`check\` reads one capture of the app and asks whether it is sound.\n` +
144
+ ` Capture it first: ia-qa-heal map`);
145
+ process.exitCode = 2;
146
+ return;
147
+ }
148
+ const pages = readMappings(dir);
149
+ const realPages = pages.filter((p) => !p.isLayout);
150
+ if (realPages.length === 0) {
151
+ console.error(`❌ No page contracts in ${path.resolve(dir)} — run \`ia-qa-heal map\` first.`);
152
+ process.exitCode = 2;
153
+ return;
154
+ }
155
+ let baseUrl = '';
156
+ try {
157
+ baseUrl = (0, config_1.loadConfig)().baseUrl ?? '';
158
+ }
159
+ catch {
160
+ /* a missing config only costs the network half; the static checks stand */
161
+ }
162
+ const skipped = [];
163
+ const elementCount = pages.reduce((s, p) => s + p.elements.length, 0);
164
+ const unnamed = (0, checks_1.findUnnamed)(pages);
165
+ const ambiguous = (0, checks_1.findAmbiguousNames)(pages);
166
+ if (!baseUrl) {
167
+ skipped.push({ check: 'orphans', why: 'no baseUrl in .ia-qa/config.json — hrefs cannot be resolved to pages' });
168
+ }
169
+ const orphans = baseUrl
170
+ ? (0, checks_1.findOrphanPages)(pages, (href) => {
171
+ try {
172
+ return (0, safety_1.normalizedPath)(href, baseUrl);
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ })
178
+ : [];
179
+ let deadLinks = [];
180
+ let linksChecked = 0;
181
+ let linksSkipped = 0;
182
+ if (offline) {
183
+ skipped.push({ check: 'dead links', why: '--offline' });
184
+ }
185
+ else if (!baseUrl) {
186
+ skipped.push({ check: 'dead links', why: 'no baseUrl in .ia-qa/config.json' });
187
+ }
188
+ if (!offline && baseUrl) {
189
+ const { targets, skipped } = (0, checks_1.collectLinkTargets)(pages, (href) => {
190
+ const verdict = (0, safety_1.classifyUrl)(href, baseUrl);
191
+ if (!verdict.ok)
192
+ return null;
193
+ try {
194
+ return new URL(href, baseUrl).toString();
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ });
200
+ linksSkipped = skipped;
201
+ linksChecked = targets.length;
202
+ if (targets.length > 0) {
203
+ // Rule 1 of this project: a flow that leaves the machine announces itself
204
+ // without being asked. Said before the first request, not after.
205
+ const host = (() => {
206
+ try {
207
+ return new URL(baseUrl).host;
208
+ }
209
+ catch {
210
+ return baseUrl;
211
+ }
212
+ })();
213
+ if (!json) {
214
+ console.log(`\n🔗 Requesting ${targets.length} distinct link${targets.length === 1 ? '' : 's'} on ${host} —` +
215
+ ` GET only, same site only, action URLs skipped.` +
216
+ (skipped > 0 ? ` ${skipped} href${skipped === 1 ? '' : 's'} left alone.` : ''));
217
+ }
218
+ deadLinks = await probeAll(targets, () => undefined);
219
+ }
220
+ }
221
+ const report = {
222
+ pagesChecked: realPages.length,
223
+ elements: elementCount,
224
+ unnamed,
225
+ ambiguous,
226
+ orphans,
227
+ deadLinks,
228
+ linksChecked,
229
+ linksSkipped,
230
+ offline: offline || !baseUrl,
231
+ skipped,
232
+ };
233
+ const verdict = (0, checks_1.checkVerdict)(report);
234
+ if (json) {
235
+ console.log(JSON.stringify({ verdict, ...report }, null, 2));
236
+ process.exitCode = verdict === 'FAIL' || (strict && verdict === 'WARN') ? 1 : 0;
237
+ return;
238
+ }
239
+ printCheck(report, verdict, strict);
240
+ (0, summary_1.summaryLine)('check', verdict === 'PASS' ? 'ok' : verdict === 'WARN' ? 'fix' : 'block', verdict === 'PASS'
241
+ ? `PASS · ${report.pagesChecked} pages · ${(0, checks_1.checkCoverage)(report).ran}/${(0, checks_1.checkCoverage)(report).total} checks ran`
242
+ : verdict === 'FAIL'
243
+ ? `FAIL · ${deadLinks.length} dead link${deadLinks.length === 1 ? '' : 's'}`
244
+ : `WARN · ${unnamed.length} unnamed · ${ambiguous.length} ambiguous · ${orphans.length} orphan`, [
245
+ `${report.elements} elements`,
246
+ report.offline ? 'offline' : `${linksChecked} links`,
247
+ skipped.length > 0 ? `${skipped.length} check${skipped.length === 1 ? '' : 's'} skipped` : undefined,
248
+ ]);
249
+ process.exitCode = verdict === 'FAIL' || (strict && verdict === 'WARN') ? 1 : 0;
250
+ }
251
+ /**
252
+ * Where a finding lives. An element repeated by the shared shell is ONE thing to
253
+ * fix on N pages, not N findings — saying "142 pages" instead of printing it 142
254
+ * times is the difference between a report someone reads and one they mute.
255
+ */
256
+ function where(pages) {
257
+ if (pages.length === 1)
258
+ return pages[0];
259
+ if (pages.length <= 3)
260
+ return pages.join(', ');
261
+ return `${pages.length} pages (shared shell — ${pages.slice(0, 2).join(', ')}…)`;
262
+ }
263
+ function printCheck(r, verdict, strict) {
264
+ const icon = verdict === 'PASS' ? '✅' : verdict === 'WARN' ? '⚠️ ' : '⛔';
265
+ console.log(`\n${icon} ${verdict} ia-qa-heal check — is the app you mapped sound?`);
266
+ console.log(` ${r.pagesChecked} page${r.pagesChecked === 1 ? '' : 's'} · ${r.elements} elements · ` +
267
+ (r.offline ? 'links not requested (offline)' : `${r.linksChecked} distinct link${r.linksChecked === 1 ? '' : 's'} requested`));
268
+ if (r.deadLinks.length > 0) {
269
+ console.log(`\n ⛔ ${r.deadLinks.length} dead link${r.deadLinks.length === 1 ? '' : 's'}:`);
270
+ for (const d of r.deadLinks.slice(0, 15)) {
271
+ const what = d.status === null ? (d.error ?? 'no response') : `HTTP ${d.status}`;
272
+ const from = d.from.slice(0, 2).map((f) => `${f.page} "${f.name}"`).join(', ');
273
+ const more = d.from.length > 2 ? ` +${d.from.length - 2} more` : '';
274
+ console.log(` ${what.padEnd(10)} ${d.url}`);
275
+ console.log(` linked from ${from}${more}`);
276
+ }
277
+ if (r.deadLinks.length > 15)
278
+ console.log(` … and ${r.deadLinks.length - 15} more.`);
279
+ }
280
+ if (r.ambiguous.length > 0) {
281
+ console.log(`\n ❓ ${r.ambiguous.length} name${r.ambiguous.length === 1 ? '' : 's'} that cannot identify one element:`);
282
+ for (const a of r.ambiguous.slice(0, 10)) {
283
+ console.log(` ${a.count}× ${a.role} "${a.name}" · ${where(a.pages)}`);
284
+ }
285
+ if (r.ambiguous.length > 10)
286
+ console.log(` … and ${r.ambiguous.length - 10} more.`);
287
+ console.log(` Any getByRole/cy.findByRole for these is a coin flip — Playwright raises a` +
288
+ `\n strict-mode violation and the healer reports \`ambiguous\` and refuses.` +
289
+ `\n A data-testid on one of them settles it. This is drift that has not happened yet.`);
290
+ }
291
+ if (r.unnamed.length > 0) {
292
+ const occurrences = r.unnamed.reduce((acc, u) => acc + u.pages.length, 0);
293
+ console.log(`
294
+ 👻 ${r.unnamed.length} interactive element${r.unnamed.length === 1 ? '' : 's'} with no accessible name` +
295
+ (occurrences > r.unnamed.length ? ` (${occurrences} occurrences — shared shell counted once)` : '') +
296
+ ':');
297
+ for (const u of r.unnamed.slice(0, 8)) {
298
+ console.log(` ${u.role.padEnd(8)} ${u.selector}`);
299
+ console.log(` ${where(u.pages)}`);
300
+ }
301
+ if (r.unnamed.length > 8)
302
+ console.log(` … and ${r.unnamed.length - 8} more.`);
303
+ console.log(` A screen reader announces nothing, getByRole cannot target it, and healing` +
304
+ `\n cannot recover it after a move — identity here IS role + name.`);
305
+ }
306
+ if (r.orphans.length > 0) {
307
+ console.log(`\n 🔌 ${r.orphans.length} mapped page${r.orphans.length === 1 ? '' : 's'} nothing links to:`);
308
+ for (const o of r.orphans.slice(0, 10))
309
+ console.log(` ${o.page} (${o.url})`);
310
+ if (r.orphans.length > 10)
311
+ console.log(` … and ${r.orphans.length - 10} more.`);
312
+ console.log(` Either the navigation lost a link, or they are deep-link-only. Worth one look.`);
313
+ }
314
+ if (r.skipped.length > 0) {
315
+ const cov = (0, checks_1.checkCoverage)(r);
316
+ console.log(`
317
+ ⏸ ${cov.ran} of ${cov.total} checks ran. NOT checked:`);
318
+ for (const sk of r.skipped)
319
+ console.log(` ${sk.check.padEnd(12)} ${sk.why}`);
320
+ console.log(` A verdict cannot speak for a question it never asked — whatever those` +
321
+ `
322
+ would have found is invisible above, PASS included.`);
323
+ }
324
+ if (verdict === 'WARN' && !strict) {
325
+ console.log(`\n Advisory — exits 0. Add --strict to fail the pipeline on these too.`);
326
+ }
327
+ console.log('');
328
+ }
329
+ //# sourceMappingURL=check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check.js","sourceRoot":"","sources":["../../src/cli/check.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { Usage } from '../ingest';
2
+ import { NameDriftFinding } from '../nameDrift';
2
3
  import { FixOutcome } from '../htmlReport';
3
4
  /**
4
5
  * `ia-qa-heal diff [baseline] [current] [--strict] [--json] [--dir]`
@@ -42,13 +43,21 @@ export declare function writeDriftReport(reportPath: string, agg: DirReport, rep
42
43
  isLayout: boolean;
43
44
  }>, baselineDirPath: string, currentDirPath: string, usage?: Usage | null, open?: boolean, outcome?: FixOutcome): void;
44
45
  export declare function runDiff(args: string[]): Promise<void>;
46
+ /**
47
+ * The name-drift section of a human verdict.
48
+ *
49
+ * Deliberately loud: this is the class of drift that used to exit 0 while the
50
+ * suite went red, so it prints the failing call sites — file:line — rather than
51
+ * a count. A count would be one more thing to eyeball; a file:line is a fix.
52
+ */
53
+ export declare function printNameDrift(findings: NameDriftFinding[] | undefined): void;
45
54
  /**
46
55
  * The `diff`/`run` verdict compressed to the one greppable receipt line. Placed after
47
56
  * the detail block so a `tail` catches the takeaway even when the rows scrolled past —
48
57
  * and never in `--json` mode, where it would corrupt the machine-readable payload.
49
58
  * Shared with `run`, which ends on the same verdict.
50
59
  */
51
- export declare function driftSummaryLine(verb: string, verdict: 'PASS' | 'FIX' | 'BLOCK', c: Report['counts']): void;
60
+ export declare function driftSummaryLine(verb: string, verdict: 'PASS' | 'FIX' | 'BLOCK', c: Report['counts'], nameDrift?: NameDriftFinding[]): void;
52
61
  export interface DirDiffCollection {
53
62
  reports: Array<{
54
63
  name: string;
@@ -80,6 +89,11 @@ export interface DirReport {
80
89
  totals: Report['counts'];
81
90
  /** Pairs left out of the verdict: same capture on both sides, so incapable of showing drift. */
82
91
  staleExcluded?: string[];
92
+ /**
93
+ * Renamed labels the suite locates by name, de-duped across pages — a shared
94
+ * header renames once, not once per page that carries it.
95
+ */
96
+ nameDrift?: NameDriftFinding[];
83
97
  }
84
98
  export declare function aggregateReports(reports: Array<{
85
99
  name: string;
@@ -129,4 +143,9 @@ export interface Report {
129
143
  };
130
144
  rows: DiffRow[];
131
145
  added: DiffRow[];
146
+ /**
147
+ * Renames the suite locates by name. Present only once `ia-qa-heal ingest` has
148
+ * inventoried the tests — absent means "not measured", never "none".
149
+ */
150
+ nameDrift?: NameDriftFinding[];
132
151
  }
package/dist/cli/diff.js CHANGED
@@ -37,6 +37,7 @@ exports.takeReportFlag = takeReportFlag;
37
37
  exports.openInBrowser = openInBrowser;
38
38
  exports.writeDriftReport = writeDriftReport;
39
39
  exports.runDiff = runDiff;
40
+ exports.printNameDrift = printNameDrift;
40
41
  exports.driftSummaryLine = driftSummaryLine;
41
42
  exports.collectDirDiff = collectDirDiff;
42
43
  exports.aggregateReports = aggregateReports;
@@ -48,6 +49,8 @@ const match_1 = require("../browser/match");
48
49
  const config_1 = require("../config");
49
50
  const ingest_1 = require("../ingest");
50
51
  const captureMerge_1 = require("../captureMerge");
52
+ const nameHint_1 = require("../nameHint");
53
+ const nameDrift_1 = require("../nameDrift");
51
54
  const htmlReport_1 = require("../htmlReport");
52
55
  const summary_1 = require("./summary");
53
56
  /**
@@ -172,21 +175,48 @@ async function runDiff(args) {
172
175
  refuseToCompare('same-capture', sameCaptureMessage(baseline.capturedAt), 'Change the app and run `ia-qa-heal map` again, so the two sides are different moments.', json);
173
176
  return;
174
177
  }
178
+ const usage = (0, ingest_1.loadUsage)();
175
179
  const report = attachBaselineHrefs((0, match_1.diffMappings)(baseline, current), baseline);
180
+ // A rename is harmless to a CSS-anchored suite and fatal to a name-anchored
181
+ // one. Only the test inventory can tell the two apart, so the verdict is
182
+ // raised here rather than inside the shared matcher.
183
+ report.nameDrift = (0, nameDrift_1.assessNameDrift)(report.rows, current.elements, usage);
184
+ report.verdict = (0, nameDrift_1.escalateVerdict)(report.verdict, report.nameDrift);
176
185
  if (json) {
177
186
  console.log(JSON.stringify(report, null, 2));
178
187
  }
179
188
  else {
180
- printHuman(report, files, (0, ingest_1.loadUsage)());
189
+ printHuman(report, files, usage);
190
+ printNameDrift(report.nameDrift);
181
191
  }
182
192
  if (reportPath) {
183
193
  const single = [{ name: path.basename(files[1]).replace(/\.json$/, ''), report, isLayout: false }];
184
- writeDriftReport(reportPath, aggregateReports(single), single, files[0], files[1], (0, ingest_1.loadUsage)(), open);
194
+ writeDriftReport(reportPath, aggregateReports(single), single, files[0], files[1], usage, open);
185
195
  }
186
196
  const failing = report.verdict === 'BLOCK' || (strict && report.verdict === 'FIX');
187
197
  process.exitCode = failing ? 1 : 0;
188
198
  if (!json)
189
- driftSummaryLine('diff', report.verdict, report.counts);
199
+ driftSummaryLine('diff', report.verdict, report.counts, report.nameDrift);
200
+ }
201
+ /**
202
+ * The name-drift section of a human verdict.
203
+ *
204
+ * Deliberately loud: this is the class of drift that used to exit 0 while the
205
+ * suite went red, so it prints the failing call sites — file:line — rather than
206
+ * a count. A count would be one more thing to eyeball; a file:line is a fix.
207
+ */
208
+ function printNameDrift(findings) {
209
+ if (!findings || findings.length === 0)
210
+ return;
211
+ const fixable = findings.filter((f) => f.fixable);
212
+ const manual = findings.filter((f) => !f.fixable);
213
+ console.log(`\n 🏷 ${findings.length} renamed label${findings.length === 1 ? '' : 's'} your tests locate by name` +
214
+ ` — a selector diff alone would have called this PASS.`);
215
+ for (const f of [...manual, ...fixable])
216
+ console.log((0, nameDrift_1.formatFinding)(f));
217
+ if (fixable.length > 0) {
218
+ console.log(`\n ${fixable.length} of them are deterministic: \`ia-qa-heal fix\` rewrites the call.`);
219
+ }
190
220
  }
191
221
  /**
192
222
  * The `diff`/`run` verdict compressed to the one greppable receipt line. Placed after
@@ -194,16 +224,28 @@ async function runDiff(args) {
194
224
  * and never in `--json` mode, where it would corrupt the machine-readable payload.
195
225
  * Shared with `run`, which ends on the same verdict.
196
226
  */
197
- function driftSummaryLine(verb, verdict, c) {
227
+ function driftSummaryLine(verb, verdict, c, nameDrift = []) {
198
228
  const kind = verdict === 'PASS' ? 'ok' : verdict === 'FIX' ? 'fix' : 'block';
229
+ const nameFixable = nameDrift.filter((f) => f.fixable).length;
230
+ const nameManual = nameDrift.length - nameFixable;
231
+ // Name drift can be the *only* reason for the verdict, and a headline reading
232
+ // "BLOCK · 0 lost · 0 ambiguous · 0 rebound" would send someone hunting for a
233
+ // selector that never broke. Name the actual cause first.
234
+ const blockCause = c.lost + c.ambiguous + c.rebound === 0 && nameManual > 0
235
+ ? `${nameManual} renamed label${nameManual === 1 ? '' : 's'} your tests use`
236
+ : `${c.lost} lost · ${c.ambiguous} ambiguous · ${c.rebound} rebound`;
237
+ const fixCause = c.healable === 0 && nameFixable > 0
238
+ ? `${nameFixable} renamed label${nameFixable === 1 ? '' : 's'}`
239
+ : `${c.healable} healable`;
199
240
  const headline = verdict === 'PASS'
200
241
  ? 'PASS · no drift, suite safe'
201
242
  : verdict === 'FIX'
202
- ? `FIX · ${c.healable} healable → \`fix\``
203
- : `BLOCK · ${c.lost} lost · ${c.ambiguous} ambiguous · ${c.rebound} rebound — human needed`;
243
+ ? `FIX · ${fixCause} → \`fix\``
244
+ : `BLOCK · ${blockCause} — human needed`;
204
245
  (0, summary_1.summaryLine)(verb, kind, headline, [
205
246
  `${c.ok} ok`,
206
247
  c.renamed > 0 ? `${c.renamed} renamed` : undefined,
248
+ nameDrift.length > 0 ? `${nameDrift.length} name-drift` : undefined,
207
249
  c.added > 0 ? `${c.added} new` : undefined,
208
250
  ]);
209
251
  }
@@ -246,6 +288,7 @@ async function runDirDiff(baselineDir, currentDir, opts) {
246
288
  }
247
289
  else {
248
290
  printAggregate(aggregate, reports, baselineDir, currentDir, usage);
291
+ printNameDrift(aggregate.nameDrift);
249
292
  }
250
293
  if (opts.reportPath) {
251
294
  writeDriftReport(opts.reportPath, aggregate, reports, baselineDir, currentDir, usage, opts.open);
@@ -253,7 +296,7 @@ async function runDirDiff(baselineDir, currentDir, opts) {
253
296
  const failing = aggregate.verdict === 'BLOCK' || (opts.strict && aggregate.verdict === 'FIX');
254
297
  process.exitCode = failing ? 1 : 0;
255
298
  if (!opts.json)
256
- driftSummaryLine('diff', aggregate.verdict, aggregate.totals);
299
+ driftSummaryLine('diff', aggregate.verdict, aggregate.totals, aggregate.nameDrift);
257
300
  }
258
301
  /**
259
302
  * The collection half of a directory diff, shared with `ia-qa-heal run`: walk two
@@ -265,6 +308,15 @@ function collectDirDiff(baselineDir, currentDir) {
265
308
  // Kept out of the verdict rather than counted as `ok` — N ok that were never
266
309
  // compared is exactly the false green this guard exists to stop.
267
310
  const stale = [];
311
+ // Read once for the whole walk: `run` and `diff --dir` both come through here,
312
+ // so name drift is assessed on every path that produces a directory verdict.
313
+ const usage = (0, ingest_1.loadUsage)();
314
+ const assess = (baseline, current) => {
315
+ const report = attachBaselineHrefs((0, match_1.diffMappings)(baseline, current), baseline);
316
+ report.nameDrift = (0, nameDrift_1.assessNameDrift)(report.rows, current.elements, usage);
317
+ report.verdict = (0, nameDrift_1.escalateVerdict)(report.verdict, report.nameDrift);
318
+ return report;
319
+ };
268
320
  const layoutsDir = path.join(baselineDir, '_layouts');
269
321
  if (fs.existsSync(layoutsDir)) {
270
322
  const layoutFiles = fs.readdirSync(layoutsDir).filter((f) => f.endsWith('.json'));
@@ -281,7 +333,7 @@ function collectDirDiff(baselineDir, currentDir) {
281
333
  stale.push(`_layouts/${name}`);
282
334
  continue;
283
335
  }
284
- const report = attachBaselineHrefs((0, match_1.diffMappings)(baseline, current), baseline);
336
+ const report = assess(baseline, current);
285
337
  reports.push({ name: `_layouts/${name}`, report, isLayout: true });
286
338
  }
287
339
  catch (e) {
@@ -306,7 +358,7 @@ function collectDirDiff(baselineDir, currentDir) {
306
358
  stale.push(name);
307
359
  continue;
308
360
  }
309
- const report = attachBaselineHrefs((0, match_1.diffMappings)(baseline, current), baseline);
361
+ const report = assess(baseline, current);
310
362
  reports.push({ name, report, isLayout: false });
311
363
  }
312
364
  catch (e) {
@@ -345,8 +397,12 @@ function aggregateReports(reports) {
345
397
  return 'PASS';
346
398
  };
347
399
  const verdict = reports.reduce((acc, r) => worstV(acc, r.report.verdict), 'PASS');
400
+ // Per-page verdicts are already escalated by `collectDirDiff`, so the aggregate
401
+ // inherits the raise for free — this only collects the findings for reporting.
402
+ const nameDrift = (0, nameDrift_1.mergeFindings)(reports.map((r) => r.report.nameDrift ?? []));
348
403
  return {
349
404
  verdict,
405
+ ...(nameDrift.length > 0 ? { nameDrift } : {}),
350
406
  layoutVerdict,
351
407
  layoutCounts,
352
408
  pageReports: pageReports.map((r) => ({
@@ -443,6 +499,9 @@ function printAggregate(agg, reports, baselineDir, currentDir, usage) {
443
499
  if (agg.totals.healable > 0) {
444
500
  console.log(' 🔧 Healable selectors can be auto-fixed with `ia-qa-heal fix --dir`.');
445
501
  }
502
+ if (agg.totals.renamed > 0) {
503
+ nameDriftNote(reports.flatMap((r) => r.report.rows));
504
+ }
446
505
  console.log('');
447
506
  }
448
507
  function statusIcon(status) {
@@ -517,6 +576,43 @@ function volatileNote(rows) {
517
576
  console.log(` lists) that is usually content churn, not UI drift. If ${n === 1 ? 'it rotates' : 'they rotate'} by design, keep`);
518
577
  console.log(` ${n === 1 ? 'it' : 'them'} out of the contract: "volatile": ["href:*source=rss*"] in .ia-qa/config.json.`);
519
578
  }
579
+ /**
580
+ * The variable-label hint. Sibling of `volatileNote`, and display-only for the same
581
+ * reason: only the user knows what rotates by design, so this names what it saw and the
582
+ * one action that exists today — it never filters a row or moves a verdict.
583
+ *
584
+ * It points at `volatile` and states its cost out loud (the element leaves the contract),
585
+ * because that cost is the whole decision. Nothing here suggests a config key that the
586
+ * loader would accept and then ignore: an ignored declaration is exactly the silent
587
+ * failure this hint exists to end.
588
+ */
589
+ const MAX_HINT_GROUPS = 6;
590
+ function nameDriftNote(rows) {
591
+ const groups = (0, nameHint_1.groupNumericRenames)(rows);
592
+ if (groups.length === 0)
593
+ return;
594
+ const total = groups.reduce((n, g) => n + g.count, 0);
595
+ console.log(` 💡 ${total} renamed ${total === 1 ? 'label differs' : 'labels differ'} only by a number — a counter, total or`);
596
+ console.log(` clock drifts on its own, so ${total === 1 ? 'this row comes' : 'these rows come'} back every run. If they rotate by`);
597
+ console.log(` design, keep them out of the contract with "volatile" in .ia-qa/config.json`);
598
+ console.log(` (which drops the element from the gate entirely, coverage included):`);
599
+ // A pattern with no stable part would match every label in the app, so those groups
600
+ // are offered by selector instead — never as a `*` that silently masks everything.
601
+ const shown = groups.slice(0, MAX_HINT_GROUPS);
602
+ const entries = shown.map((g) => ({
603
+ key: g.pattern ? `"name:${g.pattern}"` : `"selector:${g.examples[0].selector}"`,
604
+ group: g,
605
+ }));
606
+ const width = Math.max(...entries.map((e) => e.key.length));
607
+ for (const { key, group } of entries) {
608
+ const e = group.examples[0];
609
+ const why = group.pattern ? '' : ' (no stable part left in the label)';
610
+ console.log(` ${key.padEnd(width)} ${group.count} row${group.count === 1 ? ' ' : 's'} e.g. "${e.from}" → "${e.to}"${why}`);
611
+ }
612
+ if (groups.length > shown.length) {
613
+ console.log(` … and ${groups.length - shown.length} more group${groups.length - shown.length === 1 ? '' : 's'} (see the renamed rows above).`);
614
+ }
615
+ }
520
616
  function printHuman(report, files, usage) {
521
617
  const { verdict, counts, rows, added } = report;
522
618
  const icon = verdict === 'PASS' ? '✅' : verdict === 'FIX' ? '🔧' : '⛔';
@@ -565,6 +661,7 @@ function printHuman(report, files, usage) {
565
661
  }
566
662
  if (counts.renamed > 0) {
567
663
  console.log(' ✏️ Renamed elements still pass (selector valid) but the label changed — check it is intended, not a regression.');
664
+ nameDriftNote(rows);
568
665
  }
569
666
  if (verdict === 'PASS' && counts.renamed === 0) {
570
667
  console.log(' ✅ No drift — your suite is safe to run.');