fimo 2.6.0-staging.1 → 2.6.0-staging.3

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.
@@ -22,7 +22,8 @@ do not poll indefinitely.
22
22
  Start only after this batch's real records read back as non-empty. Own exactly
23
23
  the consumer files assigned by the planner.
24
24
 
25
- 1. Load the active framework's `fimo-code` reference.
25
+ 1. Load the active framework's `fimo-code` reference. Before changing a media
26
+ read or image component, load `fimo skills get fimo-migration --ref media`.
26
27
  2. Establish the generated Fimo client/adapter needed by the batch.
27
28
  3. Replace every planned `sourceFile`/`sourceKey` at its existing consumer in
28
29
  place. Appending a disconnected duplicate section is not wiring.
@@ -1,11 +1,13 @@
1
1
  # Migration validator
2
2
 
3
3
  Validation is one independent read-only wave after all writers return. The
4
- coordinator gives every validator the accepted migration scope and deliberate
5
- user-approved differences, but no producer findings, repair reasoning,
6
- transcript, or expected verdict. A validator checks the finished target against
7
- the source within that contract. It does not report an explicitly excluded
8
- route, integration, or control as migration drift.
4
+ coordinator gives every validator the accepted migration scope, deliberate
5
+ user-approved differences, and the guardrail inventory path
6
+ (`references/qa.md`, Rendered guardrails), but no producer findings, repair
7
+ reasoning, transcript, or expected verdict. A validator may add a rule the
8
+ source proves; it never removes or narrows one. A validator checks the
9
+ finished target against the source within that contract. It does not report an
10
+ explicitly excluded route, integration, or control as migration drift.
9
11
 
10
12
  When subagents are available, launch separate workers for build and runtime,
11
13
  Fimo data and content, and rendered browser behavior. Run them concurrently
@@ -13,11 +15,18 @@ after their prerequisites pass. They may inspect overlapping files because none
13
15
  of them writes.
14
16
 
15
17
  Compare the source, content plan, actual `git diff`, real Fimo state, and local
16
- application behavior. A worker report is a claim, not evidence.
18
+ application behavior. A worker report, manifest, or coverage table is a
19
+ diagnostic, not acceptance evidence. A check that observed nothing - zero
20
+ rendered routes, zero collected links, zero matched rules - is failed, not
21
+ passed.
17
22
 
18
23
  Verify:
19
24
 
20
25
  - every source route renders with expected status and representative content;
26
+ - the rendered guardrail inventory passes: brand, legal, compliance, and
27
+ navigation rules are proven on rendered HTML or a running local server with
28
+ `scripts/check-rendered-guardrails.mjs`, never by grepping target source.
29
+ Browser captures include the response metadata required by `references/qa.md`;
21
30
  - schemas, entries, singles, labels, media, forms, and locales exist through
22
31
  CLI read-back;
23
32
  - every planned source item is consumed at its original behavior path, not in
@@ -43,6 +52,13 @@ duration, checks run, counts, failing source items, blocked descendants, and
43
52
  suggested repair ownership. Stop only when a failed prerequisite makes a check
44
53
  meaningless.
45
54
 
55
+ End every validator report with an `Acceptance` section: the exact commands
56
+ another party can rerun (build, local start, the guardrail check with its
57
+ inventory and observation source, CLI read-back) and the expected evidence for
58
+ each - exit status, the unmodified `## Guardrails` block ending in
59
+ `Result: passed`, and the counts observed. A pass reported without its command
60
+ and output is a claim.
61
+
46
62
  The coordinator fans every lane into one finding set before editing. It assigns
47
63
  disjoint repair owners, waits for the whole repair wave, then reruns one central
48
64
  validation wave. It does not restart every worker, repair findings one at a
@@ -76,9 +76,11 @@ other after it. Do not add a metadata format for a small overlap.
76
76
  framework gate.
77
77
  6. Start each code worker only after its own imported records read back as
78
78
  non-empty. A build over empty data proves nothing.
79
- 7. After every writer returns, start one read-only validation wave. When
80
- subagents are available, run build and runtime, Fimo data and content, and
81
- rendered browser checks concurrently after their prerequisites pass.
79
+ 7. Before the validation wave, write the cited rendered-guardrail inventory
80
+ required by `references/qa.md`. After every writer returns, start one
81
+ read-only validation wave. When subagents are available, run build and
82
+ runtime, Fimo data and content, and rendered browser checks concurrently
83
+ after their prerequisites pass.
82
84
 
83
85
  Each prompt names the worker's source paths, exact target ownership, applicable
84
86
  reference above, and dependencies. Each report states status, duration, source
@@ -0,0 +1,500 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // Evaluates the migration guardrail inventory against the final rendered
4
+ // application. Inputs are rendered HTML observations (browser/HTTP captures or
5
+ // a running local server), never the target's source files: once copy moves
6
+ // into Fimo, a source grep proves nothing about what visitors see.
7
+ //
8
+ // The check fails closed. An empty inventory, a route with no rendered text,
9
+ // or a rule that ends up evaluated against zero observations is a failure, not
10
+ // a pass.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import process from 'node:process';
14
+
15
+ /**
16
+ * Shipped as plain JavaScript so a migration target needs only Node.
17
+ * Checked with its subprocess tests by tsconfig.guardrails.json.
18
+ * @typedef {{projectRoot?: string, inventory?: string, captures?: string, baseUrl?: string, output?: string}} Args
19
+ * @typedef {{id: string, kind: string, source?: string, text?: string | string[], href?: string, routes?: string[], allow?: string[]}} Rule
20
+ * @typedef {{routes: string[], rules: Rule[]}} Inventory
21
+ * @typedef {{route: string, source: string, url: string, baseUrl: string, html: string | null, text: string, links: string[], passed: boolean, reason: string}} Observation
22
+ * @typedef {{id: string, kind: string, passed: boolean, observed: number, failures: string[], extra: string | null}} Result
23
+ */
24
+
25
+ const USAGE =
26
+ 'Usage: node check-rendered-guardrails.mjs [project-root] [--inventory .fimo/migration/guardrails.json] ' +
27
+ '(--captures <dir> | --base-url <http://localhost:port>) [--output .fimo/migration/guardrails-report.md]';
28
+
29
+ const RULE_KINDS = ['required-text', 'forbidden-text', 'required-link', 'links-resolve'];
30
+
31
+ async function main() {
32
+ const args = parseArgs(process.argv.slice(2));
33
+ const projectRoot = path.resolve(args.projectRoot ?? process.cwd());
34
+ const inventoryPath = path.resolve(projectRoot, args.inventory ?? '.fimo/migration/guardrails.json');
35
+ const inventory = loadInventory(inventoryPath);
36
+
37
+ if (!args.captures && !args.baseUrl) fail(`Provide --captures <dir> or --base-url <url>.\n${USAGE}`);
38
+ if (args.captures && args.baseUrl) fail('Provide either --captures or --base-url, not both.');
39
+
40
+ const observations = args.baseUrl
41
+ ? await fetchObservations(inventory.routes, args.baseUrl)
42
+ : readCaptures(inventory.routes, path.resolve(projectRoot, args.captures ?? ''));
43
+ const results = evaluateRules(inventory, observations);
44
+ const passed = observations.every((item) => item.passed) && results.every((item) => item.passed);
45
+
46
+ const report = buildReport({
47
+ inventoryPath: toPosix(path.relative(projectRoot, inventoryPath)),
48
+ inventory,
49
+ source: args.baseUrl
50
+ ? `fetched from ${args.baseUrl}`
51
+ : `captures in ${toPosix(path.relative(projectRoot, path.resolve(projectRoot, args.captures ?? '')))}`,
52
+ observations,
53
+ results,
54
+ passed,
55
+ });
56
+
57
+ if (args.output) {
58
+ const outputPath = path.resolve(projectRoot, args.output);
59
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
60
+ fs.writeFileSync(outputPath, report, 'utf8');
61
+ }
62
+ console.log(report);
63
+ if (!passed) process.exitCode = 1;
64
+ }
65
+
66
+ /** @param {string[]} argv */
67
+ function parseArgs(argv) {
68
+ /** @type {Args} */
69
+ const args = {};
70
+ /** @type {Record<string, keyof Args>} */
71
+ const valueFlags = {
72
+ '--inventory': 'inventory',
73
+ '--captures': 'captures',
74
+ '--base-url': 'baseUrl',
75
+ '--output': 'output',
76
+ };
77
+ for (let i = 0; i < argv.length; i += 1) {
78
+ const arg = argv[i];
79
+ if (arg === '--help' || arg === '-h') {
80
+ console.log(USAGE);
81
+ process.exit(0);
82
+ }
83
+ const [flag, inlineValue] = arg.includes('=')
84
+ ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]
85
+ : [arg, undefined];
86
+ if (valueFlags[flag]) {
87
+ const value = inlineValue ?? argv[++i];
88
+ if (!value || value.startsWith('--')) fail(`Missing value for ${flag}.\n${USAGE}`);
89
+ args[valueFlags[flag]] = value;
90
+ continue;
91
+ }
92
+ if (!arg.startsWith('--') && !args.projectRoot) {
93
+ args.projectRoot = arg;
94
+ continue;
95
+ }
96
+ fail(`Unknown argument: ${arg}\n${USAGE}`);
97
+ }
98
+ return args;
99
+ }
100
+
101
+ // The inventory is the explicit acceptance contract. It must name the route
102
+ // surface and at least one rule; the script never infers either from the
103
+ // target, so a migration cannot pass by declaring nothing.
104
+ /** @param {string} inventoryPath @returns {Inventory} */
105
+ function loadInventory(inventoryPath) {
106
+ if (!fs.existsSync(inventoryPath)) fail(`Guardrail inventory not found: ${inventoryPath}`);
107
+ /** @type {unknown} */
108
+ let raw;
109
+ try {
110
+ raw = JSON.parse(fs.readFileSync(inventoryPath, 'utf8'));
111
+ } catch (error) {
112
+ fail(`Could not parse ${inventoryPath}: ${error instanceof Error ? error.message : String(error)}`);
113
+ }
114
+ if (!isRecord(raw) || !Array.isArray(raw.routes) || !Array.isArray(raw.rules)) {
115
+ fail('Invalid guardrail inventory: expected an object with routes and rules arrays.');
116
+ }
117
+ if (!raw.routes.every(isNonEmptyString)) fail('Invalid guardrail inventory: routes must be non-empty strings.');
118
+ // Keep trailing slashes for document-relative URL resolution during fetches.
119
+ const routes = uniqueArray(
120
+ raw.routes.map(
121
+ (route) => normalizePath(route) + (route.trim().endsWith('/') && normalizePath(route) !== '/' ? '/' : '')
122
+ )
123
+ );
124
+ const rules = raw.rules;
125
+ if (routes.length === 0)
126
+ fail(
127
+ `Empty guardrail inventory: ${inventoryPath} lists no routes. Inventory every rendered route the migration must prove.`
128
+ );
129
+ if (rules.length === 0)
130
+ fail(
131
+ `Empty guardrail inventory: ${inventoryPath} lists no rules. Add the brand, legal/compliance, and navigation rules the source imposes.`
132
+ );
133
+ rules.forEach((rule, index) => {
134
+ if (!isRecord(rule)) fail(`Rule #${index + 1} must be an object.`);
135
+ if (!isNonEmptyString(rule.id)) fail(`Rule #${index + 1} has no id.`);
136
+ if (typeof rule.kind !== 'string' || !RULE_KINDS.includes(rule.kind))
137
+ fail(`Rule ${rule.id} has unknown kind "${rule.kind}". Use one of: ${RULE_KINDS.join(', ')}.`);
138
+ if (
139
+ ['required-text', 'forbidden-text'].includes(String(rule.kind)) &&
140
+ (asArray(rule.text).length === 0 || !asArray(rule.text).every(isNonEmptyString))
141
+ ) {
142
+ fail(`Rule ${rule.id} (${rule.kind}) needs a non-empty "text".`);
143
+ }
144
+ if (rule.kind === 'required-link' && !isNonEmptyString(rule.href))
145
+ fail(`Rule ${rule.id} (required-link) needs an "href".`);
146
+ if (rule.kind === 'required-link' && /^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(String(rule.href))) {
147
+ fail(`Rule ${rule.id} (required-link) supports paths and http(s) URLs; prove ${rule.href} in the browser pass.`);
148
+ }
149
+ for (const field of ['routes', 'allow']) {
150
+ if (rule[field] !== undefined && (!Array.isArray(rule[field]) || !rule[field].every(isNonEmptyString))) {
151
+ fail(`Rule ${rule.id} (${field}) needs an array of non-empty strings.`);
152
+ }
153
+ }
154
+ if (!isNonEmptyString(rule.source))
155
+ fail(`Rule ${rule.id} needs "source": cite the original page, file, or owner decision that imposes it.`);
156
+ const unknownScope = asArray(rule.routes)
157
+ .filter(isNonEmptyString)
158
+ .map(normalizePath)
159
+ .filter((route) => !routes.some((item) => normalizePath(item) === route));
160
+ if (unknownScope.length > 0) {
161
+ fail(
162
+ `Rule ${rule.id} is scoped to routes missing from the inventory: ${unknownScope.join(', ')}. A rule cannot be proven on a route that is never rendered.`
163
+ );
164
+ }
165
+ });
166
+ return { routes, rules: /** @type {Rule[]} */ (rules) };
167
+ }
168
+
169
+ /** @param {string[]} routes @param {string} capturesDir */
170
+ function readCaptures(routes, capturesDir) {
171
+ return routes.map((route) => {
172
+ const name = captureFileName(route);
173
+ const file = path.join(capturesDir, name);
174
+ if (!fs.existsSync(file)) return observation(route, `capture missing: ${name}`, null);
175
+ // Status must come from the navigation response, never from HTML content.
176
+ try {
177
+ /** @type {unknown} */
178
+ const metadata = JSON.parse(fs.readFileSync(`${file}.json`, 'utf8'));
179
+ if (!isRecord(metadata) || metadata.status !== 200 || metadata.error != null) {
180
+ return observation(route, `capture rejected: ${name}.json must record status 200 and no error`, null);
181
+ }
182
+ if (!isNonEmptyString(metadata.url) || !/^https?:\/\//i.test(metadata.url)) {
183
+ return observation(route, `capture rejected: ${name}.json needs the final HTTP(S) document URL`, null);
184
+ }
185
+ const finalUrl = new URL(metadata.url).href;
186
+ const finalPath = normalizePath(new URL(finalUrl).pathname);
187
+ if (finalPath !== normalizePath(route)) {
188
+ return observation(
189
+ route,
190
+ `capture rejected: ${name}.json records ${finalPath}, not ${normalizePath(route)}`,
191
+ null
192
+ );
193
+ }
194
+ return observation(route, `capture ${name}`, fs.readFileSync(file, 'utf8'), finalUrl);
195
+ } catch (error) {
196
+ return observation(
197
+ route,
198
+ `capture evidence unreadable: ${name}.json: ${error instanceof Error ? error.message : String(error)}`,
199
+ null
200
+ );
201
+ }
202
+ });
203
+ }
204
+
205
+ // `/` -> index.html, `/legal/privacy` -> legal/privacy.html. The validator saves
206
+ // the hydrated document under these names when it captures with a browser.
207
+ /** @param {string} route */
208
+ function captureFileName(route) {
209
+ route = normalizePath(route);
210
+ return route === '/' ? 'index.html' : `${route.replace(/^\//, '')}.html`;
211
+ }
212
+
213
+ /** @param {string[]} routes @param {string} baseUrl */
214
+ async function fetchObservations(routes, baseUrl) {
215
+ const base = baseUrl.replace(/\/$/, '');
216
+ return Promise.all(
217
+ routes.map(async (route) => {
218
+ try {
219
+ const response = await fetch(`${base}${route}`, { redirect: 'follow' });
220
+ const body = await response.text();
221
+ if (response.status !== 200) {
222
+ return observation(route, `HTTP ${response.status} from ${base}${route}`, null);
223
+ }
224
+ const finalPath = normalizePath(new URL(response.url).pathname);
225
+ if (finalPath !== normalizePath(route)) {
226
+ return observation(
227
+ route,
228
+ `redirected to ${finalPath}: the route surface is not preserved`,
229
+ null,
230
+ response.url
231
+ );
232
+ }
233
+ return observation(route, `HTTP 200 from ${base}${route}`, body, response.url);
234
+ } catch (error) {
235
+ return observation(route, `request failed: ${error instanceof Error ? error.message : String(error)}`, null);
236
+ }
237
+ })
238
+ );
239
+ }
240
+
241
+ /** @param {string} route @param {string} source @param {string | null} html @param {string} [url] @returns {Observation} */
242
+ function observation(route, source, html, url = '') {
243
+ const context = { route, source, url, baseUrl: url };
244
+ if (html === null) {
245
+ return { ...context, html: null, text: '', links: [], passed: false, reason: source };
246
+ }
247
+ const text = visibleText(html);
248
+ const links = extractLinks(html);
249
+ const baseHref = html
250
+ .replace(/<!--[^]*?-->/g, '')
251
+ .replace(/<(script|style|template)\b[^>]*>[^]*?<\/\1\s*>/gi, ' ')
252
+ .match(/<base\b[^>]*\s+href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/i);
253
+ if (baseHref) context.baseUrl = new URL(decodeEntities(baseHref[1] ?? baseHref[2] ?? baseHref[3]), url).href;
254
+ if (text.length === 0) {
255
+ return {
256
+ ...context,
257
+ html,
258
+ text,
259
+ links,
260
+ passed: false,
261
+ reason:
262
+ 'rendered HTML has no visible text (an empty client-rendered root is not a page; capture after hydration)',
263
+ };
264
+ }
265
+ return {
266
+ ...context,
267
+ html,
268
+ text,
269
+ links,
270
+ passed: true,
271
+ reason: `${text.length} visible characters, ${links.length} links`,
272
+ };
273
+ }
274
+
275
+ // Visible text is body text plus the attributes a visitor or crawler reads:
276
+ // alt, title, aria-label, placeholder, and meta content. Scripts, styles, and
277
+ // comments are excluded on purpose: a brand string inside a bundle or JSON
278
+ // payload is source presence, not a rendered observation.
279
+ /** @param {string} html */
280
+ function visibleText(html) {
281
+ const stripped = html
282
+ .replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
283
+ .replace(/<!--[\s\S]*?-->/g, ' ');
284
+ const attributeText = [
285
+ ...stripped.matchAll(/\b(?:alt|title|aria-label|placeholder|content)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi),
286
+ ].map((match) => match[1] ?? match[2] ?? '');
287
+ const bodyText = stripped.replace(/<[^>]+>/g, ' ');
288
+ return decodeEntities([bodyText, ...attributeText].join(' '))
289
+ .replace(/\s+/g, ' ')
290
+ .trim();
291
+ }
292
+
293
+ /** @param {string} html */
294
+ function extractLinks(html) {
295
+ const withoutHidden = html
296
+ .replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
297
+ .replace(/<!--[\s\S]*?-->/g, ' ');
298
+ return [...withoutHidden.matchAll(/<a\b[^>]*\s+href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi)].map((match) =>
299
+ decodeEntities(match[1] ?? match[2] ?? match[3] ?? '').trim()
300
+ );
301
+ }
302
+
303
+ /** @param {Inventory} inventory @param {Observation[]} observations */
304
+ function evaluateRules(inventory, observations) {
305
+ const rendered = observations.filter((item) => item.passed);
306
+ return inventory.rules.map((rule) => {
307
+ const scope = rule.routes?.length ? rule.routes.map(normalizePath) : inventory.routes.map(normalizePath);
308
+ const observed = rendered.filter((item) => scope.includes(normalizePath(item.route)));
309
+ if (observed.length === 0) {
310
+ return result(rule, false, 0, [`vacuous: evaluated against 0 rendered routes (scope: ${scope.join(', ')})`]);
311
+ }
312
+ switch (rule.kind) {
313
+ case 'required-text':
314
+ return textRule(rule, observed, (haystack, phrase) => !haystack.includes(phrase), 'missing');
315
+ case 'forbidden-text':
316
+ return textRule(rule, observed, (haystack, phrase) => haystack.includes(phrase), 'found');
317
+ case 'required-link':
318
+ return linkRule(rule, observed);
319
+ case 'links-resolve':
320
+ return linksResolveRule(rule, inventory, observed);
321
+ default:
322
+ return result(rule, false, observed.length, [`unknown rule kind ${rule.kind}`]);
323
+ }
324
+ });
325
+ }
326
+
327
+ /** @param {Rule} rule @param {Observation[]} observed @param {(haystack: string, phrase: string) => boolean} isFailure @param {string} verb */
328
+ function textRule(rule, observed, isFailure, verb) {
329
+ const failures = [];
330
+ for (const item of observed) {
331
+ const haystack = item.text.toLowerCase();
332
+ for (const phrase of phrases(rule)) {
333
+ const needle = phrase.toLowerCase();
334
+ if (isFailure(haystack, needle)) {
335
+ failures.push(
336
+ `"${phrase}" ${verb} on ${item.route}${verb === 'found' ? `: ${snippet(item.text, phrase)}` : ''}`
337
+ );
338
+ }
339
+ }
340
+ }
341
+ return result(rule, failures.length === 0, observed.length, failures);
342
+ }
343
+
344
+ /** @param {Rule} rule @param {Observation[]} observed */
345
+ function linkRule(rule, observed) {
346
+ const failures = observed
347
+ .filter((item) => {
348
+ const target = linkTarget(rule.href ?? '', item, false);
349
+ return !target || !item.links.some((href) => linkTarget(href, item, false) === target);
350
+ })
351
+ .map((item) => `no <a href="${rule.href}"> rendered on ${item.route} (${item.links.length} links collected)`);
352
+ return result(rule, failures.length === 0, observed.length, failures);
353
+ }
354
+
355
+ // Every internal link rendered on the observed routes must point at a route
356
+ // the inventory proves, or at an explicitly allowed path prefix. Collecting
357
+ // zero links is a failure: a navigation check over nothing checks nothing.
358
+ /** @param {Rule} rule @param {Inventory} inventory @param {Observation[]} observed */
359
+ function linksResolveRule(rule, inventory, observed) {
360
+ const allow = asArray(rule.allow).filter(isNonEmptyString).map(normalizePath);
361
+ const internal = [];
362
+ for (const item of observed) {
363
+ for (const href of item.links) {
364
+ const target = linkTarget(href, item);
365
+ if (target !== null) internal.push({ route: item.route, href, target });
366
+ }
367
+ }
368
+ if (internal.length === 0) {
369
+ return result(rule, false, observed.length, [
370
+ `vacuous: 0 internal links collected across ${observed.length} rendered routes`,
371
+ ]);
372
+ }
373
+ const failures = uniqueArray(
374
+ internal
375
+ .filter(
376
+ ({ target }) =>
377
+ !inventory.routes.some((route) => normalizePath(route) === target) &&
378
+ !allow.some((prefix) => target === prefix || target.startsWith(`${prefix}/`))
379
+ )
380
+ .map(({ route, href }) => `${href} on ${route} resolves to no inventoried route`)
381
+ );
382
+ return result(rule, failures.length === 0, observed.length, failures, `${internal.length} internal links`);
383
+ }
384
+
385
+ /** @param {Rule} rule @param {boolean} passed @param {number} observed @param {string[]} failures @param {string} [extra] @returns {Result} */
386
+ function result(rule, passed, observed, failures, extra) {
387
+ return { id: rule.id, kind: rule.kind, passed, observed, failures, extra: extra ?? null };
388
+ }
389
+
390
+ /** @param {{inventoryPath: string, inventory: Inventory, source: string, observations: Observation[], results: Result[], passed: boolean}} report */
391
+ function buildReport({ inventoryPath, inventory, source, observations, results, passed }) {
392
+ const lines = [
393
+ '## Guardrails',
394
+ '',
395
+ `- Inventory: ${inventoryPath} (${inventory.routes.length} routes, ${inventory.rules.length} rules)`,
396
+ `- Observations: ${source}`,
397
+ '',
398
+ '### Rendered routes',
399
+ '',
400
+ ...observations.map((item) => `- ${box(item.passed)} ${item.route}: ${item.reason}`),
401
+ '',
402
+ '### Rules',
403
+ '',
404
+ ...results.flatMap((item) => [
405
+ `- ${box(item.passed)} ${item.id} (${item.kind}) observed on ${item.observed} routes${item.extra ? `, ${item.extra}` : ''}`,
406
+ ...item.failures.map((failure) => ` - ${failure}`),
407
+ ]),
408
+ '',
409
+ `Result: ${passed ? 'passed' : 'failed'}`,
410
+ '',
411
+ ];
412
+ return lines.join('\n');
413
+ }
414
+
415
+ /** @param {Rule} rule */
416
+ function phrases(rule) {
417
+ return asArray(rule.text ?? []).filter(isNonEmptyString);
418
+ }
419
+
420
+ /** @param {string} text @param {string} phrase */
421
+ function snippet(text, phrase) {
422
+ const index = text.toLowerCase().indexOf(phrase.toLowerCase());
423
+ const start = Math.max(0, index - 40);
424
+ const end = Math.min(text.length, index + phrase.length + 40);
425
+ return `"${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}"`;
426
+ }
427
+
428
+ // Queries and fragments address the same route. Absolute and protocol-relative
429
+ // URLs are internal only when their origin matches the observed document.
430
+ /** @param {string} href @param {Observation} item @param {boolean} [internalOnly] @returns {string | null} */
431
+ function linkTarget(href, item, internalOnly = true) {
432
+ try {
433
+ const url = new URL(href, item.baseUrl);
434
+ if (!['http:', 'https:'].includes(url.protocol)) return null;
435
+ if (url.origin !== new URL(item.url).origin)
436
+ return internalOnly ? null : `${url.origin}${normalizePath(url.pathname)}`;
437
+ return normalizePath(url.pathname);
438
+ } catch {
439
+ return ''; // Invalid URLs cannot match an inventoried route.
440
+ }
441
+ }
442
+
443
+ /** @param {string} value */
444
+ function normalizePath(value) {
445
+ const trimmed = value.trim();
446
+ if (trimmed === '' || trimmed === '/') return '/';
447
+ return `/${trimmed.replace(/^\/+/, '').replace(/\/+$/, '')}`;
448
+ }
449
+
450
+ /** @param {string} value */
451
+ function decodeEntities(value) {
452
+ return value
453
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
454
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
455
+ .replace(
456
+ /&(amp|lt|gt|quot|apos|nbsp);/g,
457
+ (_, name) =>
458
+ /** @type {Record<string, string>} */ ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' })[name]
459
+ );
460
+ }
461
+
462
+ /** @param {boolean} passed */
463
+ function box(passed) {
464
+ return passed ? '[x]' : '[ ]';
465
+ }
466
+
467
+ /** @template T @param {T | T[]} value @returns {T[]} */
468
+ function asArray(value) {
469
+ if (Array.isArray(value)) return value;
470
+ if (value === undefined || value === null) return [];
471
+ return [value];
472
+ }
473
+
474
+ /** @template T @param {T[]} items */
475
+ function uniqueArray(items) {
476
+ return [...new Set(items)];
477
+ }
478
+
479
+ /** @param {unknown} value @returns {value is string} */
480
+ function isNonEmptyString(value) {
481
+ return typeof value === 'string' && value.trim().length > 0;
482
+ }
483
+
484
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
485
+ function isRecord(value) {
486
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
487
+ }
488
+
489
+ /** @param {string} value */
490
+ function toPosix(value) {
491
+ return value.split(path.sep).join('/');
492
+ }
493
+
494
+ /** @param {string} message @returns {never} */
495
+ function fail(message) {
496
+ console.error(message);
497
+ process.exit(1);
498
+ }
499
+
500
+ await main();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "bundled": true,
3
3
  "bundler": "esbuild",
4
- "bundledAt": "2026-09-09T17:05:37.211Z",
4
+ "bundledAt": "2026-09-11T17:18:35.411Z",
5
5
  "cliVersion": "2.5.1",
6
6
  "external": [
7
7
  "oxc-parser",