mandrel 2.3.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/observability/runtime-friction.js +62 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +9 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +49 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +73 -15
- 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/lib/orchestration/ticket-lease.js +78 -10
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +68 -16
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +73 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +93 -12
- package/.agents/scripts/nav-registry-diff.js +449 -0
- package/.agents/scripts/single-story-init.js +189 -51
- package/.agents/scripts/stories-wave-tick.js +39 -2
- 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/deliver.md +32 -8
- 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 +29 -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
|
+
});
|