@selfcure/analyzer 0.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/dist/index.js ADDED
@@ -0,0 +1,1483 @@
1
+ // src/tml/schema.ts
2
+ var TML_LABELS = {
3
+ 0: "unusable",
4
+ 1: "fragile",
5
+ 2: "usable",
6
+ 3: "stable",
7
+ 4: "governed"
8
+ };
9
+
10
+ // src/tml/score.ts
11
+ function computeStaticCeiling(input) {
12
+ const { testabilityScore, ambiguous, bestStrategy, inventory } = input;
13
+ if (testabilityScore <= 10) return 0;
14
+ if (ambiguous) return 1;
15
+ if (bestStrategy === "css" || bestStrategy === "xpath") return 1;
16
+ if (bestStrategy === "id" || bestStrategy === "aria-label" || bestStrategy === "name") return 2;
17
+ if (bestStrategy === "data-testid") {
18
+ if (!inventory) return 3;
19
+ if (inventory.isDuplicate || inventory.isDeprecated || !inventory.namingValid) return 2;
20
+ if (inventory.hasOwner && inventory.hasIntent) return 4;
21
+ return 3;
22
+ }
23
+ return 1;
24
+ }
25
+ function computeCeilingLevel(input) {
26
+ let level = computeStaticCeiling(input);
27
+ if (input.runtime) {
28
+ if (!input.runtime.observed) {
29
+ if (level > 2) level = 2;
30
+ } else if (!input.runtime.unique) {
31
+ if (level > 1) level = 1;
32
+ }
33
+ }
34
+ return level;
35
+ }
36
+ function buildReasons(input, level) {
37
+ const { testabilityScore, ambiguous, bestStrategy, inventory, runtime } = input;
38
+ const reasons = [];
39
+ if (bestStrategy === "data-testid" && !ambiguous) {
40
+ reasons.push({
41
+ code: "stable-testid",
42
+ severity: "info",
43
+ message: "Governed data-testid provides a stable, refactor-proof locator."
44
+ });
45
+ } else if (bestStrategy === "aria-label" || bestStrategy === "id" || bestStrategy === "name") {
46
+ reasons.push({
47
+ code: "strong-role-name",
48
+ severity: "info",
49
+ message: `Selector uses ${bestStrategy} \u2014 stable but can drift on copy-changes.`
50
+ });
51
+ }
52
+ if (bestStrategy !== "data-testid") {
53
+ reasons.push({
54
+ code: "missing-testid",
55
+ severity: level <= 1 ? "error" : "warning",
56
+ message: "No data-testid found. Adding one raises the ceiling to TML 3."
57
+ });
58
+ }
59
+ if (bestStrategy === "css" || bestStrategy === "xpath") {
60
+ reasons.push({
61
+ code: "weak-css-selector",
62
+ severity: "error",
63
+ message: `Best available selector is ${bestStrategy} (score ${testabilityScore}) \u2014 brittle under refactoring.`
64
+ });
65
+ }
66
+ if (ambiguous) {
67
+ reasons.push({
68
+ code: "ambiguous-selector",
69
+ severity: "error",
70
+ message: "Best selector matches multiple siblings \u2014 Playwright would resolve to several nodes."
71
+ });
72
+ }
73
+ if (testabilityScore <= 10) {
74
+ reasons.push({
75
+ code: "missing-accessible-name",
76
+ severity: "error",
77
+ message: "Element has no accessible name, role, or identifier \u2014 not reliably targetable."
78
+ });
79
+ }
80
+ if (inventory) {
81
+ if (inventory.isDuplicate) {
82
+ reasons.push({ code: "duplicate-testid", severity: "error", message: "data-testid is shared with another element \u2014 violates the stable selector contract." });
83
+ }
84
+ if (inventory.isDeprecated) {
85
+ reasons.push({ code: "deprecated-testid", severity: "warning", message: "data-testid is marked deprecated in the inventory." });
86
+ }
87
+ if (!inventory.namingValid) {
88
+ reasons.push({ code: "invalid-testid-name", severity: "warning", message: "data-testid does not follow the configured naming convention." });
89
+ }
90
+ if (!inventory.hasOwner) {
91
+ reasons.push({ code: "missing-owner", severity: "info", message: "No owner in inventory \u2014 add to enable TML 4 governance." });
92
+ }
93
+ if (!inventory.hasIntent) {
94
+ reasons.push({ code: "missing-intent", severity: "info", message: "No intent in inventory \u2014 add to enable TML 4 governance." });
95
+ }
96
+ }
97
+ if (runtime) {
98
+ if (!runtime.observed) {
99
+ reasons.push({ code: "runtime-not-observed", severity: "warning", message: "Element was not found in the rendered DOM during runtime discovery." });
100
+ } else if (runtime.unique) {
101
+ reasons.push({ code: "runtime-unique", severity: "info", message: "Locator resolved to exactly one element in the rendered DOM." });
102
+ }
103
+ }
104
+ return reasons;
105
+ }
106
+ function buildChanges(input, level) {
107
+ const { bestStrategy, ambiguous, inventory, label, elementType } = input;
108
+ const changes = [];
109
+ if (bestStrategy !== "data-testid") {
110
+ const suggestion = deriveTestIdSuggestion(label, elementType);
111
+ changes.push({
112
+ type: "add-testid",
113
+ priority: level === 0 ? "high" : level === 1 ? "high" : "medium",
114
+ description: "Add a governed data-testid to raise the ceiling to TML 3.",
115
+ suggestedValue: suggestion,
116
+ patchAvailable: true
117
+ });
118
+ }
119
+ if (ambiguous) {
120
+ changes.push({
121
+ type: "add-testid",
122
+ priority: "high",
123
+ description: "Selector is ambiguous. Add a unique data-testid to resolve to a single element.",
124
+ patchAvailable: true
125
+ });
126
+ }
127
+ if (!inventory || !inventory.hasOwner && !inventory.hasIntent) {
128
+ changes.push({
129
+ type: "add-inventory-entry",
130
+ priority: level >= 3 ? "medium" : "low",
131
+ description: "Register in .selfcure/testid-inventory.json (with owner + intent) to reach TML 4."
132
+ });
133
+ }
134
+ if (inventory) {
135
+ if (!inventory.hasOwner) {
136
+ changes.push({ type: "add-owner", priority: "medium", description: "Add owner field to the inventory entry." });
137
+ }
138
+ if (!inventory.hasIntent) {
139
+ changes.push({ type: "add-intent", priority: "medium", description: "Add intent field to the inventory entry." });
140
+ }
141
+ if (inventory.isDuplicate) {
142
+ changes.push({ type: "dedupe-testid", priority: "high", description: "Rename or scope the data-testid so it is unique across the codebase." });
143
+ }
144
+ if (inventory.isDeprecated) {
145
+ changes.push({ type: "rename-testid", priority: "high", description: "Replace the deprecated data-testid with a current one from the inventory." });
146
+ }
147
+ }
148
+ return changes;
149
+ }
150
+ function toKebab(s) {
151
+ return s.trim().replace(/([A-Z])/g, "-$1").replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
152
+ }
153
+ function deriveTestIdSuggestion(label, elementType) {
154
+ if (label) return toKebab(label);
155
+ return `${elementType}-1`;
156
+ }
157
+ function computeConfidence(input) {
158
+ let c = 0.9;
159
+ if (!input.runtime) c -= 0.1;
160
+ if (!input.inventory) c -= 0.05;
161
+ if (input.ambiguous) c += 0.05;
162
+ return Math.min(1, Math.max(0.5, Math.round(c * 100) / 100));
163
+ }
164
+ function assessTagMaturity(input) {
165
+ const level = computeCeilingLevel(input);
166
+ const label = TML_LABELS[level];
167
+ const reasons = buildReasons(input, level);
168
+ const changes = buildChanges(input, level);
169
+ const confidence = computeConfidence(input);
170
+ return {
171
+ level,
172
+ label,
173
+ score: input.testabilityScore,
174
+ reasons,
175
+ requiredChanges: changes,
176
+ confidence
177
+ };
178
+ }
179
+
180
+ // src/testids/inventory.ts
181
+ import { readFile } from "fs/promises";
182
+ var VALID_STATUSES = ["active", "deprecated", "removed"];
183
+ var VALID_STABILITIES = ["stable", "unstable", "dynamic"];
184
+ function parseInventory(raw) {
185
+ let data;
186
+ try {
187
+ data = JSON.parse(raw);
188
+ } catch {
189
+ return { ok: false, errors: ["Invalid JSON: could not parse inventory file"] };
190
+ }
191
+ const errors = validateInventory(data);
192
+ if (errors.length > 0) return { ok: false, errors };
193
+ return { ok: true, inventory: normalizeInventory(data) };
194
+ }
195
+ async function loadInventory(filePath) {
196
+ let raw;
197
+ try {
198
+ raw = await readFile(filePath, "utf8");
199
+ } catch (err) {
200
+ const msg = err instanceof Error ? err.message : String(err);
201
+ return { ok: false, errors: [`Cannot read inventory file: ${msg}`] };
202
+ }
203
+ return parseInventory(raw);
204
+ }
205
+ function allTestIds(inventory) {
206
+ const seen = /* @__PURE__ */ new Set();
207
+ const ids = [];
208
+ for (const route of inventory.routes) {
209
+ for (const el of route.elements) {
210
+ if (!seen.has(el.testId)) {
211
+ seen.add(el.testId);
212
+ ids.push(el.testId);
213
+ }
214
+ }
215
+ }
216
+ return ids;
217
+ }
218
+ function validateInventory(data) {
219
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
220
+ return ["Root must be a JSON object"];
221
+ }
222
+ const obj = data;
223
+ const errors = [];
224
+ if (typeof obj["version"] !== "string" || !obj["version"]) {
225
+ errors.push('"version" must be a non-empty string');
226
+ }
227
+ if (typeof obj["app"] !== "string" || !obj["app"]) {
228
+ errors.push('"app" must be a non-empty string');
229
+ }
230
+ if (!Array.isArray(obj["routes"])) {
231
+ errors.push('"routes" must be an array');
232
+ return errors;
233
+ }
234
+ for (let i = 0; i < obj["routes"].length; i++) {
235
+ errors.push(...validateRoute(obj["routes"][i], i));
236
+ }
237
+ return errors;
238
+ }
239
+ function validateRoute(data, index) {
240
+ const prefix = `routes[${index}]`;
241
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
242
+ return [`${prefix} must be an object`];
243
+ }
244
+ const obj = data;
245
+ const errors = [];
246
+ if (typeof obj["path"] !== "string" || !obj["path"]) {
247
+ errors.push(`${prefix}.path must be a non-empty string`);
248
+ }
249
+ if (!Array.isArray(obj["elements"])) {
250
+ errors.push(`${prefix}.elements must be an array`);
251
+ return errors;
252
+ }
253
+ for (let j = 0; j < obj["elements"].length; j++) {
254
+ errors.push(...validateElement(obj["elements"][j], index, j));
255
+ }
256
+ return errors;
257
+ }
258
+ function validateElement(data, ri, ei) {
259
+ const prefix = `routes[${ri}].elements[${ei}]`;
260
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
261
+ return [`${prefix} must be an object`];
262
+ }
263
+ const obj = data;
264
+ const errors = [];
265
+ if (typeof obj["testId"] !== "string" || !obj["testId"].trim()) {
266
+ errors.push(`${prefix}.testId must be a non-empty string`);
267
+ }
268
+ if (obj["status"] !== void 0 && !VALID_STATUSES.includes(obj["status"])) {
269
+ errors.push(`${prefix}.status must be one of: ${VALID_STATUSES.join(", ")}`);
270
+ }
271
+ if (obj["stability"] !== void 0 && !VALID_STABILITIES.includes(obj["stability"])) {
272
+ errors.push(`${prefix}.stability must be one of: ${VALID_STABILITIES.join(", ")}`);
273
+ }
274
+ return errors;
275
+ }
276
+ function normalizeInventory(raw) {
277
+ return {
278
+ version: raw.version,
279
+ app: raw.app,
280
+ generatedAt: raw.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
281
+ routes: (raw.routes ?? []).map(normalizeRoute)
282
+ };
283
+ }
284
+ function normalizeRoute(raw) {
285
+ const result = {
286
+ path: raw.path,
287
+ elements: (raw.elements ?? []).map(normalizeElement)
288
+ };
289
+ if (raw.screen !== void 0) result.screen = raw.screen;
290
+ if (raw.owner !== void 0) result.owner = raw.owner;
291
+ return result;
292
+ }
293
+ function normalizeElement(raw) {
294
+ const now = (/* @__PURE__ */ new Date()).toISOString();
295
+ const result = {
296
+ testId: raw.testId.trim(),
297
+ status: raw.status ?? "active",
298
+ stability: raw.stability ?? "stable",
299
+ firstSeenAt: raw.firstSeenAt ?? now,
300
+ lastSeenAt: raw.lastSeenAt ?? now
301
+ };
302
+ if (raw.component !== void 0) result.component = raw.component;
303
+ if (raw.elementType !== void 0) result.elementType = raw.elementType;
304
+ if (raw.intent !== void 0) result.intent = raw.intent;
305
+ if (raw.label !== void 0) result.label = raw.label;
306
+ if (raw.sourceFile !== void 0) result.sourceFile = raw.sourceFile;
307
+ if (raw.pattern !== void 0) result.pattern = raw.pattern;
308
+ if (raw.owner !== void 0) result.owner = raw.owner;
309
+ return result;
310
+ }
311
+
312
+ // src/testids/naming.ts
313
+ var SEGMENT_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
314
+ var GENERIC_SINGLE_SEGMENTS = /* @__PURE__ */ new Set([
315
+ "button",
316
+ "submit",
317
+ "close",
318
+ "open",
319
+ "cancel",
320
+ "confirm",
321
+ "save",
322
+ "input",
323
+ "form",
324
+ "link",
325
+ "modal",
326
+ "dialog",
327
+ "dropdown",
328
+ "menu",
329
+ "tab",
330
+ "panel",
331
+ "card",
332
+ "item",
333
+ "row",
334
+ "cell",
335
+ "table",
336
+ "list"
337
+ ]);
338
+ var GENERIC_MULTI = /* @__PURE__ */ new Set([
339
+ "button-submit",
340
+ "modal-close",
341
+ "input-email",
342
+ "submit-button",
343
+ "close-button",
344
+ "cancel-button",
345
+ "confirm-button",
346
+ "save-button"
347
+ ]);
348
+ function checkNaming(testId) {
349
+ const segments = testId.split(".");
350
+ if (segments.length === 1) {
351
+ if (GENERIC_SINGLE_SEGMENTS.has(testId) || GENERIC_MULTI.has(testId)) {
352
+ return { testId, rule: "generic-name", reason: "Single-segment name is too generic to be stable" };
353
+ }
354
+ return { testId, rule: "invalid-name", reason: "Must use dot-separated segments: <domain>.<screen>.<element>" };
355
+ }
356
+ for (const segment of segments) {
357
+ if (!SEGMENT_RE.test(segment)) {
358
+ return {
359
+ testId,
360
+ rule: "invalid-name",
361
+ reason: `Segment "${segment}" is invalid \u2014 use lowercase letters, digits, and hyphens only`
362
+ };
363
+ }
364
+ }
365
+ if (GENERIC_MULTI.has(testId)) {
366
+ return { testId, rule: "generic-name", reason: "Name is too generic to be stable" };
367
+ }
368
+ return null;
369
+ }
370
+ function isValidTestId(testId) {
371
+ return checkNaming(testId) === null;
372
+ }
373
+
374
+ // src/testids/audit.ts
375
+ var SEVERITY = {
376
+ "duplicate-testid": "error",
377
+ "test-only-selector": "error",
378
+ "deprecated-used": "error",
379
+ "missing-inventory": "warning",
380
+ "orphaned-inventory": "warning",
381
+ "invalid-name": "warning",
382
+ "generic-name": "warning",
383
+ "missing-owner": "warning"
384
+ };
385
+ function audit(inventory, usages) {
386
+ const issues = [];
387
+ const frontendMap = /* @__PURE__ */ new Map();
388
+ const testMap = /* @__PURE__ */ new Map();
389
+ for (const u of usages) {
390
+ const loc = `${u.filePath}:${u.line}`;
391
+ const map = u.kind === "frontend" ? frontendMap : testMap;
392
+ const locs = map.get(u.value) ?? [];
393
+ locs.push(loc);
394
+ map.set(u.value, locs);
395
+ }
396
+ const inventoryMeta = /* @__PURE__ */ new Map();
397
+ for (const route of inventory.routes) {
398
+ for (const el of route.elements) {
399
+ inventoryMeta.set(el.testId, {
400
+ status: el.status,
401
+ owner: el.owner ?? route.owner
402
+ });
403
+ }
404
+ }
405
+ for (const [testId, locs] of frontendMap) {
406
+ if (locs.length > 1) {
407
+ issues.push({
408
+ rule: "duplicate-testid",
409
+ severity: SEVERITY["duplicate-testid"],
410
+ testId,
411
+ locations: locs
412
+ });
413
+ }
414
+ }
415
+ for (const [testId, locs] of testMap) {
416
+ if (!frontendMap.has(testId)) {
417
+ issues.push({
418
+ rule: "test-only-selector",
419
+ severity: SEVERITY["test-only-selector"],
420
+ testId,
421
+ locations: locs
422
+ });
423
+ }
424
+ }
425
+ for (const [testId, locs] of frontendMap) {
426
+ if (!inventoryMeta.has(testId)) {
427
+ issues.push({
428
+ rule: "missing-inventory",
429
+ severity: SEVERITY["missing-inventory"],
430
+ testId,
431
+ locations: locs
432
+ });
433
+ }
434
+ }
435
+ for (const [testId, meta] of inventoryMeta) {
436
+ if (meta.status === "removed") continue;
437
+ if (!frontendMap.has(testId)) {
438
+ issues.push({
439
+ rule: "orphaned-inventory",
440
+ severity: SEVERITY["orphaned-inventory"],
441
+ testId
442
+ });
443
+ }
444
+ }
445
+ for (const [testId, locs] of frontendMap) {
446
+ const meta = inventoryMeta.get(testId);
447
+ if (meta?.status === "deprecated") {
448
+ issues.push({
449
+ rule: "deprecated-used",
450
+ severity: SEVERITY["deprecated-used"],
451
+ testId,
452
+ locations: locs
453
+ });
454
+ }
455
+ }
456
+ const allObserved = /* @__PURE__ */ new Set([...frontendMap.keys(), ...testMap.keys()]);
457
+ for (const testId of allObserved) {
458
+ const violation = checkNaming(testId);
459
+ if (violation) {
460
+ issues.push({
461
+ rule: violation.rule,
462
+ severity: SEVERITY[violation.rule],
463
+ testId,
464
+ message: violation.reason
465
+ });
466
+ }
467
+ }
468
+ for (const [testId, meta] of inventoryMeta) {
469
+ if (!meta.owner) {
470
+ issues.push({
471
+ rule: "missing-owner",
472
+ severity: SEVERITY["missing-owner"],
473
+ testId
474
+ });
475
+ }
476
+ }
477
+ const summary = {
478
+ totalObserved: frontendMap.size,
479
+ duplicates: count(issues, "duplicate-testid"),
480
+ missingInventory: count(issues, "missing-inventory"),
481
+ orphaned: count(issues, "orphaned-inventory"),
482
+ testOnlySelectors: count(issues, "test-only-selector"),
483
+ invalidNames: count(issues, "invalid-name") + count(issues, "generic-name")
484
+ };
485
+ return { summary, issues };
486
+ }
487
+ function count(issues, rule) {
488
+ return issues.filter((i) => i.rule === rule).length;
489
+ }
490
+
491
+ // src/a11y/rules.ts
492
+ var SEVERITY_ORDER = ["info", "minor", "major", "critical"];
493
+ var LEVEL_HIERARCHY = {
494
+ A: ["A"],
495
+ AA: ["A", "AA"],
496
+ AAA: ["A", "AA", "AAA"]
497
+ };
498
+ var accessibilityRules = [
499
+ // ── Level A — perceivable ─────────────────────────────────────────────────
500
+ {
501
+ id: "a11y.img-alt-text",
502
+ wcag: ["1.1.1"],
503
+ level: "A",
504
+ category: "perceivable",
505
+ severity: "major",
506
+ source: "static",
507
+ paid: true,
508
+ title: "Image must have alternative text",
509
+ description: 'Images must have an alt attribute. Informative images need descriptive alt text. Decorative images must use alt="" so assistive technology skips them.',
510
+ remediation: 'Add an alt attribute with descriptive text for informative images (alt="User profile photo") or alt="" for decorative images.'
511
+ },
512
+ {
513
+ id: "a11y.input-associated-label",
514
+ wcag: ["1.3.1", "3.3.2", "4.1.2"],
515
+ level: "A",
516
+ category: "perceivable",
517
+ severity: "critical",
518
+ source: "static",
519
+ paid: true,
520
+ title: "Input must have an associated label",
521
+ description: "Form inputs must be associated with a label element via htmlFor/for or element wrapping, or provide aria-label/aria-labelledby so assistive technology can identify the field.",
522
+ remediation: 'Add <label htmlFor="inputId">, wrap the input inside a <label>, or add an aria-label or aria-labelledby attribute.'
523
+ },
524
+ // ── Level A — operable ────────────────────────────────────────────────────
525
+ {
526
+ id: "a11y.positive-tabindex",
527
+ wcag: ["2.4.3"],
528
+ level: "A",
529
+ category: "operable",
530
+ severity: "major",
531
+ source: "static",
532
+ paid: true,
533
+ title: "tabIndex must not be positive",
534
+ description: "Positive tabIndex values (tabIndex > 0) disrupt the natural focus order of the page and create a confusing keyboard navigation experience for keyboard-only users.",
535
+ remediation: "Remove the positive tabIndex or use tabIndex={0} to include the element in the natural tab order. Control focus sequence with DOM order and CSS instead."
536
+ },
537
+ {
538
+ id: "a11y.noninteractive-click-handler",
539
+ wcag: ["2.1.1", "4.1.2"],
540
+ level: "A",
541
+ category: "operable",
542
+ severity: "major",
543
+ source: "static",
544
+ paid: true,
545
+ title: "Non-interactive element must not have a click handler without keyboard support",
546
+ description: "Adding onClick to a non-interactive element (div, span, p) without a keyboard equivalent means keyboard-only users and screen reader users cannot trigger the action.",
547
+ remediation: 'Use an interactive element like <button> or <a>. If a div must be clickable, also add role="button", tabIndex={0}, and an onKeyDown handler for Enter and Space.'
548
+ },
549
+ {
550
+ id: "a11y.link-accessible-name",
551
+ wcag: ["4.1.2", "2.4.4"],
552
+ level: "A",
553
+ category: "operable",
554
+ severity: "critical",
555
+ source: "static",
556
+ paid: true,
557
+ title: "Link must have an accessible name",
558
+ description: "Anchor elements used for navigation must have descriptive link text, aria-label, or aria-labelledby so users understand where the link leads.",
559
+ remediation: 'Add descriptive text content inside the <a> element, or use aria-label to provide an accessible name. Avoid generic text like "click here" or "read more".'
560
+ },
561
+ // ── Level A — robust ──────────────────────────────────────────────────────
562
+ {
563
+ id: "a11y.button-accessible-name",
564
+ wcag: ["4.1.2", "2.5.3"],
565
+ level: "A",
566
+ category: "robust",
567
+ severity: "critical",
568
+ source: "static",
569
+ paid: true,
570
+ title: "Button must have an accessible name",
571
+ description: "Interactive buttons need visible text, aria-label, aria-labelledby, or an equivalent accessible name so screen readers and voice control users can identify and activate them.",
572
+ remediation: "Add visible text inside the button element, or an explicit aria-label or aria-labelledby attribute that matches the visible label or user-facing intent."
573
+ },
574
+ {
575
+ id: "a11y.aria-invalid-reference",
576
+ wcag: ["4.1.2"],
577
+ level: "A",
578
+ category: "robust",
579
+ severity: "critical",
580
+ source: "static",
581
+ paid: true,
582
+ title: "ARIA attribute must reference a valid element ID",
583
+ description: "Attributes like aria-labelledby, aria-describedby, and aria-controls must reference IDs that exist in the DOM. Broken references are silently ignored by assistive technology.",
584
+ remediation: "Ensure the referenced ID exists and is spelled correctly. If the referenced element is conditionally rendered, handle the absent case explicitly."
585
+ },
586
+ {
587
+ id: "a11y.missing-dialog-name",
588
+ wcag: ["4.1.2"],
589
+ level: "A",
590
+ category: "robust",
591
+ severity: "critical",
592
+ source: "static",
593
+ paid: true,
594
+ title: "Dialog must have an accessible name",
595
+ description: 'Elements with role="dialog" or role="alertdialog" must have aria-label or aria-labelledby so screen reader users understand the purpose of the dialog when it opens.',
596
+ remediation: "Add aria-labelledby pointing to the dialog title element, or add aria-label with a descriptive name."
597
+ },
598
+ {
599
+ id: "a11y.duplicate-id",
600
+ wcag: ["4.1.1"],
601
+ level: "A",
602
+ category: "robust",
603
+ severity: "critical",
604
+ source: "static",
605
+ paid: true,
606
+ title: "ID attribute must be unique within a page",
607
+ description: "Duplicate IDs break ARIA references, label associations, and fragment links. Assistive technology behaviour when encountering duplicate IDs is undefined.",
608
+ remediation: "Ensure all id attributes are unique within a rendered page. Generate IDs dynamically (e.g. useId()) for list items and reusable components."
609
+ },
610
+ // ── Level AA — perceivable ────────────────────────────────────────────────
611
+ {
612
+ id: "a11y.heading-order",
613
+ wcag: ["1.3.1", "2.4.6"],
614
+ level: "AA",
615
+ category: "perceivable",
616
+ severity: "minor",
617
+ source: "static",
618
+ paid: true,
619
+ title: "Headings must follow a logical order",
620
+ description: "Heading levels (h1\u2013h6) must not skip levels. An h4 following an h2 without an h3 in between breaks the document outline that screen readers use for navigation.",
621
+ remediation: "Ensure headings follow a sequential order. Never use heading elements purely for visual styling \u2014 use CSS instead."
622
+ }
623
+ ];
624
+ function getRuleById(id) {
625
+ return accessibilityRules.find((r) => r.id === id);
626
+ }
627
+ function getRulesByLevel(targetLevel) {
628
+ const included = LEVEL_HIERARCHY[targetLevel];
629
+ return accessibilityRules.filter((r) => included.includes(r.level));
630
+ }
631
+ function getRulesBySeverity(minSeverity) {
632
+ const minIdx = SEVERITY_ORDER.indexOf(minSeverity);
633
+ return accessibilityRules.filter((r) => SEVERITY_ORDER.indexOf(r.severity) >= minIdx);
634
+ }
635
+ function filterRules(opts = {}) {
636
+ let rules = [...accessibilityRules];
637
+ if (opts.level !== void 0) {
638
+ const included = LEVEL_HIERARCHY[opts.level];
639
+ rules = rules.filter((r) => included.includes(r.level));
640
+ }
641
+ if (opts.minSeverity !== void 0) {
642
+ const minIdx = SEVERITY_ORDER.indexOf(opts.minSeverity);
643
+ rules = rules.filter((r) => SEVERITY_ORDER.indexOf(r.severity) >= minIdx);
644
+ }
645
+ if (opts.paid !== void 0) {
646
+ rules = rules.filter((r) => r.paid === opts.paid);
647
+ }
648
+ if (opts.source !== void 0) {
649
+ rules = rules.filter((r) => r.source === opts.source);
650
+ }
651
+ if (opts.category !== void 0) {
652
+ rules = rules.filter((r) => r.category === opts.category);
653
+ }
654
+ return rules;
655
+ }
656
+
657
+ // src/a11y/static.ts
658
+ var _seq = 0;
659
+ function makeFindingId() {
660
+ _seq += 1;
661
+ const ts = Date.now().toString(36);
662
+ const seq = _seq.toString(36).padStart(4, "0");
663
+ return `a11y_${ts}${seq}`;
664
+ }
665
+ var NON_INTERACTIVE_TAGS = /* @__PURE__ */ new Set([
666
+ "div",
667
+ "span",
668
+ "p",
669
+ "section",
670
+ "article",
671
+ "main",
672
+ "aside",
673
+ "header",
674
+ "footer",
675
+ "nav",
676
+ "ul",
677
+ "ol",
678
+ "li",
679
+ "table",
680
+ "thead",
681
+ "tbody",
682
+ "tr",
683
+ "td",
684
+ "th",
685
+ "figure",
686
+ "figcaption",
687
+ "blockquote"
688
+ ]);
689
+ var HEADING_TAGS = ["h1", "h2", "h3", "h4", "h5", "h6"];
690
+ function hasAccessibleName(el) {
691
+ return Boolean(
692
+ el.hasTextChildren || el.attrs["aria-label"] || el.attrs["aria-labelledby"] || el.attrs["title"]
693
+ );
694
+ }
695
+ function buildSelector(el) {
696
+ if (el.attrs["data-testid"]) return `[data-testid="${el.attrs["data-testid"]}"]`;
697
+ if (el.attrs["id"]) return `#${el.attrs["id"]}`;
698
+ if (el.attrs["aria-label"]) return `${el.tag}[aria-label="${el.attrs["aria-label"]}"]`;
699
+ return el.tag;
700
+ }
701
+ function finding(ruleId, el, message) {
702
+ const rule = getRuleById(ruleId);
703
+ if (!rule) return null;
704
+ const now = (/* @__PURE__ */ new Date()).toISOString();
705
+ return {
706
+ id: makeFindingId(),
707
+ ruleId,
708
+ wcag: rule.wcag,
709
+ level: rule.level,
710
+ severity: rule.severity,
711
+ status: "open",
712
+ sourceFile: el.filePath,
713
+ line: el.line,
714
+ column: el.column,
715
+ selector: buildSelector(el),
716
+ message,
717
+ remediation: rule.remediation,
718
+ firstSeenAt: now,
719
+ lastSeenAt: now
720
+ };
721
+ }
722
+ function runAllRules(evidence) {
723
+ const { elements, allIds } = evidence;
724
+ const results = [];
725
+ const idSet = new Set(allIds);
726
+ const labelHtmlFors = new Set(
727
+ elements.filter((e) => e.tag === "label" && e.attrs["htmlFor"] && e.attrs["htmlFor"] !== "__dynamic__").map((e) => e.attrs["htmlFor"])
728
+ );
729
+ const seenIds = /* @__PURE__ */ new Map();
730
+ const headingStack = [];
731
+ for (const el of elements) {
732
+ if (el.tag === "button" && !hasAccessibleName(el)) {
733
+ const f = finding("a11y.button-accessible-name", el, "Button has no accessible name.");
734
+ if (f) results.push(f);
735
+ }
736
+ if (el.tag === "a" && !hasAccessibleName(el)) {
737
+ const f = finding("a11y.link-accessible-name", el, "Link has no accessible name.");
738
+ if (f) results.push(f);
739
+ }
740
+ if (el.tag === "input" && el.attrs["type"] !== "hidden") {
741
+ const hasDirectName = el.attrs["aria-label"] || el.attrs["aria-labelledby"];
742
+ const linkedByLabel = el.attrs["id"] && labelHtmlFors.has(el.attrs["id"]);
743
+ if (!hasDirectName && !linkedByLabel) {
744
+ const f = finding("a11y.input-associated-label", el, "Input has no associated label.");
745
+ if (f) results.push(f);
746
+ }
747
+ }
748
+ if (el.tag === "img" && !("alt" in el.attrs)) {
749
+ const f = finding("a11y.img-alt-text", el, "Image is missing an alt attribute.");
750
+ if (f) results.push(f);
751
+ }
752
+ for (const ariaAttr of ["aria-labelledby", "aria-describedby", "aria-controls"]) {
753
+ const val = el.attrs[ariaAttr];
754
+ if (val && val !== "__dynamic__") {
755
+ for (const refId of val.split(/\s+/).filter(Boolean)) {
756
+ if (!idSet.has(refId)) {
757
+ const f = finding(
758
+ "a11y.aria-invalid-reference",
759
+ el,
760
+ `${ariaAttr}="${refId}" references an ID that does not exist in this file.`
761
+ );
762
+ if (f) results.push(f);
763
+ }
764
+ }
765
+ }
766
+ }
767
+ const rawTab = el.attrs["tabIndex"] ?? el.attrs["tabindex"];
768
+ if (rawTab !== void 0 && rawTab !== "__dynamic__" && rawTab !== "") {
769
+ const idx = Number(rawTab);
770
+ if (!isNaN(idx) && idx > 0) {
771
+ const f = finding(
772
+ "a11y.positive-tabindex",
773
+ el,
774
+ `tabIndex="${rawTab}" disrupts natural focus order \u2014 use 0 or -1 instead.`
775
+ );
776
+ if (f) results.push(f);
777
+ }
778
+ }
779
+ if (NON_INTERACTIVE_TAGS.has(el.tag) && el.attrs["onClick"] !== void 0) {
780
+ const hasRole = el.attrs["role"] !== void 0;
781
+ const hasTabIndex = el.attrs["tabIndex"] !== void 0 || el.attrs["tabindex"] !== void 0;
782
+ if (!hasRole || !hasTabIndex) {
783
+ const f = finding(
784
+ "a11y.noninteractive-click-handler",
785
+ el,
786
+ `<${el.tag}> has onClick but lacks role and/or tabIndex \u2014 keyboard users cannot activate it.`
787
+ );
788
+ if (f) results.push(f);
789
+ }
790
+ }
791
+ const role = el.attrs["role"];
792
+ if (role === "dialog" || role === "alertdialog") {
793
+ if (!el.attrs["aria-label"] && !el.attrs["aria-labelledby"]) {
794
+ const f = finding(
795
+ "a11y.missing-dialog-name",
796
+ el,
797
+ `Dialog (role="${role}") has no accessible name \u2014 add aria-label or aria-labelledby.`
798
+ );
799
+ if (f) results.push(f);
800
+ }
801
+ }
802
+ if (HEADING_TAGS.includes(el.tag)) {
803
+ const level = parseInt(el.tag[1], 10);
804
+ if (headingStack.length > 0) {
805
+ const prev = headingStack[headingStack.length - 1];
806
+ if (level > prev + 1) {
807
+ const f = finding(
808
+ "a11y.heading-order",
809
+ el,
810
+ `Heading level skipped: <${el.tag}> follows <h${prev}> (expected at most h${prev + 1}).`
811
+ );
812
+ if (f) results.push(f);
813
+ }
814
+ }
815
+ headingStack.push(level);
816
+ }
817
+ const elId = el.attrs["id"];
818
+ if (elId && elId !== "__dynamic__" && elId !== "") {
819
+ if (seenIds.has(elId)) {
820
+ const f = finding(
821
+ "a11y.duplicate-id",
822
+ el,
823
+ `ID "${elId}" is already used at line ${seenIds.get(elId)} \u2014 IDs must be unique.`
824
+ );
825
+ if (f) results.push(f);
826
+ } else {
827
+ seenIds.set(elId, el.line);
828
+ }
829
+ }
830
+ }
831
+ return results;
832
+ }
833
+ function runStaticAnalysis(evidenceList, opts = {}) {
834
+ const { level = "AA" } = opts;
835
+ const enabledIds = new Set(getRulesByLevel(level).map((r) => r.id));
836
+ return evidenceList.flatMap(runAllRules).filter((f) => enabledIds.has(f.ruleId)).sort((a, b) => a.sourceFile.localeCompare(b.sourceFile) || a.line - b.line);
837
+ }
838
+
839
+ // src/discovery/testability.ts
840
+ function issuesForElement(el) {
841
+ const issues = [];
842
+ if (!el.testId && !el.name && !el.role) {
843
+ issues.push("No accessible name, role, or data-testid \u2014 selector is unstable");
844
+ }
845
+ if (!el.testId && el.score < 75) {
846
+ issues.push("Missing data-testid \u2014 add one for a stable 100-score locator");
847
+ }
848
+ if (el.tag === "div" || el.tag === "span") {
849
+ issues.push("Non-semantic interactive element \u2014 prefer <button> or <a>");
850
+ }
851
+ return issues;
852
+ }
853
+ function averageScore(elements) {
854
+ if (elements.length === 0) return 100;
855
+ const sum = elements.reduce((acc, el) => acc + el.score, 0);
856
+ return Math.round(sum / elements.length);
857
+ }
858
+ function buildTestabilityReport(result, minimumScore = 80) {
859
+ const routeResults = [];
860
+ for (const evidence of result.routes) {
861
+ const interactive = evidence.interactiveElements;
862
+ const findings = [];
863
+ for (const el of interactive) {
864
+ if (el.score < minimumScore) {
865
+ findings.push({
866
+ route: evidence.route,
867
+ url: evidence.url,
868
+ element: el,
869
+ score: el.score,
870
+ issues: issuesForElement(el)
871
+ });
872
+ }
873
+ }
874
+ routeResults.push({
875
+ route: evidence.route,
876
+ url: evidence.url,
877
+ status: evidence.status,
878
+ score: averageScore(interactive),
879
+ totalElements: interactive.length,
880
+ flaggedCount: findings.length,
881
+ findings
882
+ });
883
+ }
884
+ const totalElements = routeResults.reduce((s, r) => s + r.totalElements, 0);
885
+ const flaggedCount = routeResults.reduce((s, r) => s + r.flaggedCount, 0);
886
+ const overallScore = routeResults.length === 0 ? 100 : Math.round(routeResults.reduce((s, r) => s + r.score, 0) / routeResults.length);
887
+ return {
888
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
889
+ minimumScore,
890
+ routes: routeResults,
891
+ overall: { score: overallScore, totalElements, flaggedCount }
892
+ };
893
+ }
894
+ function summarizeReport(report) {
895
+ const reachable = report.routes.filter((r) => r.status === "reachable");
896
+ return {
897
+ critical: reachable.filter((r) => r.score < 60),
898
+ warning: reachable.filter((r) => r.score >= 60 && r.score < report.minimumScore),
899
+ healthy: reachable.filter((r) => r.score >= report.minimumScore)
900
+ };
901
+ }
902
+
903
+ // src/tml/inventory.ts
904
+ function buildDuplicateSet(inventory) {
905
+ const counts = /* @__PURE__ */ new Map();
906
+ for (const route of inventory.routes) {
907
+ for (const el of route.elements) {
908
+ counts.set(el.testId, (counts.get(el.testId) ?? 0) + 1);
909
+ }
910
+ }
911
+ return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id));
912
+ }
913
+ function isValidTmlNaming(testId) {
914
+ return /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*){1,4}$/.test(testId);
915
+ }
916
+ function buildTmlInventoryEntry(testId, inventory, duplicateIds) {
917
+ for (const route of inventory.routes) {
918
+ const invEl = route.elements.find((e) => e.testId === testId);
919
+ if (invEl) {
920
+ return {
921
+ hasOwner: Boolean(invEl.owner ?? route.owner),
922
+ hasIntent: Boolean(invEl.intent),
923
+ hasRoute: Boolean(route.path),
924
+ isDeprecated: invEl.status === "deprecated" || invEl.status === "removed",
925
+ isDuplicate: duplicateIds.has(testId),
926
+ namingValid: isValidTmlNaming(testId)
927
+ };
928
+ }
929
+ }
930
+ return void 0;
931
+ }
932
+ function extractTestIdValue(dataTestIdSelector) {
933
+ if (!dataTestIdSelector) return void 0;
934
+ const m = dataTestIdSelector.match(/\[data-testid=["']([^"']+)["']\]/);
935
+ return m ? m[1] : void 0;
936
+ }
937
+ function enrichTmlWithInventory(results, inventory) {
938
+ const duplicates = buildDuplicateSet(inventory);
939
+ for (const { interactiveElements } of results) {
940
+ for (const el of interactiveElements) {
941
+ const rawTestId = extractTestIdValue(el.selectors.dataTestId);
942
+ const invEntry = rawTestId ? buildTmlInventoryEntry(rawTestId, inventory, duplicates) : void 0;
943
+ const best = el.selectorRanking[0];
944
+ el.tml = assessTagMaturity({
945
+ testabilityScore: el.testabilityScore,
946
+ ambiguous: el.ambiguous,
947
+ bestStrategy: best?.strategy ?? "css",
948
+ label: el.label,
949
+ elementType: el.type,
950
+ inventory: invEntry
951
+ });
952
+ }
953
+ }
954
+ }
955
+
956
+ // src/tml/runtime.ts
957
+ function extractTestId(sel) {
958
+ if (!sel) return void 0;
959
+ const m = sel.match(/\[data-testid=["']([^"']+)["']\]/);
960
+ return m ? m[1] : void 0;
961
+ }
962
+ function extractId(sel) {
963
+ if (!sel) return void 0;
964
+ const m = sel.match(/^#(.+)$/);
965
+ return m ? m[1] : void 0;
966
+ }
967
+ function extractAriaLabel(sel) {
968
+ if (!sel) return void 0;
969
+ const m = sel.match(/\[aria-label=["']([^"']+)["']\]/);
970
+ return m ? m[1] : void 0;
971
+ }
972
+ function matchRuntime(el, routes) {
973
+ const reachable = routes.filter((r) => r.status === "reachable");
974
+ const allRt = reachable.flatMap((r) => r.interactiveElements);
975
+ const testId = extractTestId(el.selectors.dataTestId);
976
+ if (testId) {
977
+ const hits = allRt.filter((r) => r.testId === testId);
978
+ if (hits.length > 0) return { found: true, unique: hits.length === 1 };
979
+ }
980
+ const idVal = extractId(el.selectors.id);
981
+ if (idVal) {
982
+ const hits = allRt.filter((r) => r.selector === `#${idVal}`);
983
+ if (hits.length > 0) return { found: true, unique: hits.length === 1 };
984
+ }
985
+ const ariaLabel = extractAriaLabel(el.selectors.ariaLabel);
986
+ const nameToMatch = ariaLabel ?? el.label;
987
+ if (nameToMatch) {
988
+ const hits = allRt.filter(
989
+ (r) => r.name === nameToMatch || r.selector === `[aria-label="${nameToMatch}"]`
990
+ );
991
+ if (hits.length > 0) return { found: true, unique: hits.length === 1 };
992
+ }
993
+ return { found: false, unique: false };
994
+ }
995
+ function enrichTmlWithRuntime(results, runtimeMap, inventory) {
996
+ const duplicates = inventory ? buildDuplicateSet(inventory) : /* @__PURE__ */ new Set();
997
+ for (const { interactiveElements } of results) {
998
+ for (const el of interactiveElements) {
999
+ const match = matchRuntime(el, runtimeMap.routes);
1000
+ const testId = extractTestId(el.selectors.dataTestId);
1001
+ let invEntry;
1002
+ if (inventory && testId) {
1003
+ invEntry = buildTmlInventoryEntry(testId, inventory, duplicates);
1004
+ }
1005
+ const best = el.selectorRanking[0];
1006
+ el.tml = assessTagMaturity({
1007
+ testabilityScore: el.testabilityScore,
1008
+ ambiguous: el.ambiguous,
1009
+ bestStrategy: best?.strategy ?? "css",
1010
+ label: el.label,
1011
+ elementType: el.type,
1012
+ inventory: invEntry,
1013
+ runtime: {
1014
+ observed: match.found,
1015
+ unique: match.unique
1016
+ }
1017
+ });
1018
+ }
1019
+ }
1020
+ }
1021
+ async function loadRuntimeMap(filePath) {
1022
+ const { readFile: readFile3 } = await import("fs/promises");
1023
+ const raw = await readFile3(filePath, "utf-8");
1024
+ const parsed = JSON.parse(raw);
1025
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed["routes"])) {
1026
+ throw new Error(`Invalid route-map.json at ${filePath}`);
1027
+ }
1028
+ return parsed;
1029
+ }
1030
+
1031
+ // src/a11y/findings.ts
1032
+ import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
1033
+ import path from "path";
1034
+ function parseFindings(raw) {
1035
+ let data;
1036
+ try {
1037
+ data = JSON.parse(raw);
1038
+ } catch {
1039
+ return { ok: false, errors: ["Invalid JSON: could not parse findings file"] };
1040
+ }
1041
+ const errors = validateFindings(data);
1042
+ if (errors.length > 0) return { ok: false, errors };
1043
+ return { ok: true, inventory: data };
1044
+ }
1045
+ function validateFindings(data) {
1046
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
1047
+ return ["Root must be a JSON object"];
1048
+ }
1049
+ const obj = data;
1050
+ const errors = [];
1051
+ if (typeof obj["version"] !== "string") errors.push('"version" must be a string');
1052
+ if (typeof obj["app"] !== "string") errors.push('"app" must be a string');
1053
+ if (obj["standard"] !== "WCAG") errors.push('"standard" must be "WCAG"');
1054
+ if (!Array.isArray(obj["findings"])) errors.push('"findings" must be an array');
1055
+ return errors;
1056
+ }
1057
+ async function loadFindings(filePath) {
1058
+ let raw;
1059
+ try {
1060
+ raw = await readFile2(filePath, "utf-8");
1061
+ } catch (err) {
1062
+ const msg = err instanceof Error ? err.message : String(err);
1063
+ return { ok: false, errors: [`Cannot read findings file: ${msg}`] };
1064
+ }
1065
+ return parseFindings(raw);
1066
+ }
1067
+ async function saveFindings(filePath, inventory) {
1068
+ await mkdir(path.dirname(filePath), { recursive: true });
1069
+ await writeFile(filePath, JSON.stringify(inventory, null, 2), "utf-8");
1070
+ }
1071
+ function emptyInventory(opts) {
1072
+ return {
1073
+ version: "1.0",
1074
+ app: opts.app,
1075
+ standard: "WCAG",
1076
+ targetLevel: opts.targetLevel ?? "AA",
1077
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1078
+ findings: []
1079
+ };
1080
+ }
1081
+ function mergeFindings(existing, newFindings) {
1082
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1083
+ const newByKey = /* @__PURE__ */ new Map();
1084
+ for (const f of newFindings) {
1085
+ newByKey.set(findingKey(f), f);
1086
+ }
1087
+ const merged = [];
1088
+ for (const f of existing.findings) {
1089
+ if (f.status === "suppressed") {
1090
+ merged.push(f);
1091
+ newByKey.delete(findingKey(f));
1092
+ continue;
1093
+ }
1094
+ const key = findingKey(f);
1095
+ if (newByKey.has(key)) {
1096
+ merged.push({ ...f, status: "open", lastSeenAt: now });
1097
+ newByKey.delete(key);
1098
+ } else if (f.status === "open") {
1099
+ merged.push({ ...f, status: "resolved", lastSeenAt: now });
1100
+ } else {
1101
+ merged.push(f);
1102
+ }
1103
+ }
1104
+ for (const f of newByKey.values()) {
1105
+ merged.push({ ...f, status: "open", firstSeenAt: now, lastSeenAt: now });
1106
+ }
1107
+ return { ...existing, generatedAt: now, findings: merged };
1108
+ }
1109
+ function findingKey(f) {
1110
+ return `${f.ruleId}::${f.sourceFile}::${f.line}::${f.column}`;
1111
+ }
1112
+
1113
+ // src/a11y/audit.ts
1114
+ function runAudit(inventory, opts = {}) {
1115
+ const { failOn = "major" } = opts;
1116
+ const failOnIdx = SEVERITY_ORDER.indexOf(failOn);
1117
+ const open = inventory.findings.filter((f) => f.status === "open");
1118
+ const resolved = inventory.findings.filter((f) => f.status === "resolved");
1119
+ const suppressed = inventory.findings.filter((f) => f.status === "suppressed");
1120
+ const bySeverity = { critical: 0, major: 0, minor: 0, info: 0 };
1121
+ for (const f of open) bySeverity[f.severity]++;
1122
+ const wouldFailCI = open.some(
1123
+ (f) => SEVERITY_ORDER.indexOf(f.severity) >= failOnIdx
1124
+ );
1125
+ return {
1126
+ findings: inventory.findings,
1127
+ counts: {
1128
+ open: open.length,
1129
+ resolved: resolved.length,
1130
+ suppressed: suppressed.length,
1131
+ bySeverity
1132
+ },
1133
+ wouldFailCI
1134
+ };
1135
+ }
1136
+
1137
+ // src/ide-prompt.ts
1138
+ var DEFAULT_CONVENTION = "[component]-[type]-[purpose] in kebab-case (e.g. checkout-select-payment-method)";
1139
+ function relPath(filePath, baseDir) {
1140
+ let p = filePath;
1141
+ if (baseDir && p.startsWith(baseDir)) {
1142
+ p = p.slice(baseDir.length).replace(/^[/\\]+/, "");
1143
+ }
1144
+ return p.replace(/\\/g, "/");
1145
+ }
1146
+ function lineFor(issue) {
1147
+ const loc = issue.line ? `L${issue.line} \xB7 ` : "";
1148
+ const where = issue.selector ? ` matched by \`${issue.selector}\`` : "";
1149
+ const reason = issue.kind === "ambiguous" ? issue.ambiguityReason ? `ambiguous \u2014 ${issue.ambiguityReason.replace(/\.\s*$/, "")}` : "ambiguous \u2014 its best selector also matches a sibling" : "no stable selector (low testability)";
1150
+ return `- ${loc}the \`${issue.elementType}\` element${where} \u2014 ${reason}. Add \`data-testid="${issue.suggestedTestId}"\`.`;
1151
+ }
1152
+ function buildIdePrompt(issues, opts = {}) {
1153
+ if (issues.length === 0) return "";
1154
+ const convention = opts.convention ?? DEFAULT_CONVENTION;
1155
+ const byFile = /* @__PURE__ */ new Map();
1156
+ for (const issue of issues) {
1157
+ const key = relPath(issue.filePath, opts.baseDir);
1158
+ if (!byFile.has(key)) byFile.set(key, []);
1159
+ byFile.get(key).push(issue);
1160
+ }
1161
+ const fileBlocks = [];
1162
+ for (const [file, fileIssues] of byFile) {
1163
+ fileBlocks.push(`### ${file}
1164
+ ${fileIssues.map(lineFor).join("\n")}`);
1165
+ }
1166
+ return [
1167
+ "You are working in a frontend codebase. Add stable `data-testid` attributes to the",
1168
+ "interactive elements listed below so automated tests can target them reliably.",
1169
+ "Change nothing else \u2014 do not alter behavior, styling, or unrelated markup.",
1170
+ "",
1171
+ `Naming convention: ${convention}.`,
1172
+ "Keep every `data-testid` value unique within its file. For elements flagged as",
1173
+ "ambiguous, make sure the new value disambiguates it from its siblings.",
1174
+ "",
1175
+ `Elements to fix (${issues.length} across ${byFile.size} file(s)):`,
1176
+ "",
1177
+ fileBlocks.join("\n\n"),
1178
+ "",
1179
+ "After editing, the listed elements should each be addressable by a unique",
1180
+ "`data-testid`. Then I can open a pull request with the changes."
1181
+ ].join("\n");
1182
+ }
1183
+
1184
+ // src/index.ts
1185
+ function walkAST(node, visitor) {
1186
+ if (!node || typeof node !== "object") return;
1187
+ const n = node;
1188
+ if (typeof n["type"] === "string") visitor(n);
1189
+ for (const key of Object.keys(n)) {
1190
+ const child = n[key];
1191
+ if (Array.isArray(child)) {
1192
+ for (const item of child) walkAST(item, visitor);
1193
+ } else if (child && typeof child === "object") {
1194
+ walkAST(child, visitor);
1195
+ }
1196
+ }
1197
+ }
1198
+ var TAG_TYPE = {
1199
+ button: "button",
1200
+ input: "input",
1201
+ select: "input",
1202
+ textarea: "input",
1203
+ a: "link",
1204
+ form: "form"
1205
+ };
1206
+ var ACTIONS = {
1207
+ button: ["click"],
1208
+ input: ["fill", "press", "clear"],
1209
+ link: ["click"],
1210
+ form: ["submit"],
1211
+ custom: ["click"]
1212
+ };
1213
+ var CLICK_HANDLER_ATTRS = ["ng-click", "(click)", "onclick", "@click", "v-on:click", "data-ng-click", "click"];
1214
+ var INTERACTIVE_ROLE_TYPE = {
1215
+ button: "button",
1216
+ tab: "button",
1217
+ menuitem: "button",
1218
+ menuitemcheckbox: "button",
1219
+ menuitemradio: "button",
1220
+ checkbox: "button",
1221
+ radio: "button",
1222
+ switch: "button",
1223
+ option: "button",
1224
+ link: "link",
1225
+ combobox: "input",
1226
+ slider: "input",
1227
+ spinbutton: "input"
1228
+ };
1229
+ function classifyElementType(tag, attrs) {
1230
+ if (TAG_TYPE[tag]) return TAG_TYPE[tag];
1231
+ const role = (attrs["role"] ?? "").toLowerCase();
1232
+ if (role && INTERACTIVE_ROLE_TYPE[role]) return INTERACTIVE_ROLE_TYPE[role];
1233
+ const hasClick = CLICK_HANDLER_ATTRS.some((a) => a in attrs);
1234
+ const hasTab = "tabindex" in attrs;
1235
+ const hasTestId = "data-testid" in attrs || "testid" in attrs;
1236
+ if (hasClick || hasTab || hasTestId || role) return "custom";
1237
+ return null;
1238
+ }
1239
+ function normalizeTestId(attrs) {
1240
+ if (!attrs["data-testid"] && attrs["testid"]) {
1241
+ return { ...attrs, "data-testid": attrs["testid"] };
1242
+ }
1243
+ return attrs;
1244
+ }
1245
+ function attrStringValue(valueNode) {
1246
+ if (!valueNode || typeof valueNode !== "object") return void 0;
1247
+ const v = valueNode;
1248
+ if (v["type"] === "Literal") return String(v["value"] ?? "");
1249
+ if (v["type"] === "JSXExpressionContainer") {
1250
+ const expr = v["expression"];
1251
+ if (expr?.["type"] === "Literal") return String(expr["value"] ?? "");
1252
+ }
1253
+ return void 0;
1254
+ }
1255
+ function buildSelector2(tag, attrs) {
1256
+ if (attrs["data-testid"]) return `[data-testid="${attrs["data-testid"]}"]`;
1257
+ if (attrs["aria-label"]) return `${tag}[aria-label="${attrs["aria-label"]}"]`;
1258
+ if (attrs["id"]) return `#${attrs["id"]}`;
1259
+ if (attrs["name"]) return `${tag}[name="${attrs["name"]}"]`;
1260
+ if (attrs["type"] && tag === "input") return `input[type="${attrs["type"]}"]`;
1261
+ return tag;
1262
+ }
1263
+ function buildXPath(tag, attrs) {
1264
+ if (attrs["id"]) return `//${tag}[@id='${attrs["id"]}']`;
1265
+ if (attrs["data-testid"]) return `//${tag}[@data-testid='${attrs["data-testid"]}']`;
1266
+ if (attrs["aria-label"]) return `//${tag}[@aria-label='${attrs["aria-label"]}']`;
1267
+ if (attrs["name"]) return `//${tag}[@name='${attrs["name"]}']`;
1268
+ if (attrs["type"]) return `//${tag}[@type='${attrs["type"]}']`;
1269
+ return `//${tag}`;
1270
+ }
1271
+ function buildElementSelectors(tag, attrs) {
1272
+ return {
1273
+ dataTestId: attrs["data-testid"] ? `[data-testid="${attrs["data-testid"]}"]` : void 0,
1274
+ id: attrs["id"] ? `#${attrs["id"]}` : void 0,
1275
+ ariaLabel: attrs["aria-label"] ? `${tag}[aria-label="${attrs["aria-label"]}"]` : void 0,
1276
+ name: attrs["name"] ? `${tag}[name="${attrs["name"]}"]` : void 0,
1277
+ cssSelector: buildSelector2(tag, attrs),
1278
+ xpath: buildXPath(tag, attrs)
1279
+ };
1280
+ }
1281
+ function buildSelectorRanking(tag, attrs) {
1282
+ const candidates = [];
1283
+ if (attrs["data-testid"]) {
1284
+ candidates.push({ strategy: "data-testid", value: `[data-testid="${attrs["data-testid"]}"]`, score: 100 });
1285
+ }
1286
+ if (attrs["id"]) {
1287
+ candidates.push({ strategy: "id", value: `#${attrs["id"]}`, score: 85 });
1288
+ }
1289
+ if (attrs["aria-label"]) {
1290
+ candidates.push({ strategy: "aria-label", value: `${tag}[aria-label="${attrs["aria-label"]}"]`, score: 75 });
1291
+ }
1292
+ if (attrs["name"]) {
1293
+ candidates.push({ strategy: "name", value: `${tag}[name="${attrs["name"]}"]`, score: 65 });
1294
+ }
1295
+ const cssVal = buildSelector2(tag, attrs);
1296
+ if (!candidates.some((c) => c.value === cssVal)) {
1297
+ const cssScore = attrs["type"] && tag === "input" ? 35 : 10;
1298
+ candidates.push({ strategy: "css", value: cssVal, score: cssScore });
1299
+ }
1300
+ candidates.push({ strategy: "xpath", value: buildXPath(tag, attrs), score: 20 });
1301
+ return candidates.sort((a, b) => b.score - a.score);
1302
+ }
1303
+ function elementTestabilityScore(attrs) {
1304
+ if (attrs["data-testid"]) return 100;
1305
+ if (attrs["id"]) return 85;
1306
+ if (attrs["aria-label"]) return 75;
1307
+ if (attrs["name"]) return 65;
1308
+ if (attrs["type"]) return 35;
1309
+ return 10;
1310
+ }
1311
+ function buildLabel(attrs) {
1312
+ return attrs["aria-label"] ?? attrs["placeholder"] ?? attrs["name"] ?? attrs["id"];
1313
+ }
1314
+ function extractInteractiveElements(ast) {
1315
+ const elements = [];
1316
+ walkAST(ast, (node) => {
1317
+ if (node["type"] !== "JSXOpeningElement") return;
1318
+ const nameNode = node["name"];
1319
+ if (!nameNode || nameNode["type"] !== "JSXIdentifier") return;
1320
+ const tag = String(nameNode["name"] ?? "");
1321
+ if (!TAG_TYPE[tag]) return;
1322
+ const attrs = {};
1323
+ for (const attr of node["attributes"] ?? []) {
1324
+ if (attr["type"] !== "JSXAttribute") continue;
1325
+ const attrName = attr["name"]?.["name"] ?? "";
1326
+ attrs[attrName] = attrStringValue(attr["value"]);
1327
+ }
1328
+ const selector = buildSelector2(tag, attrs);
1329
+ const type = TAG_TYPE[tag];
1330
+ const ranking = buildSelectorRanking(tag, attrs);
1331
+ elements.push({
1332
+ type,
1333
+ selector,
1334
+ label: buildLabel(attrs),
1335
+ actions: ACTIONS[type],
1336
+ selectors: buildElementSelectors(tag, attrs),
1337
+ selectorRanking: ranking,
1338
+ testabilityScore: elementTestabilityScore(attrs),
1339
+ ambiguous: false
1340
+ });
1341
+ });
1342
+ return elements;
1343
+ }
1344
+ function extractInteractiveElementsFromHtml(htmlElements) {
1345
+ const elements = [];
1346
+ for (const raw of htmlElements) {
1347
+ const tag = raw.tag;
1348
+ const attrs = normalizeTestId(raw.attrs);
1349
+ const type = classifyElementType(tag, attrs);
1350
+ if (!type) continue;
1351
+ const selector = buildSelector2(tag, attrs);
1352
+ const ranking = buildSelectorRanking(tag, attrs);
1353
+ elements.push({
1354
+ type,
1355
+ selector,
1356
+ label: buildLabel(attrs),
1357
+ actions: ACTIONS[type],
1358
+ selectors: buildElementSelectors(tag, attrs),
1359
+ selectorRanking: ranking,
1360
+ testabilityScore: elementTestabilityScore(attrs),
1361
+ ambiguous: false
1362
+ });
1363
+ }
1364
+ return elements;
1365
+ }
1366
+ var INTRA_AMBIGUITY_PENALTY = 0.4;
1367
+ var STRONG_STRATEGIES = /* @__PURE__ */ new Set([
1368
+ "data-testid",
1369
+ "id",
1370
+ "aria-label",
1371
+ "name"
1372
+ ]);
1373
+ function countSelectorValues(elements) {
1374
+ const counts = /* @__PURE__ */ new Map();
1375
+ for (const el of elements) {
1376
+ for (const cand of el.selectorRanking) {
1377
+ counts.set(cand.value, (counts.get(cand.value) ?? 0) + 1);
1378
+ }
1379
+ }
1380
+ return counts;
1381
+ }
1382
+ function applyAmbiguity(elements, intraCounts, crossCounts) {
1383
+ for (const el of elements) {
1384
+ for (const cand of el.selectorRanking) {
1385
+ const intra = intraCounts.get(cand.value) ?? 1;
1386
+ const cross = crossCounts.get(cand.value) ?? intra;
1387
+ cand.matchCount = intra;
1388
+ cand.crossMatchCount = cross;
1389
+ if (intra > 1) {
1390
+ cand.ambiguous = true;
1391
+ cand.score = Math.round(cand.score * INTRA_AMBIGUITY_PENALTY);
1392
+ }
1393
+ }
1394
+ el.selectorRanking.sort((a, b) => b.score - a.score);
1395
+ const best = el.selectorRanking[0];
1396
+ if (best) {
1397
+ el.selector = best.value;
1398
+ el.testabilityScore = best.score;
1399
+ el.ambiguous = best.ambiguous === true;
1400
+ if (el.ambiguous && STRONG_STRATEGIES.has(best.strategy)) {
1401
+ el.ambiguityReason = `Selector \`${best.value}\` (${best.strategy}) matches ${best.matchCount} elements in this component \u2014 Playwright will resolve to multiple nodes.`;
1402
+ } else if (el.ambiguous) {
1403
+ el.ambiguityReason = `No unique selector available \u2014 best candidate \`${best.value}\` matches ${best.matchCount} elements in this component.`;
1404
+ }
1405
+ }
1406
+ }
1407
+ }
1408
+ function computeScore(elements) {
1409
+ if (elements.length === 0) return 40;
1410
+ const avg = elements.reduce((s, e) => s + e.testabilityScore, 0) / elements.length;
1411
+ return Math.round(avg);
1412
+ }
1413
+ function classifyComplexity(elements) {
1414
+ if (elements.length <= 2) return "low";
1415
+ if (elements.length <= 6) return "medium";
1416
+ return "high";
1417
+ }
1418
+ async function analyze(components) {
1419
+ const perComponent = components.map((component) => ({
1420
+ component,
1421
+ interactiveElements: component.htmlElements ? extractInteractiveElementsFromHtml(component.htmlElements) : extractInteractiveElements(component.ast)
1422
+ }));
1423
+ const crossCounts = countSelectorValues(perComponent.flatMap((p) => p.interactiveElements));
1424
+ for (const { interactiveElements } of perComponent) {
1425
+ const intraCounts = countSelectorValues(interactiveElements);
1426
+ applyAmbiguity(interactiveElements, intraCounts, crossCounts);
1427
+ }
1428
+ for (const { interactiveElements } of perComponent) {
1429
+ for (const el of interactiveElements) {
1430
+ const best = el.selectorRanking[0];
1431
+ el.tml = assessTagMaturity({
1432
+ testabilityScore: el.testabilityScore,
1433
+ ambiguous: el.ambiguous,
1434
+ bestStrategy: best?.strategy ?? "css",
1435
+ label: el.label,
1436
+ elementType: el.type
1437
+ });
1438
+ }
1439
+ }
1440
+ return perComponent.map(({ component, interactiveElements }) => ({
1441
+ component,
1442
+ score: computeScore(interactiveElements),
1443
+ interactiveElements,
1444
+ complexity: classifyComplexity(interactiveElements)
1445
+ }));
1446
+ }
1447
+ var index_default = analyze;
1448
+ export {
1449
+ LEVEL_HIERARCHY,
1450
+ SEVERITY_ORDER,
1451
+ TML_LABELS,
1452
+ accessibilityRules,
1453
+ allTestIds,
1454
+ analyze,
1455
+ assessTagMaturity,
1456
+ audit,
1457
+ buildDuplicateSet,
1458
+ buildIdePrompt,
1459
+ buildTestabilityReport,
1460
+ buildTmlInventoryEntry,
1461
+ checkNaming,
1462
+ index_default as default,
1463
+ deriveTestIdSuggestion,
1464
+ emptyInventory,
1465
+ enrichTmlWithInventory,
1466
+ enrichTmlWithRuntime,
1467
+ filterRules,
1468
+ getRuleById,
1469
+ getRulesByLevel,
1470
+ getRulesBySeverity,
1471
+ isValidTestId,
1472
+ isValidTmlNaming,
1473
+ loadFindings,
1474
+ loadInventory,
1475
+ loadRuntimeMap,
1476
+ mergeFindings,
1477
+ parseFindings,
1478
+ parseInventory,
1479
+ runAudit,
1480
+ runStaticAnalysis,
1481
+ saveFindings,
1482
+ summarizeReport
1483
+ };