mandrel 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.agents/audit-checklists/accessibility.md +29 -0
  2. package/.agents/audit-checklists/architecture.md +4 -5
  3. package/.agents/audit-checklists/clean-code.md +10 -0
  4. package/.agents/audit-checklists/data-model.md +22 -0
  5. package/.agents/audit-checklists/dependencies.md +11 -2
  6. package/.agents/audit-checklists/devops.md +4 -0
  7. package/.agents/audit-checklists/navigability.md +3 -0
  8. package/.agents/audit-checklists/performance.md +8 -11
  9. package/.agents/audit-checklists/privacy.md +3 -4
  10. package/.agents/audit-checklists/quality.md +2 -0
  11. package/.agents/audit-checklists/security.md +4 -5
  12. package/.agents/audit-checklists/seo.md +7 -1
  13. package/.agents/audit-checklists/sre.md +14 -12
  14. package/.agents/audit-checklists/ux-ui.md +4 -0
  15. package/.agents/docs/configuration.md +3 -0
  16. package/.agents/docs/workflows.md +4 -3
  17. package/.agents/schemas/agentrc.schema.json +17 -0
  18. package/.agents/schemas/audit-rules.json +134 -19
  19. package/.agents/schemas/audit-rules.schema.json +6 -2
  20. package/.agents/scripts/audit-labels-bootstrap.js +4 -4
  21. package/.agents/scripts/audit-to-stories.js +244 -19
  22. package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
  23. package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
  24. package/.agents/scripts/lib/audit-suite/index.js +1 -0
  25. package/.agents/scripts/lib/audit-suite/selector.js +290 -14
  26. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
  27. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
  28. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
  29. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
  30. package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
  31. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
  32. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
  33. package/.agents/scripts/lib/command-header.js +1 -1
  34. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  35. package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
  36. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
  37. package/.agents/scripts/lib/findings/route-finding.js +108 -10
  38. package/.agents/scripts/lib/observability/runtime-friction.js +137 -6
  39. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  40. package/.agents/scripts/lib/orchestration/run-epilogue.js +5 -19
  41. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +11 -0
  42. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
  43. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
  44. package/.agents/scripts/lib/orchestration/story-follow-ups.js +53 -13
  45. package/.agents/scripts/nav-registry-diff.js +449 -0
  46. package/.agents/workflows/audit-accessibility.md +243 -0
  47. package/.agents/workflows/audit-architecture.md +89 -71
  48. package/.agents/workflows/audit-clean-code.md +87 -53
  49. package/.agents/workflows/audit-data-model.md +198 -0
  50. package/.agents/workflows/audit-dependencies.md +143 -28
  51. package/.agents/workflows/audit-devops.md +109 -18
  52. package/.agents/workflows/audit-documentation.md +25 -53
  53. package/.agents/workflows/audit-navigability.md +78 -22
  54. package/.agents/workflows/audit-performance.md +207 -103
  55. package/.agents/workflows/audit-privacy.md +51 -13
  56. package/.agents/workflows/audit-quality.md +71 -61
  57. package/.agents/workflows/audit-security.md +94 -71
  58. package/.agents/workflows/audit-seo.md +80 -25
  59. package/.agents/workflows/audit-sre.md +99 -66
  60. package/.agents/workflows/audit-to-stories.md +44 -5
  61. package/.agents/workflows/audit-ux-ui.md +71 -17
  62. package/.agents/workflows/helpers/audit-dual-path.md +59 -0
  63. package/.agents/workflows/helpers/audit-self-check.md +70 -0
  64. package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
  65. package/.agents/workflows/helpers/deliver-story.md +25 -0
  66. package/docs/CHANGELOG.md +23 -0
  67. package/package.json +1 -1
  68. package/.agents/audit-checklists/lighthouse.md +0 -15
  69. package/.agents/schemas/audit-results.schema.json +0 -69
  70. package/.agents/workflows/audit-lighthouse.md +0 -269
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * .agents/scripts/nav-registry-diff.js — the deterministic route ↔ nav-registry
4
+ * cross-check the navigability lens (`audit-navigability.md`) runs and triages.
5
+ *
6
+ * The navigability lens asserts two symmetric invariants over a consumer's web
7
+ * surface:
8
+ *
9
+ * 1. **Every route has a persona nav door** — a route registered in the route
10
+ * tree that no nav-registry entry surfaces for an entitled persona is an
11
+ * **orphaned route**.
12
+ * 2. **No nav href is dead** — a nav-registry door whose target does not
13
+ * resolve to a real route is a **dead nav href**.
14
+ *
15
+ * Both invariants are a set-difference over two identifier lists, not a
16
+ * judgement call, so they belong in a script rather than in lens prose that
17
+ * asks the agent to eyeball the two files. The lens enumerates the route tree
18
+ * (from `planning.navigation.routeGlobs`) and the nav registry (from
19
+ * `planning.navigation.navRegistry`), hands both to this tool, and triages the
20
+ * structured diff it prints.
21
+ *
22
+ * The one subtlety a naive set-difference gets wrong is **false orphans**: a
23
+ * dynamic detail route (`/users/:id`) is reachable through its surfaced parent,
24
+ * a system route (`/login`, `/404`) is reachable by construction, and a route
25
+ * reached only by an in-app link is not orphaned either. This tool applies that
26
+ * **orphan-verification exemption taxonomy** so the lens reports only genuine
27
+ * orphans (Story #4630, AC-5).
28
+ *
29
+ * Input is two JSON files (route tree + nav registry); a third optional file
30
+ * lists in-app inbound references. Route and door **identifiers only** are read
31
+ * — never route bodies or persona PII (the navigability lens's logging
32
+ * constraint). The tool prints the diff and exits 0 on a successful run; pass
33
+ * `--strict` to exit non-zero when genuine findings remain (a CI gate posture).
34
+ *
35
+ * This is a one-shot deterministic reporter, not an orchestrator: it takes no
36
+ * ticket, mutates no state, and spawns no process.
37
+ */
38
+
39
+ import fs from 'node:fs';
40
+ import { parseArgs } from 'node:util';
41
+ import { runAsCli } from './lib/cli-utils.js';
42
+
43
+ /**
44
+ * Last-segment tokens (or whole-path tokens) that mark a **system route** —
45
+ * reachable by construction (auth walls, error pages) rather than through a
46
+ * persona nav door, so their absence from the nav registry is never an orphan.
47
+ */
48
+ const SYSTEM_ROUTE_TOKENS = Object.freeze([
49
+ 'login',
50
+ 'logout',
51
+ 'signin',
52
+ 'sign-in',
53
+ 'signout',
54
+ 'sign-out',
55
+ 'signup',
56
+ 'sign-up',
57
+ 'register',
58
+ 'auth',
59
+ 'callback',
60
+ 'unauthorized',
61
+ 'forbidden',
62
+ 'not-found',
63
+ 'notfound',
64
+ '404',
65
+ '401',
66
+ '403',
67
+ '500',
68
+ 'error',
69
+ 'maintenance',
70
+ ]);
71
+
72
+ /** The exemption reasons a verified non-orphan can carry, for triage clarity. */
73
+ const EXEMPTION_REASONS = Object.freeze({
74
+ EXPLICIT: 'explicit-exempt',
75
+ SYSTEM: 'system-route',
76
+ DYNAMIC_CHILD: 'dynamic-child-of-surfaced-parent',
77
+ INBOUND: 'inbound-in-app-reference',
78
+ });
79
+
80
+ /**
81
+ * Normalize a route or href path for comparison: coerce to string, trim, force
82
+ * a single leading slash, collapse duplicate slashes, and drop a trailing slash
83
+ * (except for the root `/`). Returns `''` for a nullish or empty input so the
84
+ * caller can reject it.
85
+ *
86
+ * @param {unknown} p
87
+ * @returns {string}
88
+ */
89
+ export function normalizePath(p) {
90
+ if (typeof p !== 'string') return '';
91
+ const trimmed = p.trim();
92
+ if (trimmed.length === 0) return '';
93
+ const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
94
+ const collapsed = withSlash.replace(/\/{2,}/g, '/');
95
+ return collapsed.length > 1 ? collapsed.replace(/\/+$/, '') : collapsed;
96
+ }
97
+
98
+ /**
99
+ * Split a normalized path into its non-empty segments (`'/'` → `[]`).
100
+ *
101
+ * @param {string} normalized
102
+ * @returns {string[]}
103
+ */
104
+ function segmentsOf(normalized) {
105
+ return normalized.split('/').filter(Boolean);
106
+ }
107
+
108
+ /**
109
+ * True when a single path segment is a **dynamic** segment: an Express/React
110
+ * Router `:param`, a Next.js `[param]` / `[...catchAll]`, a bare wildcard `*`,
111
+ * or a `{param}` template.
112
+ *
113
+ * @param {string} segment
114
+ * @returns {boolean}
115
+ */
116
+ export function isDynamicSegment(segment) {
117
+ return (
118
+ segment.startsWith(':') ||
119
+ segment === '*' ||
120
+ (segment.startsWith('[') && segment.endsWith(']')) ||
121
+ (segment.startsWith('{') && segment.endsWith('}'))
122
+ );
123
+ }
124
+
125
+ /** True when the segment is a catch-all (`[...slug]` / `*`) that eats the rest. */
126
+ function isCatchAllSegment(segment) {
127
+ return segment === '*' || segment.startsWith('[...');
128
+ }
129
+
130
+ /**
131
+ * True when a normalized route path contains at least one dynamic segment.
132
+ *
133
+ * @param {string} normalized
134
+ * @returns {boolean}
135
+ */
136
+ export function isDynamicPath(normalized) {
137
+ return segmentsOf(normalized).some(isDynamicSegment);
138
+ }
139
+
140
+ /**
141
+ * True when a normalized route path is a system route (its last segment, or the
142
+ * whole path, is a recognized system token).
143
+ *
144
+ * @param {string} normalized
145
+ * @returns {boolean}
146
+ */
147
+ export function isSystemRoute(normalized) {
148
+ const segs = segmentsOf(normalized);
149
+ if (segs.length === 0) return false;
150
+ const last = segs[segs.length - 1].toLowerCase();
151
+ return SYSTEM_ROUTE_TOKENS.includes(last);
152
+ }
153
+
154
+ /**
155
+ * The parent of a normalized path — the path with its last segment removed
156
+ * (`/users/:id` → `/users`, `/users` → `/`, `/` → `/`).
157
+ *
158
+ * @param {string} normalized
159
+ * @returns {string}
160
+ */
161
+ export function parentPath(normalized) {
162
+ const segs = segmentsOf(normalized);
163
+ if (segs.length <= 1) return '/';
164
+ return `/${segs.slice(0, -1).join('/')}`;
165
+ }
166
+
167
+ /**
168
+ * True when a **route template** (which may contain dynamic segments) matches a
169
+ * concrete **href**. A dynamic segment matches any single href segment; a
170
+ * catch-all matches one-or-more trailing href segments. A template with no
171
+ * dynamic segment matches only an identical href.
172
+ *
173
+ * @param {string} routeNorm normalized route path (the template)
174
+ * @param {string} hrefNorm normalized href (the concrete target)
175
+ * @returns {boolean}
176
+ */
177
+ export function routeTemplateMatchesHref(routeNorm, hrefNorm) {
178
+ const routeSegs = segmentsOf(routeNorm);
179
+ const hrefSegs = segmentsOf(hrefNorm);
180
+ for (let i = 0; i < routeSegs.length; i += 1) {
181
+ const rSeg = routeSegs[i];
182
+ if (isCatchAllSegment(rSeg)) {
183
+ // A catch-all consumes every remaining href segment (>= 1).
184
+ return hrefSegs.length >= i + 1;
185
+ }
186
+ if (i >= hrefSegs.length) return false;
187
+ if (isDynamicSegment(rSeg)) continue; // matches any one segment
188
+ if (rSeg !== hrefSegs[i]) return false;
189
+ }
190
+ return routeSegs.length === hrefSegs.length;
191
+ }
192
+
193
+ /**
194
+ * Coerce a route-tree entry (a bare path string or a `{ path, personas, exempt,
195
+ * kind }` object) into the internal route shape. Throws on an entry with no
196
+ * usable path so a malformed fixture fails loudly rather than silently
197
+ * dropping a route.
198
+ *
199
+ * @param {unknown} entry
200
+ * @returns {{ path: string, personas: string[], exempt: boolean }}
201
+ */
202
+ export function toRoute(entry) {
203
+ const raw = typeof entry === 'string' ? { path: entry } : (entry ?? {});
204
+ const path = normalizePath(raw.path);
205
+ if (path === '') {
206
+ throw new Error(
207
+ `nav-registry-diff: route entry has no usable "path": ${JSON.stringify(entry)}`,
208
+ );
209
+ }
210
+ const personas = Array.isArray(raw.personas)
211
+ ? raw.personas.filter((x) => typeof x === 'string' && x.trim().length > 0)
212
+ : [];
213
+ return { path, personas, exempt: raw.exempt === true };
214
+ }
215
+
216
+ /**
217
+ * Coerce a nav-registry entry (a bare href string or a `{ href, persona }`
218
+ * object) into the internal door shape. Throws on an entry with no usable href.
219
+ *
220
+ * @param {unknown} entry
221
+ * @returns {{ href: string, persona: string|null }}
222
+ */
223
+ export function toDoor(entry) {
224
+ const raw = typeof entry === 'string' ? { href: entry } : (entry ?? {});
225
+ const href = normalizePath(raw.href ?? raw.path);
226
+ if (href === '') {
227
+ throw new Error(
228
+ `nav-registry-diff: nav entry has no usable "href": ${JSON.stringify(entry)}`,
229
+ );
230
+ }
231
+ const persona =
232
+ typeof raw.persona === 'string' && raw.persona.trim().length > 0
233
+ ? raw.persona.trim()
234
+ : null;
235
+ return { href, persona };
236
+ }
237
+
238
+ /**
239
+ * True when a nav door surfaces a route: the door's href resolves to the route
240
+ * (identical path, or the route template matches the concrete href), AND — when
241
+ * both sides name personas — the door renders in a persona entitled to the
242
+ * route. A route with no declared personas is surfaced by any resolving door; a
243
+ * door with no persona surfaces for any entitled persona.
244
+ *
245
+ * @param {{ path: string, personas: string[] }} route
246
+ * @param {{ href: string, persona: string|null }} door
247
+ * @returns {boolean}
248
+ */
249
+ function doorSurfacesRoute(route, door) {
250
+ const resolves =
251
+ route.path === door.href || routeTemplateMatchesHref(route.path, door.href);
252
+ if (!resolves) return false;
253
+ if (route.personas.length === 0 || door.persona === null) return true;
254
+ return route.personas.includes(door.persona);
255
+ }
256
+
257
+ /**
258
+ * Resolve why an unsurfaced route is exempt from the orphan report, or `null`
259
+ * when it is a genuine orphan. The taxonomy (Story #4630, AC-5): an explicitly
260
+ * exempt route, a system route, a dynamic-segment child of a surfaced parent,
261
+ * or a route reached by an in-app inbound reference.
262
+ *
263
+ * @param {{ path: string, exempt: boolean }} route
264
+ * @param {Set<string>} surfacedPaths route paths a door surfaces
265
+ * @param {Set<string>} inboundRefs normalized in-app referenced paths
266
+ * @returns {string|null} an {@link EXEMPTION_REASONS} value, or null
267
+ */
268
+ function orphanExemption(route, surfacedPaths, inboundRefs) {
269
+ if (route.exempt) return EXEMPTION_REASONS.EXPLICIT;
270
+ if (isSystemRoute(route.path)) return EXEMPTION_REASONS.SYSTEM;
271
+ if (isDynamicPath(route.path) && surfacedPaths.has(parentPath(route.path))) {
272
+ return EXEMPTION_REASONS.DYNAMIC_CHILD;
273
+ }
274
+ if (inboundRefs.has(route.path)) return EXEMPTION_REASONS.INBOUND;
275
+ return null;
276
+ }
277
+
278
+ /**
279
+ * Compute the two-way route ↔ nav-registry diff with orphan verification.
280
+ *
281
+ * @param {{
282
+ * routes?: unknown[],
283
+ * nav?: unknown[],
284
+ * refs?: unknown[],
285
+ * }} params
286
+ * @returns {{
287
+ * counts: { routes: number, doors: number },
288
+ * orphanedRoutes: { path: string, personas: string[] }[],
289
+ * deadHrefs: { href: string, persona: string|null }[],
290
+ * exemptRoutes: { path: string, reason: string }[],
291
+ * }}
292
+ */
293
+ export function computeNavDiff({ routes = [], nav = [], refs = [] } = {}) {
294
+ const routeList = routes.map(toRoute);
295
+ const doorList = nav.map(toDoor);
296
+ const inboundRefs = new Set(refs.map(normalizePath).filter((p) => p !== ''));
297
+
298
+ // Which route paths does at least one door surface (persona-aware)?
299
+ const surfacedPaths = new Set();
300
+ for (const route of routeList) {
301
+ if (doorList.some((door) => doorSurfacesRoute(route, door))) {
302
+ surfacedPaths.add(route.path);
303
+ }
304
+ }
305
+
306
+ const orphanedRoutes = [];
307
+ const exemptRoutes = [];
308
+ for (const route of routeList) {
309
+ if (surfacedPaths.has(route.path)) continue;
310
+ const reason = orphanExemption(route, surfacedPaths, inboundRefs);
311
+ if (reason === null) {
312
+ orphanedRoutes.push({ path: route.path, personas: route.personas });
313
+ } else {
314
+ exemptRoutes.push({ path: route.path, reason });
315
+ }
316
+ }
317
+
318
+ // A door is dead when its href resolves to no route (identical or template).
319
+ const deadHrefs = [];
320
+ for (const door of doorList) {
321
+ const resolves = routeList.some(
322
+ (route) =>
323
+ route.path === door.href ||
324
+ routeTemplateMatchesHref(route.path, door.href),
325
+ );
326
+ if (!resolves) deadHrefs.push({ href: door.href, persona: door.persona });
327
+ }
328
+
329
+ return {
330
+ counts: { routes: routeList.length, doors: doorList.length },
331
+ orphanedRoutes,
332
+ deadHrefs,
333
+ exemptRoutes,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Read and parse a JSON array from a file, throwing a clear error when the file
339
+ * is unreadable, not JSON, or not an array.
340
+ *
341
+ * @param {string} label human-readable role for the error message
342
+ * @param {string} file
343
+ * @returns {unknown[]}
344
+ */
345
+ function readJsonArray(label, file) {
346
+ let raw;
347
+ try {
348
+ raw = fs.readFileSync(file, 'utf8');
349
+ } catch (err) {
350
+ throw new Error(
351
+ `nav-registry-diff: cannot read ${label} file '${file}': ${err.message}`,
352
+ );
353
+ }
354
+ let parsed;
355
+ try {
356
+ parsed = JSON.parse(raw);
357
+ } catch (err) {
358
+ throw new Error(
359
+ `nav-registry-diff: ${label} file '${file}' is not valid JSON: ${err.message}`,
360
+ );
361
+ }
362
+ // Accept either a bare array or a `{ routes: [...] }` / `{ nav: [...] }` wrapper.
363
+ const list = Array.isArray(parsed)
364
+ ? parsed
365
+ : (parsed?.routes ?? parsed?.nav ?? parsed?.entries);
366
+ if (!Array.isArray(list)) {
367
+ throw new Error(
368
+ `nav-registry-diff: ${label} file '${file}' must be a JSON array (or an object with a matching array field)`,
369
+ );
370
+ }
371
+ return list;
372
+ }
373
+
374
+ /**
375
+ * Render the diff as a human-readable, triage-friendly text report.
376
+ *
377
+ * @param {ReturnType<typeof computeNavDiff>} diff
378
+ * @returns {string}
379
+ */
380
+ export function formatDiffText(diff) {
381
+ const lines = [
382
+ 'Route ↔ nav-registry diff',
383
+ ` routes: ${diff.counts.routes} nav doors: ${diff.counts.doors}`,
384
+ ` orphaned routes: ${diff.orphanedRoutes.length}`,
385
+ ];
386
+ for (const o of diff.orphanedRoutes) {
387
+ const personas = o.personas.length > 0 ? ` [${o.personas.join(', ')}]` : '';
388
+ lines.push(` - ${o.path}${personas}`);
389
+ }
390
+ lines.push(` dead nav hrefs: ${diff.deadHrefs.length}`);
391
+ for (const d of diff.deadHrefs) {
392
+ const persona = d.persona ? ` [${d.persona}]` : '';
393
+ lines.push(` - ${d.href}${persona}`);
394
+ }
395
+ lines.push(` exempt (verified, not reported): ${diff.exemptRoutes.length}`);
396
+ for (const e of diff.exemptRoutes) {
397
+ lines.push(` - ${e.path} — ${e.reason}`);
398
+ }
399
+ return lines.join('\n');
400
+ }
401
+
402
+ /**
403
+ * @param {string[]} argv
404
+ * @returns {Promise<number>} process exit code
405
+ */
406
+ async function main(argv = process.argv.slice(2)) {
407
+ const { values } = parseArgs({
408
+ args: argv,
409
+ options: {
410
+ routes: { type: 'string' },
411
+ nav: { type: 'string' },
412
+ refs: { type: 'string' },
413
+ json: { type: 'boolean', default: false },
414
+ strict: { type: 'boolean', default: false },
415
+ },
416
+ allowPositionals: false,
417
+ });
418
+
419
+ if (!values.routes || !values.nav) {
420
+ throw new Error(
421
+ 'nav-registry-diff: both --routes <file> and --nav <file> are required.\n' +
422
+ 'Usage: node .agents/scripts/nav-registry-diff.js --routes routes.json --nav nav.json [--refs refs.json] [--json] [--strict]',
423
+ );
424
+ }
425
+
426
+ const routes = readJsonArray('routes', values.routes);
427
+ const nav = readJsonArray('nav', values.nav);
428
+ const refs = values.refs ? readJsonArray('refs', values.refs) : [];
429
+
430
+ const diff = computeNavDiff({ routes, nav, refs });
431
+
432
+ // Written straight to stdout (not the orchestrator Logger) so the output is a
433
+ // clean, machine-parseable report the lens can pipe or `JSON.parse`.
434
+ const rendered = values.json
435
+ ? JSON.stringify(diff, null, 2)
436
+ : formatDiffText(diff);
437
+ process.stdout.write(`${rendered}\n`);
438
+
439
+ const hasFindings =
440
+ diff.orphanedRoutes.length > 0 || diff.deadHrefs.length > 0;
441
+ return values.strict && hasFindings ? 1 : 0;
442
+ }
443
+
444
+ export { main };
445
+
446
+ runAsCli(import.meta.url, main, {
447
+ source: 'nav-registry-diff',
448
+ propagateExitCode: true,
449
+ });
@@ -0,0 +1,243 @@
1
+ ---
2
+ description: Audit WCAG accessibility conformance (static-first) with an optional runtime verification pass, and produce a structured findings report
3
+ ---
4
+
5
+ # Accessibility (WCAG) Audit
6
+
7
+ ## Applicability
8
+
9
+ **Web targets only.** Registered with `target: "web"` in
10
+ [`audit-rules.json`](../schemas/audit-rules.json): the selector skips this lens
11
+ on a project with no rendered frontend, since there are no components, templates,
12
+ or routes to hold to WCAG. See the `target` key's schema description for how
13
+ applicability is probed from the consumer's checkout.
14
+
15
+ ## Role
16
+
17
+ Senior Accessibility Engineer. You hold the frontend to **WCAG 2.x
18
+ conformance** — semantic structure, ARIA correctness, keyboard operability,
19
+ form labelling, media alternatives, and contrast — grounding every finding in a
20
+ concrete element and the success criterion it violates. You default to
21
+ **static** detection over the source, and escalate to a **runtime** pass only
22
+ when a live target is configured.
23
+
24
+ ## Context & Objective
25
+
26
+ This is a **read-only** audit. Detect WCAG violations statically from the
27
+ component/template/route source, optionally corroborate them against a running
28
+ build, and emit a structured Markdown report at
29
+ `{{auditOutputDir}}/audit-accessibility-results.md`. Do not modify application
30
+ code — surfacing the violations (each keyed to a WCAG success criterion) is the
31
+ deliverable; fixing them is a separate pass.
32
+
33
+ > **No conformance certification.** The lens reports findings against WCAG
34
+ > success criteria; it does **not** assert a conformance level (A / AA / AAA)
35
+ > for the product. "No findings in scope" is not "certified conformant".
36
+
37
+ ## Boundary with `audit-ux-ui`
38
+
39
+ These two web lenses share a border and must not double-report:
40
+
41
+ - **`audit-accessibility` (this lens)** owns **WCAG conformance** — the
42
+ standards question: does an assistive-technology user perceive, operate, and
43
+ understand the surface? Semantic HTML, ARIA, keyboard/focus, labelled
44
+ controls, text alternatives, and contrast against the WCAG ratio thresholds.
45
+ - **`audit-ux-ui`** owns **design-system adherence** — the consistency
46
+ question: do components and tokens match the project's own design system
47
+ (hardcoded values that bypass a token, raw elements that should defer to a
48
+ design-system component, interaction/loading/error states, premium feel)?
49
+
50
+ Contrast is the one axis both can touch: **accessibility owns the WCAG ratio
51
+ verdict** (4.5:1 body / 3:1 large text / 3:1 non-text), while ux-ui owns whether
52
+ the colour came from a sanctioned token. When a contrast defect is in scope for
53
+ both, report the WCAG failure here and leave the token-adherence note to ux-ui.
54
+
55
+ ## Scope (Story / plan-run mode)
56
+
57
+ When this lens is invoked from `/deliver` close lenses (or a plan-run audit), the
58
+ following block is populated with the Story (or plan-run) change-set file list.
59
+ Otherwise — for any manual `/audit-<dimension>` invocation — the block
60
+ renders the literal substitution token and you MUST treat it as **no
61
+ scope filter — run the lens codebase-wide** exactly as you would have
62
+ before this section existed.
63
+
64
+ ```text
65
+ {{changedFiles}}
66
+ ```
67
+
68
+ - If the block above contains a newline-delimited list of file paths,
69
+ restrict your analysis to those files (and their direct dependencies
70
+ when the lens explicitly calls for cross-file reasoning).
71
+ - If the block above renders as the literal string `{{changedFiles}}`
72
+ (i.e. no substitution was supplied), ignore this section entirely and
73
+ proceed with the full codebase-wide scan defined in the remaining
74
+ steps.
75
+
76
+ ## Step 0: Discover the frontend surface and config (run first)
77
+
78
+ **You cannot audit WCAG conformance until you have located what renders and how
79
+ the project is configured.** Before any detection:
80
+
81
+ - **Renderable surface:** the component directories (`components/**`,
82
+ `app/**`, `pages/**`, `src/**`), templates (`**/*.html`, `**/*.astro`,
83
+ framework SFCs), and any design-system component library raw elements are
84
+ expected to defer to.
85
+ - **Static a11y tooling already in the repo:** an `eslint-plugin-jsx-a11y`
86
+ config, an `axe-core` / `@axe-core/*` dependency, or a `pa11y` config. Prefer
87
+ reusing the consumer's configured ruleset over inventing one.
88
+ - **Design tokens:** the colour tokens (`tailwind.config.*`, CSS custom
89
+ properties, a theme object) whose literal values you need to compute contrast
90
+ ratios statically.
91
+ - **Runtime target (optional):** the `qa.environments` map (see
92
+ [_Runtime verification mode_](#step-2-runtime-verification-mode-optional-corroboration))
93
+ and the navigability route SSOT.
94
+
95
+ Record what exists. Every finding downstream is measured against _this
96
+ discovered surface and config_, not a generic ideal. If **no** frontend surface
97
+ exists in scope, say so and emit an empty report rather than inventing findings.
98
+
99
+ ## Step 1: Static WCAG detection, then triage
100
+
101
+ > Apply [`helpers/parallel-tooling.md`](helpers/parallel-tooling.md) when batching the scan below — independent reads belong in one turn, long shells run via `run_in_background` + `Monitor`.
102
+
103
+ Run the **mechanical detectors first** (cheap, deterministic greps and the
104
+ static a11y linters discovered in Step 0), then apply **LLM triage** to each
105
+ candidate — a mechanical hit is a _candidate_, not automatically a finding.
106
+ Cover every static WCAG dimension:
107
+
108
+ - **Semantic structure** (WCAG 1.3.1) — landmark regions (`<main>`, `<nav>`,
109
+ `<header>`), a single `<h1>` and a non-skipping heading order, lists for
110
+ list-shaped content, and `<button>`/`<a>` used for their real role rather
111
+ than a clickable `<div>`/`<span>`.
112
+ - **ARIA correctness** (WCAG 4.1.2) — census `role=` / `aria-*` usage: invalid
113
+ roles, `aria-*` attributes unsupported on their element, `aria-labelledby` /
114
+ `aria-describedby` pointing at absent ids, redundant roles on native
115
+ elements, and interactive `role=` on a non-focusable element.
116
+ - **Keyboard operability & focus management** (WCAG 2.1.1 / 2.4.3 / 2.4.7) —
117
+ click handlers on non-interactive elements with no keyboard handler or
118
+ `tabindex`, positive `tabindex` values, `:hover`/`hover:` states with no
119
+ matching `:focus-visible`/`focus-visible:`, focus traps, and `outline: none`
120
+ with no replacement focus indicator.
121
+ - **Forms & labels** (WCAG 1.3.1 / 3.3.2 / 4.1.2) — inputs with no associated
122
+ `<label for>` / wrapping label / `aria-label`, placeholder-as-label,
123
+ unlabelled control groups (`fieldset`/`legend`), and error text not tied to
124
+ its field.
125
+ - **Media alternatives** (WCAG 1.1.1 / 1.2.x) — `<img>` with no `alt`
126
+ (and decorative images missing `alt=""`), `<video>`/`<audio>` with no
127
+ captions/transcript track, `<svg>` conveying meaning with no accessible name,
128
+ and icon-only controls with no accessible name.
129
+ - **Contrast where statically derivable** (WCAG 1.4.3 / 1.4.11) — when both the
130
+ foreground and background resolve to concrete token/literal colour values,
131
+ compute the ratio and flag body text below 4.5:1, large text below 3:1, and
132
+ non-text/UI boundaries below 3:1. When either colour cannot be resolved
133
+ statically (runtime theme, image background), mark it **provisional** and
134
+ defer confirmation to Step 2 rather than guessing.
135
+ - **Raw-element census** — when the project configures a static ruleset,
136
+ reconcile the raw-element findings above against the design-system components
137
+ that already encode the accessible pattern, so the fix lands in one place.
138
+
139
+ > **Detector output is candidates.** Triage each against the Step 0 surface and
140
+ > the specific WCAG success criterion before promoting it to a finding — a role
141
+ > on a design-system primitive that already manages focus, or a token `px` value
142
+ > inside a token file, is expected, not a defect.
143
+
144
+ ## Step 2: Runtime verification mode (optional corroboration)
145
+
146
+ Static detection is the default and always runs. The runtime pass is
147
+ **conditional** — it runs only when a live target is configured; its absence
148
+ never blocks the static report.
149
+
150
+ 1. **Resolve the target from config — never a hardcoded URL.** Resolve the
151
+ target through the consumer's `qa.environments.<env>.baseUrl` (via
152
+ [`resolveQaEnvironment`](../scripts/lib/qa/resolve-qa-contract.js), the same
153
+ resolver `/qa-run` uses): an `<env>` argument resolves by exact name or
154
+ origin match; with no argument, enumerate `name → baseUrl` and let the
155
+ operator pick. If **no** `qa.environments` target is configured, **skip this
156
+ step** and note in the report that runtime corroboration was unavailable —
157
+ do not invent a URL and do not start an arbitrary dev server.
158
+ 2. **Sample routes from the navigability SSOT.** Draw the routes to exercise
159
+ from the consumer's route/nav registry (`planning.navigation.navRegistry` /
160
+ `routeGlobs` — the same SSOT [`/audit-navigability`](audit-navigability.md)
161
+ reads), sampling a representative set (key personas' landing routes plus any
162
+ route in the change-set scope) rather than a single hardcoded page.
163
+ 3. **Run an accessibility engine per sampled route.** Use the
164
+ `mcp__chrome-devtools__lighthouse_audit` tool's **Accessibility category**,
165
+ or run **axe** via the browser tooling, against each sampled `baseUrl`-rooted
166
+ route. Prefer a production-mode build.
167
+ 4. **Median-of-3 or provisional.** Any runtime score or metric is subject to
168
+ run-to-run variance: capture a **median-of-3** (three runs per route, report
169
+ the median) before treating a number as authoritative. A single-run value is
170
+ reported **provisional** and never drives a Critical/High verdict on its own.
171
+
172
+ Corroborate static findings against the runtime results (a statically-flagged
173
+ contrast defect confirmed by the engine graduates from provisional to
174
+ confirmed), and surface runtime-only violations the static pass could not see.
175
+
176
+ ## Step 3: Output Requirements
177
+
178
+ Generate and save a structured Markdown report to
179
+ `{{auditOutputDir}}/audit-accessibility-results.md`, using the exact template
180
+ below. The report MUST include all sections, even if empty (write
181
+ "_No findings._" rather than omitting a section).
182
+
183
+ > Grade every finding's severity on the shared
184
+ > [`Critical | High | Medium | Low` scale](helpers/audit-severity-scale.md), and
185
+ > key every finding to the WCAG success criterion it violates.
186
+
187
+ ```markdown
188
+ # Accessibility (WCAG) Audit report
189
+
190
+ ## Executive Summary
191
+
192
+ [Overview of WCAG conformance health across the scope, the runtime mode's
193
+ status (ran against `<env>` / skipped — no target configured), and the
194
+ self-cross-check line.]
195
+
196
+ ## Detailed Findings
197
+
198
+ [For every WCAG violation identified, use the following strict structure. Lead
199
+ each title with the primary file the finding lives in:]
200
+
201
+ ### `path/to/primary-file.ext` — [Short title of the issue]
202
+
203
+ - **Dimension:** [e.g., Semantic Structure | ARIA | Keyboard & Focus | Forms & Labels | Media Alternatives | Contrast]
204
+ - **Severity:** [Critical | High | Medium | Low]
205
+ - **WCAG:** [success criterion — e.g. `1.1.1 Non-text Content (A)`]
206
+ - **Location:** `path/to/primary-file.ext:line`
207
+ - **Evidence:** [measured | static] [the observable — a quoted element, the
208
+ computed contrast ratio, the failing axe/Lighthouse audit id + median score.
209
+ Runtime numbers from a single run are tagged `provisional`.]
210
+ - **Current State:** [what is implemented and why it fails the criterion]
211
+ - **Recommendation & Rationale:** [the specific change — attribute to add,
212
+ element to swap, token to adjust — and the assistive-technology behaviour it
213
+ restores]
214
+ - **Acceptance signal:** [the command or observable that proves this finding is
215
+ remediated — e.g. the axe rule now passing on the route, or a re-run of this
216
+ lens]
217
+ - **Agent Prompt:**
218
+ `[A copy-pasteable, highly specific prompt to execute this a11y fix independently]`
219
+
220
+ ## Runtime Verification
221
+
222
+ [Per-route median-of-3 accessibility scores when the runtime mode ran, or
223
+ "_Runtime corroboration unavailable — no `qa.environments` target configured._"]
224
+ ```
225
+
226
+ ## Constraint
227
+
228
+ This is a **read-only** audit. Provide the critique and implementation
229
+ suggestions, but do not modify components, styles, or configuration. The
230
+ runtime mode runs **non-mutating** measurements only and starts no arbitrary
231
+ dev server.
232
+
233
+ ## Self-cross-check (mandatory — filter false positives before you finalize)
234
+
235
+ Before you write the report artifact from the previous step, run the shared
236
+ adversarial self-cross-check over your Detailed Findings — see
237
+ [`helpers/audit-self-check.md`](helpers/audit-self-check.md). It defines the
238
+ per-finding evidence bar, the exclusion list, and the final re-open-and-drop
239
+ pass whose `kept <k> / dropped <d>` counts you record in the Executive
240
+ Summary, so the sequential single-pass path filters unverified findings just as
241
+ the orchestrated path's adversarial reviewer does. Drop every claimed
242
+ violation that names no concrete element and no specific WCAG success
243
+ criterion.