coding-agent-skills 0.2.8 → 0.2.10

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 (78) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +6 -0
  3. package/ROADMAP.md +21 -15
  4. package/bin/coding-agent-skills +15 -1
  5. package/docs/adapters/README.md +34 -0
  6. package/docs/adapters/project-installation.md +25 -1
  7. package/docs/adapters/real-project-adoption.md +3 -2
  8. package/docs/architecture/README.md +5 -1
  9. package/docs/release/README.md +11 -8
  10. package/docs/release/npm-package.md +10 -4
  11. package/docs/safety/README.md +9 -1
  12. package/docs/testing/README.md +15 -0
  13. package/docs/usage/README.md +23 -5
  14. package/examples/command-policies/env-audit.json +73 -0
  15. package/examples/command-policies/route-trace.json +72 -0
  16. package/examples/evidence-packs/env-audit.json +55 -0
  17. package/examples/evidence-packs/route-trace.json +55 -0
  18. package/examples/manifests/env-audit.json +14 -0
  19. package/examples/manifests/route-trace.json +14 -0
  20. package/examples/workflows/env-audit.md +16 -0
  21. package/examples/workflows/route-trace.md +20 -0
  22. package/package.json +3 -1
  23. package/runs/skill-runs.md +37 -0
  24. package/schemas/project-adapter-installation.schema.json +7 -3
  25. package/schemas/project-adapter.schema.json +4 -0
  26. package/scripts/lib/env-audit.mjs +640 -0
  27. package/scripts/lib/pack-rules.mjs +20 -2
  28. package/scripts/lib/route-trace.mjs +785 -0
  29. package/scripts/render-env-audit.mjs +8 -0
  30. package/scripts/render-route-trace.mjs +8 -0
  31. package/scripts/test-pack.mjs +159 -1
  32. package/scripts/validate-pack.mjs +8 -2
  33. package/skills/env-audit/SKILL.md +58 -0
  34. package/skills/env-audit/adapter-interface.md +12 -0
  35. package/skills/env-audit/agents/openai.yaml +4 -0
  36. package/skills/env-audit/checklist.md +7 -0
  37. package/skills/env-audit/evidence-template.md +17 -0
  38. package/skills/env-audit/examples.md +28 -0
  39. package/skills/env-audit/failure-modes.md +5 -0
  40. package/skills/route-trace/SKILL.md +58 -0
  41. package/skills/route-trace/adapter-interface.md +20 -0
  42. package/skills/route-trace/agents/openai.yaml +4 -0
  43. package/skills/route-trace/checklist.md +11 -0
  44. package/skills/route-trace/evidence-template.md +18 -0
  45. package/skills/route-trace/examples.md +32 -0
  46. package/skills/route-trace/failure-modes.md +9 -0
  47. package/tests/fixtures/env-audit/adapter-project/.coding-agent/adapters/env-audit-fixture/adapter.json +56 -0
  48. package/tests/fixtures/env-audit/adapter-project/.coding-agent/skills.json +23 -0
  49. package/tests/fixtures/env-audit/adapter-project/README.md +3 -0
  50. package/tests/fixtures/env-audit/adapter-project/package.json +4 -0
  51. package/tests/fixtures/env-audit/adapter-project/src/config.ts +2 -0
  52. package/tests/fixtures/env-audit/static-project/.env.example +3 -0
  53. package/tests/fixtures/env-audit/static-project/README.md +3 -0
  54. package/tests/fixtures/env-audit/static-project/docs/setup.md +3 -0
  55. package/tests/fixtures/env-audit/static-project/package.json +4 -0
  56. package/tests/fixtures/env-audit/static-project/src/config.ts +4 -0
  57. package/tests/fixtures/env-audit/static-project/src/deno.ts +1 -0
  58. package/tests/fixtures/route-trace/adapter-project/.coding-agent/adapters/route-trace-fixture/adapter.json +59 -0
  59. package/tests/fixtures/route-trace/adapter-project/.coding-agent/skills.json +23 -0
  60. package/tests/fixtures/route-trace/adapter-project/README.md +3 -0
  61. package/tests/fixtures/route-trace/adapter-project/app/api/items/route.ts +3 -0
  62. package/tests/fixtures/route-trace/adapter-project/package.json +5 -0
  63. package/tests/fixtures/route-trace/adapter-project/pages/index.tsx +3 -0
  64. package/tests/fixtures/route-trace/adapter-project/src/routes.ts +3 -0
  65. package/tests/fixtures/route-trace/static-project/.env.example +1 -0
  66. package/tests/fixtures/route-trace/static-project/README.md +3 -0
  67. package/tests/fixtures/route-trace/static-project/app/api/users/route.ts +3 -0
  68. package/tests/fixtures/route-trace/static-project/app/blog/[slug]/page.tsx +3 -0
  69. package/tests/fixtures/route-trace/static-project/app/page.tsx +3 -0
  70. package/tests/fixtures/route-trace/static-project/package.json +5 -0
  71. package/tests/fixtures/route-trace/static-project/pages/about.tsx +3 -0
  72. package/tests/fixtures/route-trace/static-project/pages/api/hello.ts +3 -0
  73. package/tests/fixtures/route-trace/static-project/server/routes.ts +4 -0
  74. package/tests/fixtures/route-trace/static-project/src/route-config.ts +4 -0
  75. package/tests/fixtures/route-trace/static-project/src/router.tsx +10 -0
  76. package/tests/fixtures/triggers/cases.json +25 -1
  77. package/tests/trigger/README.md +3 -0
  78. package/work-ledger.md +35 -10
@@ -0,0 +1,785 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import {
7
+ ADAPTER_MANIFEST_FILENAME,
8
+ readSafeJsonFile,
9
+ } from "./adapter-discovery.mjs";
10
+ import { PILOT_VERSION, redactSensitiveText } from "./pack-rules.mjs";
11
+ import {
12
+ readProjectAdapterDeclaration,
13
+ validateProjectAdapters,
14
+ } from "./project-adapter-installation.mjs";
15
+
16
+ const DEFAULT_CORE_ROOT = path.resolve(
17
+ path.dirname(fileURLToPath(import.meta.url)),
18
+ "..",
19
+ "..",
20
+ );
21
+
22
+ const DEFAULT_IGNORED_PATHS = [
23
+ ".git",
24
+ ".next",
25
+ "node_modules",
26
+ "dist",
27
+ "build",
28
+ "coverage",
29
+ "out",
30
+ "validation-output",
31
+ ];
32
+
33
+ const ROUTE_EXTENSIONS = new Set([
34
+ ".cjs",
35
+ ".cts",
36
+ ".js",
37
+ ".jsx",
38
+ ".mjs",
39
+ ".mts",
40
+ ".ts",
41
+ ".tsx",
42
+ ]);
43
+
44
+ const CONFIG_FILENAMES = new Set([
45
+ "routes.js",
46
+ "routes.jsx",
47
+ "routes.ts",
48
+ "routes.tsx",
49
+ "router.js",
50
+ "router.jsx",
51
+ "router.ts",
52
+ "router.tsx",
53
+ "route-config.js",
54
+ "route-config.ts",
55
+ ]);
56
+
57
+ const REFUSED_BEHAVIOR = [
58
+ "no target project builds",
59
+ "no target project tests",
60
+ "no dev servers",
61
+ "no package installs",
62
+ "no app-code execution",
63
+ "no runtime URL probing",
64
+ "no deployments",
65
+ "no migrations",
66
+ "no database inspection",
67
+ "no secret-file reads",
68
+ "no project writes",
69
+ ];
70
+
71
+ const NOT_VERIFIED = [
72
+ "runtime-generated routes",
73
+ "middleware rewrites and redirects that require execution",
74
+ "environment-dependent routing",
75
+ "framework plugin routes loaded outside inspected static files",
76
+ "remote or deployed URLs",
77
+ ];
78
+
79
+ function inside(root, candidate) {
80
+ const relative = path.relative(root, candidate);
81
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
82
+ }
83
+
84
+ function toPosix(relativePath) {
85
+ return relativePath.split(path.sep).join("/");
86
+ }
87
+
88
+ function uniqueBy(values, key) {
89
+ const seen = new Set();
90
+ const output = [];
91
+ for (const value of values) {
92
+ const marker = key(value);
93
+ if (seen.has(marker)) continue;
94
+ seen.add(marker);
95
+ output.push(value);
96
+ }
97
+ return output;
98
+ }
99
+
100
+ function safeRelativePath(candidate) {
101
+ return (
102
+ typeof candidate === "string" &&
103
+ candidate.length > 0 &&
104
+ !candidate.startsWith("/") &&
105
+ !candidate.split(/[\\/]+/).includes("..") &&
106
+ !/(^|\/)\.env(?:\.|$)/.test(candidate)
107
+ );
108
+ }
109
+
110
+ function secretBearingPath(relativePath) {
111
+ const normalized = toPosix(relativePath);
112
+ const basename = path.posix.basename(normalized);
113
+ return (
114
+ basename === ".env" ||
115
+ (basename.startsWith(".env.") && basename !== ".env.example") ||
116
+ basename === ".npmrc" ||
117
+ /\.(?:pem|key|p12|pfx)$/i.test(basename) ||
118
+ /(?:^|\/)(?:secrets?|credentials?|private-key)(?:\/|$)/i.test(normalized)
119
+ );
120
+ }
121
+
122
+ function ignoredBy(relativePath, ignoredPaths) {
123
+ const normalized = toPosix(relativePath);
124
+ return ignoredPaths.some((ignored) => {
125
+ const clean = toPosix(ignored).replace(/\/+$/g, "");
126
+ return normalized === clean || normalized.startsWith(`${clean}/`);
127
+ });
128
+ }
129
+
130
+ function gitSummary(projectRoot) {
131
+ const summary = {
132
+ root: null,
133
+ branchState: null,
134
+ hasUncommittedChanges: false,
135
+ warnings: [],
136
+ };
137
+
138
+ const revParse = spawnSync("git", ["rev-parse", "--show-toplevel"], {
139
+ cwd: projectRoot,
140
+ encoding: "utf8",
141
+ stdio: ["ignore", "pipe", "pipe"],
142
+ });
143
+ if (revParse.status === 0) summary.root = revParse.stdout.trim();
144
+
145
+ const status = spawnSync("git", ["status", "--short", "--branch"], {
146
+ cwd: projectRoot,
147
+ encoding: "utf8",
148
+ stdio: ["ignore", "pipe", "pipe"],
149
+ });
150
+ if (status.status !== 0) {
151
+ summary.warnings.push("git status unavailable");
152
+ return summary;
153
+ }
154
+
155
+ const lines = status.stdout.split(/\r?\n/).filter(Boolean);
156
+ summary.branchState = lines[0] ?? null;
157
+ summary.hasUncommittedChanges = lines.length > 1;
158
+ if (summary.hasUncommittedChanges) {
159
+ summary.warnings.push("working tree has local changes; filenames omitted");
160
+ }
161
+ if (/\[(?:ahead|behind|gone|diverged)[^\]]*\]/i.test(summary.branchState ?? "")) {
162
+ summary.warnings.push("branch state indicates remote divergence; revalidate after update");
163
+ }
164
+ return summary;
165
+ }
166
+
167
+ function discoverRouteTraceAdapters(loaded) {
168
+ const adapters = [];
169
+ const errors = [];
170
+ const declarations = loaded.declaration.adapters ?? [];
171
+ const container = path.resolve(loaded.projectRoot, loaded.declaration.adapterRoot);
172
+ const manifestCandidates = [];
173
+
174
+ if (!inside(loaded.projectRoot, container) || !fs.existsSync(container)) {
175
+ return { adapters, errors: ["adapter-root-not-found"] };
176
+ }
177
+
178
+ for (const entry of fs.readdirSync(container, { withFileTypes: true })) {
179
+ if (entry.isSymbolicLink() || !entry.isDirectory()) continue;
180
+ const manifestPath = path.join(container, entry.name, ADAPTER_MANIFEST_FILENAME);
181
+ if (!inside(loaded.projectRoot, manifestPath) || !fs.existsSync(manifestPath)) continue;
182
+ const record = readSafeJsonFile(manifestPath);
183
+ if (!record.value) {
184
+ errors.push(...record.codes);
185
+ continue;
186
+ }
187
+ manifestCandidates.push({
188
+ manifestPath,
189
+ manifest: record.value,
190
+ });
191
+ }
192
+
193
+ for (const declaration of declarations) {
194
+ if (!(declaration.skillIds ?? []).includes("route-trace")) continue;
195
+ const match = manifestCandidates.find(
196
+ (candidate) => candidate.manifest.adapterId === declaration.id,
197
+ );
198
+ if (!match) {
199
+ errors.push("declared-route-trace-adapter-not-found");
200
+ continue;
201
+ }
202
+ adapters.push({
203
+ declaration,
204
+ manifestPath: path.relative(loaded.projectRoot, match.manifestPath),
205
+ manifest: match.manifest,
206
+ });
207
+ }
208
+
209
+ return { adapters, errors };
210
+ }
211
+
212
+ function adapterContext(projectRootInput, coreRoot) {
213
+ const loaded = readProjectAdapterDeclaration(projectRootInput);
214
+ if (!loaded.ok) {
215
+ if (loaded.codes.length === 1 && loaded.codes[0] === "missing-project-declaration") {
216
+ return {
217
+ ok: true,
218
+ present: false,
219
+ enabled: false,
220
+ projectRoot: path.resolve(projectRootInput),
221
+ mode: "none",
222
+ codes: [],
223
+ };
224
+ }
225
+ return {
226
+ ok: false,
227
+ present: false,
228
+ enabled: false,
229
+ status: "failed",
230
+ codes: loaded.codes,
231
+ };
232
+ }
233
+
234
+ const validation = validateProjectAdapters(loaded.projectRoot, { coreRoot });
235
+ if (!validation.ok) {
236
+ return {
237
+ ok: false,
238
+ present: true,
239
+ enabled: false,
240
+ status: "failed",
241
+ codes: validation.codes,
242
+ validation,
243
+ };
244
+ }
245
+
246
+ if (!validation.acceptedSkills.includes("route-trace")) {
247
+ return {
248
+ ok: true,
249
+ present: true,
250
+ enabled: false,
251
+ projectRoot: loaded.projectRoot,
252
+ mode: "adapter-present-route-trace-not-enabled",
253
+ declarationPath: loaded.declarationPath,
254
+ declaration: loaded.declaration,
255
+ validation,
256
+ codes: ["route-trace-not-enabled-by-adapter"],
257
+ };
258
+ }
259
+
260
+ const { adapters, errors } = discoverRouteTraceAdapters(loaded);
261
+ if (errors.length > 0 || adapters.length === 0) {
262
+ return {
263
+ ok: false,
264
+ present: true,
265
+ enabled: false,
266
+ status: "failed",
267
+ codes: errors.length ? [...new Set(errors)] : ["route-trace-adapter-not-found"],
268
+ validation,
269
+ };
270
+ }
271
+
272
+ return {
273
+ ok: true,
274
+ present: true,
275
+ enabled: true,
276
+ projectRoot: loaded.projectRoot,
277
+ mode: "adapter-limited",
278
+ declarationPath: loaded.declarationPath,
279
+ declaration: loaded.declaration,
280
+ adapters,
281
+ validation,
282
+ codes: [],
283
+ };
284
+ }
285
+
286
+ function routeFromNextApp(relativePath) {
287
+ const normalized = toPosix(relativePath);
288
+ const parts = normalized.split("/");
289
+ const appIndex = parts.lastIndexOf("app");
290
+ if (appIndex === -1) return null;
291
+
292
+ const filename = parts.at(-1);
293
+ if (!/^(?:page|route)\.(?:jsx?|tsx?|mjs|cjs)$/.test(filename)) return null;
294
+
295
+ const routeSegments = parts
296
+ .slice(appIndex + 1, -1)
297
+ .filter(
298
+ (segment) =>
299
+ segment &&
300
+ !(segment.startsWith("(") && segment.endsWith(")")) &&
301
+ !segment.startsWith("@"),
302
+ );
303
+ const route = `/${routeSegments.join("/")}`.replace(/\/+/g, "/");
304
+ return {
305
+ route: route === "/" ? "/" : route.replace(/\/$/g, ""),
306
+ kind: filename.startsWith("route.") ? "next-app-api-route-file" : "next-app-page-file",
307
+ };
308
+ }
309
+
310
+ function stripRouteExtension(filename) {
311
+ return filename.replace(/\.(?:jsx?|tsx?|mjs|cjs)$/i, "");
312
+ }
313
+
314
+ function routeFromPages(relativePath) {
315
+ const normalized = toPosix(relativePath);
316
+ const parts = normalized.split("/");
317
+ const pagesIndex = parts.lastIndexOf("pages");
318
+ if (pagesIndex === -1) return null;
319
+
320
+ const filename = parts.at(-1);
321
+ if (!ROUTE_EXTENSIONS.has(path.posix.extname(filename))) return null;
322
+
323
+ const routeSegments = [...parts.slice(pagesIndex + 1, -1), stripRouteExtension(filename)];
324
+ if (routeSegments.some((segment) => /^_(?:app|document|error)$/.test(segment))) return null;
325
+ if (routeSegments.at(-1) === "index") routeSegments.pop();
326
+ const route = `/${routeSegments.join("/")}`.replace(/\/+/g, "/");
327
+ return {
328
+ route: route === "/" ? "/" : route.replace(/\/$/g, ""),
329
+ kind: routeSegments[0] === "api" ? "next-pages-api-route-file" : "next-pages-route-file",
330
+ };
331
+ }
332
+
333
+ function routeFileFinding(relativePath) {
334
+ const appRoute = routeFromNextApp(relativePath);
335
+ if (appRoute) {
336
+ return {
337
+ route: appRoute.route,
338
+ file: relativePath,
339
+ kind: appRoute.kind,
340
+ confidence: "verified-route-file",
341
+ };
342
+ }
343
+ const pagesRoute = routeFromPages(relativePath);
344
+ if (pagesRoute) {
345
+ return {
346
+ route: pagesRoute.route,
347
+ file: relativePath,
348
+ kind: pagesRoute.kind,
349
+ confidence: "verified-route-file",
350
+ };
351
+ }
352
+ return null;
353
+ }
354
+
355
+ function inferRoutePatterns(relativePath, source) {
356
+ const findings = [];
357
+ const patterns = [
358
+ {
359
+ kind: "react-router-route-declaration",
360
+ regex: /<Route\b[^>]*\bpath=["'`]([^"'`]+)["'`]/g,
361
+ method: null,
362
+ },
363
+ {
364
+ kind: "route-config-path",
365
+ regex: /\bpath\s*:\s*["'`]([^"'`]+)["'`]/g,
366
+ method: null,
367
+ },
368
+ {
369
+ kind: "express-style-route-registration",
370
+ regex: /\b(?:app|router|server)\.(get|post|put|patch|delete|all)\s*\(\s*["'`]([^"'`]+)["'`]/g,
371
+ methodIndex: 1,
372
+ routeIndex: 2,
373
+ },
374
+ {
375
+ kind: "fastify-route-registration",
376
+ regex: /\bfastify\.(get|post|put|patch|delete|all)\s*\(\s*["'`]([^"'`]+)["'`]/g,
377
+ methodIndex: 1,
378
+ routeIndex: 2,
379
+ },
380
+ {
381
+ kind: "hono-route-registration",
382
+ regex: /\b(?:app|router|hono)\.(get|post|put|patch|delete|all)\s*\(\s*["'`]([^"'`]+)["'`]/g,
383
+ methodIndex: 1,
384
+ routeIndex: 2,
385
+ },
386
+ {
387
+ kind: "object-route-registration",
388
+ regex: /\b(?:url|path)\s*:\s*["'`]([^"'`]+)["'`]/g,
389
+ method: null,
390
+ },
391
+ ];
392
+
393
+ for (const pattern of patterns) {
394
+ for (const match of source.matchAll(pattern.regex)) {
395
+ const route = match[pattern.routeIndex ?? 1];
396
+ if (!route || !route.startsWith("/")) continue;
397
+ findings.push({
398
+ route,
399
+ method: pattern.methodIndex ? match[pattern.methodIndex].toUpperCase() : null,
400
+ file: relativePath,
401
+ kind: pattern.kind,
402
+ confidence: "inferred-route-pattern",
403
+ });
404
+ }
405
+ }
406
+
407
+ return findings;
408
+ }
409
+
410
+ function collectFiles(projectRoot, scopePaths, ignoredPaths, options = {}) {
411
+ const maxFiles = options.maxFiles ?? 1200;
412
+ const maxDepth = options.maxDepth ?? 8;
413
+ const files = [];
414
+ const skipped = [];
415
+
416
+ function addSkip(relativePath, reason) {
417
+ skipped.push({
418
+ path: relativePath || ".",
419
+ reason,
420
+ });
421
+ }
422
+
423
+ function visit(absolutePath, relativePath, depth) {
424
+ if (files.length >= maxFiles) {
425
+ addSkip(relativePath, "file limit reached");
426
+ return;
427
+ }
428
+ if (!inside(projectRoot, absolutePath)) {
429
+ addSkip(relativePath, "outside project root");
430
+ return;
431
+ }
432
+ if (secretBearingPath(relativePath)) {
433
+ addSkip(relativePath, "secret-bearing path");
434
+ return;
435
+ }
436
+ if (ignoredBy(relativePath, ignoredPaths)) {
437
+ addSkip(relativePath, "ignored path");
438
+ return;
439
+ }
440
+
441
+ const stat = fs.lstatSync(absolutePath);
442
+ if (stat.isSymbolicLink()) {
443
+ addSkip(relativePath, "symlink skipped");
444
+ return;
445
+ }
446
+ if (stat.isDirectory()) {
447
+ if (depth > maxDepth) {
448
+ addSkip(relativePath, "maximum depth reached");
449
+ return;
450
+ }
451
+ for (const entry of fs.readdirSync(absolutePath, { withFileTypes: true })) {
452
+ visit(
453
+ path.join(absolutePath, entry.name),
454
+ toPosix(path.join(relativePath, entry.name)),
455
+ depth + 1,
456
+ );
457
+ }
458
+ return;
459
+ }
460
+ if (!stat.isFile()) {
461
+ addSkip(relativePath, "non-regular file");
462
+ return;
463
+ }
464
+ if (stat.size > (options.maxFileBytes ?? 262144)) {
465
+ addSkip(relativePath, "file too large for static route scan");
466
+ return;
467
+ }
468
+
469
+ const extension = path.extname(relativePath);
470
+ const basename = path.basename(relativePath);
471
+ const isRouteConfig = CONFIG_FILENAMES.has(basename);
472
+ if (!ROUTE_EXTENSIONS.has(extension) && !isRouteConfig && basename !== "package.json") {
473
+ return;
474
+ }
475
+ files.push(relativePath);
476
+ }
477
+
478
+ for (const scopePath of scopePaths) {
479
+ if (scopePath !== "." && !safeRelativePath(scopePath)) {
480
+ addSkip(String(scopePath), "unsafe scope path");
481
+ continue;
482
+ }
483
+ const absolute = path.resolve(projectRoot, scopePath);
484
+ if (!inside(projectRoot, absolute)) {
485
+ addSkip(scopePath, "scope path outside project root");
486
+ continue;
487
+ }
488
+ if (!fs.existsSync(absolute)) {
489
+ addSkip(scopePath, "scope path missing");
490
+ continue;
491
+ }
492
+ visit(absolute, scopePath === "." ? "" : scopePath, 0);
493
+ }
494
+
495
+ return {
496
+ files: uniqueBy(files, (file) => file).sort(),
497
+ skipped: uniqueBy(skipped, (record) => `${record.path}:${record.reason}`),
498
+ };
499
+ }
500
+
501
+ function scanFiles(projectRoot, files) {
502
+ const verifiedRouteFiles = [];
503
+ const inferredRoutePatterns = [];
504
+ const skipped = [];
505
+
506
+ for (const relativePath of files) {
507
+ const routeFile = routeFileFinding(relativePath);
508
+ if (routeFile) verifiedRouteFiles.push(routeFile);
509
+
510
+ const basename = path.basename(relativePath);
511
+ if (!ROUTE_EXTENSIONS.has(path.extname(relativePath)) && !CONFIG_FILENAMES.has(basename)) {
512
+ continue;
513
+ }
514
+
515
+ try {
516
+ const source = fs.readFileSync(path.join(projectRoot, relativePath), "utf8");
517
+ inferredRoutePatterns.push(...inferRoutePatterns(relativePath, source));
518
+ } catch {
519
+ skipped.push({
520
+ path: relativePath,
521
+ reason: "file could not be read as utf8",
522
+ });
523
+ }
524
+ }
525
+
526
+ return {
527
+ verifiedRouteFiles: uniqueBy(
528
+ verifiedRouteFiles,
529
+ (record) => `${record.route}:${record.file}:${record.kind}`,
530
+ ).sort((a, b) => `${a.route}:${a.file}`.localeCompare(`${b.route}:${b.file}`)),
531
+ inferredRoutePatterns: uniqueBy(
532
+ inferredRoutePatterns,
533
+ (record) => `${record.method ?? ""}:${record.route}:${record.file}:${record.kind}`,
534
+ ).sort((a, b) => `${a.route}:${a.file}`.localeCompare(`${b.route}:${b.file}`)),
535
+ skipped,
536
+ };
537
+ }
538
+
539
+ function adapterScope(context) {
540
+ if (!context.enabled) return null;
541
+ const safeReadPaths = uniqueBy(
542
+ context.adapters.flatMap((adapter) => adapter.manifest.extensions.safeReadPaths ?? []),
543
+ (value) => value,
544
+ );
545
+ const ignoredPaths = uniqueBy(
546
+ [
547
+ ...DEFAULT_IGNORED_PATHS,
548
+ ...context.adapters.flatMap((adapter) => adapter.manifest.extensions.ignoredPaths ?? []),
549
+ ],
550
+ (value) => value,
551
+ );
552
+ return {
553
+ adapterIds: context.adapters.map((adapter) => adapter.manifest.adapterId).sort(),
554
+ manifestPaths: context.adapters.map((adapter) => adapter.manifestPath).sort(),
555
+ scopePaths: safeReadPaths.length ? safeReadPaths : [],
556
+ ignoredPaths,
557
+ requiredEvidence: uniqueBy(
558
+ context.adapters.flatMap((adapter) => adapter.manifest.extensions.requiredEvidence ?? []),
559
+ (value) => value,
560
+ ),
561
+ };
562
+ }
563
+
564
+ export function buildRouteTraceReport(projectRootInput, options = {}) {
565
+ if (!projectRootInput) {
566
+ return {
567
+ ok: false,
568
+ status: "failed",
569
+ codes: ["missing-project-root"],
570
+ };
571
+ }
572
+
573
+ const coreRoot = path.resolve(options.coreRoot ?? DEFAULT_CORE_ROOT);
574
+ const projectRoot = path.resolve(projectRootInput);
575
+ if (!fs.existsSync(projectRoot) || !fs.lstatSync(projectRoot).isDirectory()) {
576
+ return {
577
+ ok: false,
578
+ status: "failed",
579
+ codes: ["project-root-not-found"],
580
+ };
581
+ }
582
+
583
+ const context = adapterContext(projectRoot, coreRoot);
584
+ if (!context.ok) {
585
+ return {
586
+ ok: false,
587
+ status: "failed",
588
+ codes: context.codes,
589
+ adapter: {
590
+ present: context.present,
591
+ enabled: false,
592
+ },
593
+ };
594
+ }
595
+
596
+ const git = gitSummary(projectRoot);
597
+ if (context.present && !context.enabled) {
598
+ return {
599
+ ok: true,
600
+ status: "partial",
601
+ coreVersion: PILOT_VERSION,
602
+ projectRoot,
603
+ adapter: {
604
+ present: true,
605
+ enabled: false,
606
+ mode: context.mode,
607
+ declarationPath: context.declarationPath,
608
+ codes: context.codes,
609
+ },
610
+ git,
611
+ scannedFiles: [],
612
+ verifiedRouteFiles: [],
613
+ inferredRoutePatterns: [],
614
+ skipped: [
615
+ {
616
+ path: ".",
617
+ reason: "project adapter is present but route-trace is not enabled; no route files read",
618
+ },
619
+ ],
620
+ notVerified: NOT_VERIFIED,
621
+ refusedBehavior: REFUSED_BEHAVIOR,
622
+ warnings: [
623
+ ...git.warnings,
624
+ "adapter-limited scope prevented route-trace from reading target files",
625
+ ],
626
+ };
627
+ }
628
+
629
+ const scope = adapterScope(context);
630
+ const scopePaths = scope?.scopePaths ?? ["."];
631
+ const ignoredPaths = scope?.ignoredPaths ?? DEFAULT_IGNORED_PATHS;
632
+ const collected = collectFiles(projectRoot, scopePaths, ignoredPaths, options);
633
+ const scanned = scanFiles(projectRoot, collected.files);
634
+
635
+ return {
636
+ ok: true,
637
+ status: "complete",
638
+ coreVersion: PILOT_VERSION,
639
+ projectRoot,
640
+ adapter: context.present
641
+ ? {
642
+ present: true,
643
+ enabled: true,
644
+ mode: "adapter-limited",
645
+ declarationPath: context.declarationPath,
646
+ adapterIds: scope.adapterIds,
647
+ manifestPaths: scope.manifestPaths,
648
+ scopePaths,
649
+ ignoredPaths,
650
+ requiredEvidence: scope.requiredEvidence,
651
+ }
652
+ : {
653
+ present: false,
654
+ enabled: false,
655
+ mode: "generic-static",
656
+ scopePaths,
657
+ ignoredPaths,
658
+ requiredEvidence: [],
659
+ },
660
+ git,
661
+ scannedFiles: collected.files,
662
+ verifiedRouteFiles: scanned.verifiedRouteFiles,
663
+ inferredRoutePatterns: scanned.inferredRoutePatterns,
664
+ skipped: uniqueBy([...collected.skipped, ...scanned.skipped], (record) => `${record.path}:${record.reason}`),
665
+ notVerified: NOT_VERIFIED,
666
+ refusedBehavior: REFUSED_BEHAVIOR,
667
+ warnings: uniqueBy(
668
+ [
669
+ ...git.warnings,
670
+ context.present
671
+ ? "route-trace used adapter-declared safe read paths only"
672
+ : "no project adapter declaration found; route-trace used generic bounded static scan",
673
+ ],
674
+ (value) => value,
675
+ ),
676
+ };
677
+ }
678
+
679
+ function listRecords(title, records, formatter) {
680
+ if (!records.length) return [`## ${title}`, "- none"];
681
+ return [`## ${title}`, ...records.map(formatter)];
682
+ }
683
+
684
+ export function renderRouteTraceReport(report) {
685
+ if (!report.ok) {
686
+ return [
687
+ "# Route Trace Report",
688
+ "",
689
+ `Status: ${report.status}`,
690
+ `Rejection codes: ${(report.codes ?? []).join(", ")}`,
691
+ "",
692
+ "No target project build, test, runtime, deployment, migration, package installation, app-code execution, or secret-file read was performed.",
693
+ ].join("\n");
694
+ }
695
+
696
+ const lines = [
697
+ "# Route Trace Report",
698
+ "",
699
+ `Status: ${report.status}`,
700
+ `Core version: ${report.coreVersion}`,
701
+ `Project root: ${redactSensitiveText(report.projectRoot)}`,
702
+ "",
703
+ "## Git State",
704
+ `- Git root: ${redactSensitiveText(report.git.root ?? "not detected")}`,
705
+ `- Branch state: ${redactSensitiveText(report.git.branchState ?? "not detected")}`,
706
+ `- Local changes: ${report.git.hasUncommittedChanges ? "present; filenames omitted" : "not detected"}`,
707
+ "",
708
+ "## Adapter Scope",
709
+ `- Adapter present: ${report.adapter.present ? "yes" : "no"}`,
710
+ `- Route-trace enabled: ${report.adapter.enabled ? "yes" : "no"}`,
711
+ `- Mode: ${report.adapter.mode}`,
712
+ ];
713
+
714
+ if (report.adapter.declarationPath) {
715
+ lines.push(`- Declaration: ${report.adapter.declarationPath}`);
716
+ }
717
+ if (report.adapter.adapterIds?.length) {
718
+ lines.push(`- Adapter IDs: ${report.adapter.adapterIds.join(", ")}`);
719
+ }
720
+ if (report.adapter.manifestPaths?.length) {
721
+ lines.push(`- Adapter manifests: ${report.adapter.manifestPaths.join(", ")}`);
722
+ }
723
+
724
+ lines.push(
725
+ "",
726
+ "## Scope Paths",
727
+ ...(report.adapter.scopePaths?.length ? report.adapter.scopePaths.map((item) => `- ${item}`) : ["- none; route tracing skipped"]),
728
+ "",
729
+ "## Ignored Paths",
730
+ ...(report.adapter.ignoredPaths?.length ? report.adapter.ignoredPaths.map((item) => `- ${item}`) : ["- none declared"]),
731
+ "",
732
+ "## Summary",
733
+ `- Static files scanned: ${report.scannedFiles.length}`,
734
+ `- Verified route files: ${report.verifiedRouteFiles.length}`,
735
+ `- Inferred route patterns: ${report.inferredRoutePatterns.length}`,
736
+ `- Skipped items: ${report.skipped.length}`,
737
+ "",
738
+ ...listRecords(
739
+ "Verified Route Files",
740
+ report.verifiedRouteFiles,
741
+ (record) => `- ${record.route} (${record.kind}) in ${record.file}`,
742
+ ),
743
+ "",
744
+ ...listRecords(
745
+ "Inferred Route Patterns",
746
+ report.inferredRoutePatterns,
747
+ (record) =>
748
+ `- ${record.method ? `${record.method} ` : ""}${record.route} (${record.kind}) in ${record.file}`,
749
+ ),
750
+ "",
751
+ ...listRecords(
752
+ "Skipped",
753
+ report.skipped,
754
+ (record) => `- ${record.path}: ${record.reason}`,
755
+ ),
756
+ "",
757
+ ...listRecords("Not Verified", report.notVerified, (item) => `- ${item}`),
758
+ "",
759
+ ...listRecords("Warnings", report.warnings, (item) => `- ${item}`),
760
+ "",
761
+ ...listRecords("Refused Behavior", report.refusedBehavior, (item) => `- ${item}`),
762
+ "",
763
+ "No target project build, test, runtime, deployment, migration, package installation, app-code execution, or secret-file read was performed.",
764
+ );
765
+
766
+ return lines.join("\n");
767
+ }
768
+
769
+ export function routeTraceCliResult(projectRoot, options = {}) {
770
+ if (!projectRoot) {
771
+ return {
772
+ exitCode: 2,
773
+ stream: "stderr",
774
+ lines: ["usage: node scripts/render-route-trace.mjs <project-root>"],
775
+ };
776
+ }
777
+
778
+ const report = buildRouteTraceReport(projectRoot, options);
779
+ return {
780
+ exitCode: report.ok ? 0 : 1,
781
+ stream: report.ok ? "stdout" : "stderr",
782
+ lines: renderRouteTraceReport(report).split("\n"),
783
+ report,
784
+ };
785
+ }