mandrel 2.4.0 → 2.5.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/.agents/audit-checklists/accessibility.md +29 -0
- package/.agents/audit-checklists/architecture.md +4 -5
- package/.agents/audit-checklists/clean-code.md +10 -0
- package/.agents/audit-checklists/data-model.md +22 -0
- package/.agents/audit-checklists/dependencies.md +11 -2
- package/.agents/audit-checklists/devops.md +4 -0
- package/.agents/audit-checklists/navigability.md +3 -0
- package/.agents/audit-checklists/performance.md +8 -11
- package/.agents/audit-checklists/privacy.md +3 -4
- package/.agents/audit-checklists/quality.md +2 -0
- package/.agents/audit-checklists/security.md +4 -5
- package/.agents/audit-checklists/seo.md +7 -1
- package/.agents/audit-checklists/sre.md +14 -12
- package/.agents/audit-checklists/ux-ui.md +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/docs/workflows.md +4 -3
- package/.agents/schemas/agentrc.schema.json +17 -0
- package/.agents/schemas/audit-rules.json +134 -19
- package/.agents/schemas/audit-rules.schema.json +6 -2
- package/.agents/scripts/audit-labels-bootstrap.js +4 -4
- package/.agents/scripts/audit-to-stories.js +244 -19
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
- package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
- package/.agents/scripts/lib/audit-suite/index.js +1 -0
- package/.agents/scripts/lib/audit-suite/selector.js +290 -14
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
- package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
- package/.agents/scripts/lib/command-header.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
- package/.agents/scripts/lib/findings/route-finding.js +108 -10
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
- package/.agents/scripts/nav-registry-diff.js +449 -0
- package/.agents/workflows/audit-accessibility.md +243 -0
- package/.agents/workflows/audit-architecture.md +89 -71
- package/.agents/workflows/audit-clean-code.md +87 -53
- package/.agents/workflows/audit-data-model.md +198 -0
- package/.agents/workflows/audit-dependencies.md +143 -28
- package/.agents/workflows/audit-devops.md +109 -18
- package/.agents/workflows/audit-documentation.md +25 -53
- package/.agents/workflows/audit-navigability.md +78 -22
- package/.agents/workflows/audit-performance.md +207 -103
- package/.agents/workflows/audit-privacy.md +51 -13
- package/.agents/workflows/audit-quality.md +71 -61
- package/.agents/workflows/audit-security.md +94 -71
- package/.agents/workflows/audit-seo.md +80 -25
- package/.agents/workflows/audit-sre.md +99 -66
- package/.agents/workflows/audit-to-stories.md +44 -5
- package/.agents/workflows/audit-ux-ui.md +71 -17
- package/.agents/workflows/helpers/audit-dual-path.md +59 -0
- package/.agents/workflows/helpers/audit-self-check.md +70 -0
- package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
- package/.agents/workflows/helpers/deliver-story.md +25 -0
- package/docs/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/.agents/audit-checklists/lighthouse.md +0 -15
- package/.agents/schemas/audit-results.schema.json +0 -69
- 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.
|