docguard-cli 0.40.4 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +3218 -0
  2. package/README.md +25 -16
  3. package/cli/assessment.mjs +94 -0
  4. package/cli/commands/ci.mjs +15 -5
  5. package/cli/commands/diagnose.mjs +20 -13
  6. package/cli/commands/fix.mjs +14 -45
  7. package/cli/commands/guard.mjs +53 -25
  8. package/cli/commands/hooks.mjs +51 -10
  9. package/cli/commands/init.mjs +15 -0
  10. package/cli/commands/reconcile.mjs +10 -3
  11. package/cli/commands/report.mjs +5 -1
  12. package/cli/commands/score.mjs +2 -1
  13. package/cli/commands/upgrade.mjs +4 -1
  14. package/cli/commands/verify.mjs +9 -2
  15. package/cli/commands/watch.mjs +3 -2
  16. package/cli/config.mjs +23 -0
  17. package/cli/evidence/adapters.mjs +14 -0
  18. package/cli/evidence/manifest.mjs +15 -0
  19. package/cli/evidence/python-literal.mjs +304 -0
  20. package/cli/findings.mjs +17 -3
  21. package/cli/scanners/instruction-audit.mjs +88 -11
  22. package/cli/scanners/js-ast.mjs +156 -18
  23. package/cli/scanners/reconciliation.mjs +56 -6
  24. package/cli/scanners/routes.mjs +84 -9
  25. package/cli/scanners/spec-registry.mjs +29 -0
  26. package/cli/shared-git.mjs +98 -0
  27. package/cli/shared-ignore.mjs +1 -1
  28. package/cli/shared.mjs +30 -1
  29. package/cli/validators/api-doc-smells.mjs +2 -2
  30. package/cli/validators/api-surface.mjs +4 -9
  31. package/cli/validators/diff-suspicion.mjs +3 -2
  32. package/cli/validators/docs-sync.mjs +45 -29
  33. package/cli/validators/environment.mjs +64 -6
  34. package/cli/validators/metrics-consistency.mjs +52 -11
  35. package/cli/validators/reference-existence.mjs +4 -2
  36. package/cli/validators/security.mjs +37 -12
  37. package/cli/validators/spec-registry.mjs +10 -7
  38. package/cli/validators/todo-tracking.mjs +31 -11
  39. package/cli/validators/traceability.mjs +29 -4
  40. package/cli/writers/junit.mjs +3 -3
  41. package/cli/writers/sarif.mjs +13 -9
  42. package/docs/configuration.md +12 -1
  43. package/extensions/spec-kit-docguard/README.md +3 -0
  44. package/extensions/spec-kit-docguard/commands/review.md +50 -0
  45. package/extensions/spec-kit-docguard/extension.yml +16 -4
  46. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  47. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  48. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  49. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  50. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  51. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +1 -1
  52. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  53. package/package.json +2 -1
  54. package/schemas/docguard-config.schema.json +15 -1
  55. package/schemas/docguard-evidence.schema.json +12 -0
  56. package/templates/ci/github-actions.yml +1 -1
  57. package/templates/evidence-manifest.json +16 -0
@@ -185,16 +185,104 @@ export function extractJsSchemaBodies(content, filename = 'file.ts') {
185
185
  // are middleware-ish; `all` is included (it IS a route), `use` is not.
186
186
  const HTTP_METHOD_NAMES = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'all']);
187
187
 
188
- /** Extract a string path from a call's first arg: StringLiteral or TemplateLiteral. */
189
- function pathArgValue(node) {
188
+ function topLevelStaticStrings(ast) {
189
+ const declarations = new Map();
190
+ const body = ast?.program?.body || [];
191
+ for (const statement of body) {
192
+ const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
193
+ if (declaration?.type !== 'VariableDeclaration' || declaration.kind !== 'const') continue;
194
+ for (const item of declaration.declarations || []) {
195
+ if (item.id?.type !== 'Identifier' || !item.init) continue;
196
+ if (declarations.has(item.id.name)) declarations.set(item.id.name, null);
197
+ else declarations.set(item.id.name, item.init);
198
+ }
199
+ }
200
+ const resolved = new Map();
201
+ const resolveNode = (node, visiting = new Set()) => {
202
+ if (!node) return null;
203
+ if (node.type === 'StringLiteral') return node.value;
204
+ if (node.type === 'Identifier') {
205
+ if (resolved.has(node.name)) return resolved.get(node.name);
206
+ const init = declarations.get(node.name);
207
+ if (!init || visiting.has(node.name)) return null;
208
+ const value = resolveNode(init, new Set(visiting).add(node.name));
209
+ if (value !== null) resolved.set(node.name, value);
210
+ return value;
211
+ }
212
+ if (node.type === 'TemplateLiteral') {
213
+ let value = '';
214
+ for (let i = 0; i < node.quasis.length; i++) {
215
+ value += node.quasis[i]?.value?.cooked ?? '';
216
+ if (i < node.expressions.length) {
217
+ const expression = resolveNode(node.expressions[i], visiting);
218
+ if (expression === null) return null;
219
+ value += expression;
220
+ }
221
+ }
222
+ return value;
223
+ }
224
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
225
+ const left = resolveNode(node.left, visiting);
226
+ const right = resolveNode(node.right, visiting);
227
+ return left === null || right === null ? null : left + right;
228
+ }
229
+ return null;
230
+ };
231
+ for (const name of declarations.keys()) resolveNode({ type: 'Identifier', name });
232
+ return resolved;
233
+ }
234
+
235
+ function expressRouteBindings(ast) {
236
+ const bindings = new Set(['app', 'router', 'server', 'fastify', 'hono']);
237
+ const factories = new Set(['express', 'express.Router', 'Router', 'createRouter', 'Fastify', 'Hono']);
238
+ walk(ast, node => {
239
+ if (node.type !== 'ImportDeclaration' || node.source?.value !== 'express') return;
240
+ for (const spec of node.specifiers || []) {
241
+ if (spec.type === 'ImportDefaultSpecifier') factories.add(spec.local.name);
242
+ if (spec.type === 'ImportSpecifier' && (spec.imported?.name || spec.imported?.value) === 'Router') {
243
+ factories.add(spec.local.name);
244
+ }
245
+ }
246
+ });
247
+ walk(ast, node => {
248
+ if (node.type !== 'VariableDeclarator' || node.id?.type !== 'Identifier' || node.init?.type !== 'CallExpression') return;
249
+ const factory = calleeName(node.init.callee);
250
+ if (factories.has(factory)) bindings.add(node.id.name);
251
+ });
252
+ return bindings;
253
+ }
254
+
255
+ function looksLikeRouteReceiver(name, bindings) {
256
+ return bindings.has(name) || /(?:app|server|router|routes)$/i.test(name);
257
+ }
258
+
259
+ /** Extract a statically knowable string path from a call argument. */
260
+ function pathArgValue(node, constants = new Map(), dynamicPlaceholder = true) {
190
261
  if (!node) return null;
191
262
  if (node.type === 'StringLiteral') return node.value;
263
+ if (node.type === 'Identifier') return constants.get(node.name) ?? null;
192
264
  if (node.type === 'TemplateLiteral') {
193
265
  if (node.expressions.length === 0) return node.quasis[0]?.value?.cooked ?? null;
194
- // `/users/${id}` `/users/:param` so a dynamic segment still looks like a path.
195
- return node.quasis
196
- .map((q, i) => (q.value.cooked ?? '') + (i < node.expressions.length ? ':param' : ''))
197
- .join('');
266
+ let value = '';
267
+ for (let i = 0; i < node.quasis.length; i++) {
268
+ value += node.quasis[i]?.value?.cooked ?? '';
269
+ if (i < node.expressions.length) {
270
+ const expression = node.expressions[i];
271
+ const staticValue = expression.type === 'Identifier' ? constants.get(expression.name) : null;
272
+ if (staticValue === undefined || staticValue === null) {
273
+ if (!dynamicPlaceholder) return null;
274
+ value += ':param';
275
+ } else {
276
+ value += staticValue;
277
+ }
278
+ }
279
+ }
280
+ return value;
281
+ }
282
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
283
+ const left = pathArgValue(node.left, constants, false);
284
+ const right = pathArgValue(node.right, constants, false);
285
+ return left === null || right === null ? null : left + right;
198
286
  }
199
287
  return null;
200
288
  }
@@ -216,6 +304,8 @@ export function extractJsRouteCalls(content, filename = 'file.ts') {
216
304
  if (!ok || !ast) return null;
217
305
 
218
306
  const out = [];
307
+ const constants = topLevelStaticStrings(ast);
308
+ const routeBindings = expressRouteBindings(ast);
219
309
  walk(ast, (node) => {
220
310
  if (node.type !== 'CallExpression') return;
221
311
  const callee = node.callee;
@@ -224,13 +314,20 @@ export function extractJsRouteCalls(content, filename = 'file.ts') {
224
314
  if (!prop || prop.type !== 'Identifier') return;
225
315
  const method = prop.name.toLowerCase();
226
316
  if (!HTTP_METHOD_NAMES.has(method)) return;
227
- const path = pathArgValue(node.arguments && node.arguments[0]);
317
+ // A route receiver must be a stable binding (`app.get`, `router.post`, …).
318
+ // Chained HTTP clients such as `request(app).get('/api/items')` also accept
319
+ // URL-shaped first arguments, but they issue requests rather than register
320
+ // routes. Treating their CallExpression receiver as a route used to let test
321
+ // calls contaminate the product API inventory.
322
+ if (!callee.object || callee.object.type !== 'Identifier') return;
323
+ if (!looksLikeRouteReceiver(callee.object.name, routeBindings)) return;
324
+ const path = pathArgValue(node.arguments && node.arguments[0], constants);
228
325
  if (!path || !(path.startsWith('/') || path === '*')) return;
229
326
  // `receiver` is the object the method was called on (`router` in
230
327
  // `router.get(...)`, `app` in `app.get(...)`). Mount-prefix resolution uses
231
328
  // it to apply a same-file `app.use('/api', router)` prefix ONLY to that
232
329
  // router's routes — never to sibling `app.get(...)` calls in the same file.
233
- const receiver = callee.object && callee.object.type === 'Identifier' ? callee.object.name : null;
330
+ const receiver = callee.object.name;
234
331
  out.push({ method: method.toUpperCase(), path, start: node.start ?? 0, receiver });
235
332
  });
236
333
  return out;
@@ -394,13 +491,24 @@ export function extractJsMountsAndImports(content, filename = 'file.ts') {
394
491
  if (!ok || !ast) return null;
395
492
 
396
493
  const imports = {};
494
+ const importSymbols = {};
495
+ const exports = {};
397
496
  const mounts = [];
497
+ const constants = topLevelStaticStrings(ast);
498
+ const routeBindings = expressRouteBindings(ast);
398
499
 
399
500
  walk(ast, (node) => {
400
501
  // import X from 'spec' | import { X } from 'spec' | import * as X from 'spec'
401
502
  if (node.type === 'ImportDeclaration' && node.source && node.source.type === 'StringLiteral') {
402
503
  for (const spec of node.specifiers || []) {
403
- if (spec.local && spec.local.name) imports[spec.local.name] = node.source.value;
504
+ if (spec.local && spec.local.name) {
505
+ imports[spec.local.name] = node.source.value;
506
+ importSymbols[spec.local.name] = spec.type === 'ImportDefaultSpecifier'
507
+ ? 'default'
508
+ : spec.type === 'ImportNamespaceSpecifier'
509
+ ? '*'
510
+ : (spec.imported?.name || spec.imported?.value || spec.local.name);
511
+ }
404
512
  }
405
513
  return;
406
514
  }
@@ -410,23 +518,53 @@ export function extractJsMountsAndImports(content, filename = 'file.ts') {
410
518
  && calleeName(node.init.callee) === 'require'
411
519
  && node.init.arguments[0] && node.init.arguments[0].type === 'StringLiteral') {
412
520
  imports[node.id.name] = node.init.arguments[0].value;
521
+ importSymbols[node.id.name] = 'default';
522
+ return;
523
+ }
524
+ if (node.type === 'ExportDefaultDeclaration' && node.declaration?.type === 'Identifier') {
525
+ exports.default = node.declaration.name;
526
+ return;
527
+ }
528
+ if (node.type === 'ExportNamedDeclaration') {
529
+ if (node.declaration?.type === 'VariableDeclaration') {
530
+ for (const item of node.declaration.declarations || []) {
531
+ if (item.id?.type === 'Identifier') {
532
+ exports[item.id.name] = item.init?.type === 'Identifier' ? item.init.name : item.id.name;
533
+ }
534
+ }
535
+ }
536
+ for (const spec of node.specifiers || []) {
537
+ const exported = spec.exported?.name || spec.exported?.value;
538
+ const local = spec.local?.name || spec.local?.value;
539
+ if (exported && local) exports[exported] = local;
540
+ }
413
541
  return;
414
542
  }
415
- // <x>.use('/prefix', …, <routerIdent>) the prefix is the first string-literal
416
- // arg; the mounted router is the LAST identifier arg (skips middleware).
543
+ // <x>.use('/prefix', middleware, router) and pathless <x>.use(router).
544
+ // Return every identifier candidate; the filesystem-aware caller keeps
545
+ // only candidates whose target contains router registrations or mounts.
417
546
  if (node.type === 'CallExpression' && node.callee && node.callee.type === 'MemberExpression'
418
547
  && !node.callee.computed && node.callee.property && node.callee.property.name === 'use') {
419
548
  const args = node.arguments || [];
420
549
  const first = args[0];
421
- const prefix = first && first.type === 'StringLiteral' ? first.value : null;
422
- if (!prefix || !prefix.startsWith('/')) return;
423
- let ident = null;
424
- for (let i = args.length - 1; i >= 1; i--) {
425
- if (args[i] && args[i].type === 'Identifier') { ident = args[i].name; break; }
550
+ const receiver = node.callee.object?.type === 'Identifier'
551
+ ? node.callee.object.name
552
+ : null;
553
+ if (!receiver || !looksLikeRouteReceiver(receiver, routeBindings)) return;
554
+ const explicitPrefix = pathArgValue(first, constants, false);
555
+ const hasPrefix = typeof explicitPrefix === 'string' && explicitPrefix.startsWith('/');
556
+ if (!hasPrefix && (first?.type !== 'Identifier' || args.length !== 1)) return;
557
+ const prefix = hasPrefix ? explicitPrefix : '';
558
+ const start = hasPrefix ? 1 : 0;
559
+ for (let i = start; i < args.length; i++) {
560
+ if (args[i]?.type === 'Identifier' && args[i].name !== receiver &&
561
+ (imports[args[i].name] || looksLikeRouteReceiver(args[i].name, routeBindings))) {
562
+ mounts.push({ prefix, ident: args[i].name, receiver });
563
+ }
426
564
  }
427
- if (ident) mounts.push({ prefix, ident });
428
565
  }
429
566
  });
430
567
 
431
- return { imports, mounts };
568
+ const routeReceivers = new Set(extractJsRouteCalls(content, filename)?.map(route => route.receiver) || []);
569
+ return { imports, importSymbols, exports, mounts, routeReceivers: [...routeReceivers] };
432
570
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { extname } from 'node:path';
10
- import { getDiffText, getHeadInfo } from '../shared-git.mjs';
10
+ import { getDiffSnapshot, getHeadInfo } from '../shared-git.mjs';
11
11
  import { parseUnifiedDiff } from '../shared-diff.mjs';
12
12
  import { mechanicalSectionsForChanges } from '../shared-sync-scope.mjs';
13
13
  import { projectSpecRegistry } from './spec-registry.mjs';
@@ -64,7 +64,21 @@ function disposition(kind, linked, companionKinds, currentSpecs) {
64
64
  return { evidenceClass: 'unrelated', disposition: 'unrelated_change', confidence: 'high' };
65
65
  }
66
66
 
67
- export function buildReconciliationPlan(projectDir, config = {}, since) {
67
+ function summarize(classifications) {
68
+ const count = key => Object.fromEntries([...classifications.reduce((map, item) => {
69
+ const value = item[key] || 'unknown';
70
+ map.set(value, (map.get(value) || 0) + 1);
71
+ return map;
72
+ }, new Map()).entries()].sort(([a], [b]) => a.localeCompare(b)));
73
+ return {
74
+ total: classifications.length,
75
+ byKind: count('kind'),
76
+ byDisposition: count('disposition'),
77
+ byConfidence: count('confidence'),
78
+ };
79
+ }
80
+
81
+ export function buildReconciliationPlan(projectDir, config = {}, since, runtime = {}) {
68
82
  if (!since) throw new Error('Reconcile requires --since <git-ref>.');
69
83
  const baseRevision = resolveRevision(projectDir, since);
70
84
  const head = getHeadInfo(projectDir);
@@ -80,14 +94,40 @@ export function buildReconciliationPlan(projectDir, config = {}, since) {
80
94
  };
81
95
  }
82
96
  const projection = projectSpecRegistry(projectDir, config);
83
- const diff = parseUnifiedDiff(getDiffText(projectDir, since))
97
+ const readDiff = runtime.getDiffSnapshot || getDiffSnapshot;
98
+ // Use the already verified full commit ID for every subsequent Git call.
99
+ // This removes revision-option ambiguity from user-supplied `--since` text.
100
+ const snapshot = readDiff(projectDir, baseRevision);
101
+ if (snapshot.status !== 'ok') {
102
+ return {
103
+ schemaVersion: 1,
104
+ status: 'BLOCKED',
105
+ since,
106
+ baseRevision,
107
+ revision: head.commit,
108
+ dirty: head.dirty,
109
+ range: {
110
+ status: snapshot.status,
111
+ commitCount: snapshot.commitCount,
112
+ changedFileCount: snapshot.changedFiles.length,
113
+ patchBytes: snapshot.patchBytes,
114
+ limits: snapshot.limits,
115
+ },
116
+ coverage: { status: 'partial', reason: snapshot.reason },
117
+ summary: { total: 0, byKind: {}, byDisposition: {}, byConfidence: {} },
118
+ nodes: [], edges: [], classifications: [], writes: [], issues: projection.issues,
119
+ };
120
+ }
121
+ const diff = parseUnifiedDiff(snapshot.patch)
84
122
  .filter(file => ![file.oldPath, file.newPath].some(path => path?.split('/').includes('.local')));
85
- const changed = diff.map(file => file.newPath || file.oldPath).filter(Boolean);
123
+ const changed = snapshot.changedFiles
124
+ .map(file => file.newPath || file.oldPath)
125
+ .filter(path => path && !path.split('/').includes('.local'));
86
126
  const textByPath = new Map(diff.map(file => [
87
127
  file.newPath || file.oldPath,
88
128
  file.hunks.flatMap(hunk => hunk.lines.filter(line => line.op !== ' ').map(line => line.text)).join('\n'),
89
129
  ]));
90
- const preliminary = changed.map(path => ({
130
+ const preliminary = [...new Set(changed)].sort().map(path => ({
91
131
  path,
92
132
  kind: fileKind(path, projection.registry),
93
133
  specs: directSpecLinks(path, textByPath.get(path) || '', projection.registry),
@@ -124,6 +164,7 @@ export function buildReconciliationPlan(projectDir, config = {}, since) {
124
164
  confidence: 'high',
125
165
  })));
126
166
  const review = classifications.some(item => !['mechanical_fact_refresh', 'unrelated_change'].includes(item.disposition));
167
+ const summary = summarize(classifications);
127
168
  return {
128
169
  schemaVersion: 1,
129
170
  status: projection.issues.length ? 'BLOCKED' : review ? 'REVIEW' : 'READY',
@@ -131,11 +172,20 @@ export function buildReconciliationPlan(projectDir, config = {}, since) {
131
172
  baseRevision,
132
173
  revision: head.commit,
133
174
  dirty: head.dirty,
175
+ range: {
176
+ status: 'ok',
177
+ commitCount: snapshot.commitCount,
178
+ changedFileCount: changed.length,
179
+ patchBytes: snapshot.patchBytes,
180
+ limits: snapshot.limits,
181
+ aggregated: snapshot.commitCount > 50 || changed.length > 100,
182
+ },
134
183
  coverage: { status: 'complete', reason: null },
184
+ summary,
135
185
  nodes,
136
186
  edges,
137
187
  classifications,
138
- writes: mechanicalSections.length ? [{ command: `docguard sync --since ${since} --write`, scope: 'mechanical_fact' }] : [],
188
+ writes: mechanicalSections.length ? [{ command: `docguard sync --since ${baseRevision} --write`, scope: 'mechanical_fact' }] : [],
139
189
  issues: projection.issues,
140
190
  };
141
191
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Deep Route Scanner
3
+ * @req docguard.adoption-workflow-integrity#FR-013
3
4
  * Parses actual route definitions from source code across frameworks.
4
5
  * Supports: Next.js (App Router + Pages), Express, Fastify, Hono, Django, FastAPI
5
6
  *
@@ -87,13 +88,16 @@ export function scanRoutesDeep(dir, stack, docTools, opts = {}) {
87
88
  const seen = new Set();
88
89
  return routes.filter(r => {
89
90
  const key = `${r.method}:${r.path}`;
90
- if (seen.has(key)) return false;
91
- seen.add(key);
92
91
  if (r.file) {
93
92
  const rel = relPosix(dir, resolve(dir, r.file));
94
93
  if (isNonProductPath(rel, cfg)) return false;
95
94
  if (shouldIgnore(rel, cfg)) return false;
96
95
  }
96
+ // Filter non-product and ignored evidence before deduplication. Otherwise a
97
+ // test request can reserve the same method/path key as a real route, get
98
+ // filtered out, and silently remove the product route that appears later.
99
+ if (seen.has(key)) return false;
100
+ seen.add(key);
97
101
  return true;
98
102
  });
99
103
  }
@@ -292,10 +296,10 @@ function scanExpressRoutes(dir, roots = null) {
292
296
  * - router is a LOCAL identifier → the prefix applies only to that file's
293
297
  * routes whose receiver matches (receiver: ident).
294
298
  *
295
- * Known limitations (documented, not silently wrong): transitive composition
296
- * (`app.use('/api', api)` then `api.use('/x', x)` does NOT yield `/api/x`), and
297
- * dynamic mount paths (non-string-literal prefixes) are skipped. Unmounted
298
- * files keep their bare paths — exactly the pre-mount-map behavior.
299
+ * Imported-router mounts are composed transitively, so
300
+ * `app.use('/api', api)` plus `api.use('/x', x)` yields `/api/x`. Dynamic mount
301
+ * paths (non-string-literal prefixes) are skipped. Unmounted files keep their
302
+ * bare paths — exactly the pre-mount-map behavior.
299
303
  */
300
304
  function buildExpressMountMap(files) {
301
305
  const map = new Map();
@@ -303,19 +307,90 @@ function buildExpressMountMap(files) {
303
307
  if (!map.has(absFile)) map.set(absFile, []);
304
308
  map.get(absFile).push({ receiver, prefix });
305
309
  };
310
+ const metadata = new Map();
306
311
  for (const { content, filePath } of files) {
307
312
  const mi = extractJsMountsAndImports(content, filePath);
313
+ if (mi) metadata.set(filePath, mi);
314
+ }
315
+
316
+ const edges = [];
317
+ for (const { filePath } of files) {
318
+ const mi = metadata.get(filePath);
308
319
  if (!mi) continue;
309
- for (const { prefix, ident } of mi.mounts) {
320
+ for (const { prefix, ident, receiver } of mi.mounts) {
310
321
  const spec = mi.imports[ident];
311
322
  if (spec) {
312
323
  const target = resolveLocalImport(filePath, spec);
313
- if (target) add(target, null, prefix);
324
+ const targetMeta = target ? metadata.get(target) : null;
325
+ if (!targetMeta) continue;
326
+ const importedSymbol = mi.importSymbols?.[ident];
327
+ const targetReceiver = importedSymbol === 'default'
328
+ ? (targetMeta.exports?.default ?? null)
329
+ : importedSymbol && importedSymbol !== '*'
330
+ ? (targetMeta.exports?.[importedSymbol] ?? importedSymbol)
331
+ : null;
332
+ const targetRouteReceivers = new Set(targetMeta.routeReceivers || []);
333
+ const targetMountReceivers = new Set((targetMeta.mounts || []).map(mount => mount.receiver));
334
+ if (targetReceiver !== null &&
335
+ !targetRouteReceivers.has(targetReceiver) &&
336
+ !targetMountReceivers.has(targetReceiver)) continue;
337
+ if (targetReceiver === null && targetRouteReceivers.size === 0 && targetMountReceivers.size === 0) continue;
338
+ edges.push({
339
+ from: { file: filePath, receiver },
340
+ to: { file: target, receiver: targetReceiver },
341
+ prefix,
342
+ });
314
343
  } else {
315
- add(filePath, ident, prefix);
344
+ const routeReceivers = new Set(mi.routeReceivers || []);
345
+ const mountReceivers = new Set((mi.mounts || []).map(mount => mount.receiver));
346
+ if (!routeReceivers.has(ident) && !mountReceivers.has(ident)) continue;
347
+ edges.push({
348
+ from: { file: filePath, receiver },
349
+ to: { file: filePath, receiver: ident },
350
+ prefix,
351
+ });
316
352
  }
317
353
  }
318
354
  }
355
+
356
+ const nodeKey = node => `${node.file}\0${node.receiver ?? '*'}`;
357
+ const incoming = new Map();
358
+ const nodes = new Map();
359
+ for (const edge of edges) {
360
+ const key = nodeKey(edge.to);
361
+ nodes.set(key, edge.to);
362
+ if (!incoming.has(key)) incoming.set(key, []);
363
+ incoming.get(key).push(edge);
364
+ }
365
+ const memo = new Map();
366
+ const effectivePrefixes = (node, visiting = new Set(), depth = 0) => {
367
+ const key = nodeKey(node);
368
+ if (memo.has(key)) return memo.get(key);
369
+ if (visiting.has(key) || depth > 32) return [];
370
+ const nodeEdges = incoming.get(key) || [];
371
+ if (nodeEdges.length === 0) return [];
372
+ const nextVisiting = new Set(visiting).add(key);
373
+ const prefixes = new Set();
374
+ for (const edge of nodeEdges) {
375
+ const parentKey = nodeKey(edge.from);
376
+ const parentHasIncoming = (incoming.get(parentKey) || []).length > 0;
377
+ const parentPrefixes = parentHasIncoming
378
+ ? effectivePrefixes(edge.from, nextVisiting, depth + 1)
379
+ : [''];
380
+ for (const parentPrefix of parentPrefixes) {
381
+ prefixes.add(joinRoutePath(parentPrefix, edge.prefix));
382
+ if (prefixes.size >= 256) break;
383
+ }
384
+ if (prefixes.size >= 256) break;
385
+ }
386
+ const resolved = [...prefixes];
387
+ memo.set(key, resolved);
388
+ return resolved;
389
+ };
390
+
391
+ for (const node of nodes.values()) {
392
+ for (const prefix of effectivePrefixes(node)) add(node.file, node.receiver, prefix);
393
+ }
319
394
  return map;
320
395
  }
321
396
 
@@ -214,6 +214,35 @@ export function readSpecRegistry(projectDir) {
214
214
  return loaded;
215
215
  }
216
216
 
217
+ function trackedAndClean(projectDir, path) {
218
+ const tracked = spawnSync('git', ['ls-files', '--error-unmatch', '--', path], {
219
+ cwd: projectDir, stdio: 'ignore',
220
+ });
221
+ if (tracked.status !== 0) return false;
222
+ const working = spawnSync('git', ['diff', '--quiet', '--', path], { cwd: projectDir, stdio: 'ignore' });
223
+ const staged = spawnSync('git', ['diff', '--cached', '--quiet', '--', path], { cwd: projectDir, stdio: 'ignore' });
224
+ return working.status === 0 && staged.status === 0;
225
+ }
226
+
227
+ /** Reviewed lifecycle that may defer implementation-time traceability. */
228
+ export function trustedSpecLifecycleIndex(projectDir) {
229
+ const trusted = new Map();
230
+ const loaded = readSpecRegistry(projectDir);
231
+ if (loaded.error || loaded.value?.schemaVersion !== SPEC_REGISTRY_SCHEMA_VERSION) return trusted;
232
+ if (!trackedAndClean(projectDir, SPEC_REGISTRY_PATH)) return trusted;
233
+ for (const entry of loaded.value.specs) {
234
+ if (!entry?.specId || !entry?.path || !trackedAndClean(projectDir, entry.path)) continue;
235
+ let content;
236
+ try { content = readFileSync(resolve(projectDir, entry.path), 'utf8'); } catch { continue; }
237
+ if (parseSpecId(content) !== entry.specId) continue;
238
+ const artifact = entry.observed?.artifacts?.find(item => item.path === entry.path);
239
+ if (!artifact || artifact.digest !== digest(content)) continue;
240
+ if (entry.reviewed?.lifecycle?.context !== 'current' || entry.reviewed.lifecycle.storage !== 'working_tree') continue;
241
+ trusted.set(`${entry.specId}\0${entry.path}`, entry.reviewed.lifecycle);
242
+ }
243
+ return trusted;
244
+ }
245
+
217
246
  function taskCompletion(path) {
218
247
  if (!path || !existsSync(path)) return { checked: 0, total: 0 };
219
248
  const content = readFileSync(path, 'utf8');
@@ -8,6 +8,7 @@
8
8
  * "X commits since last doc update" counter — silently hiding drift.
9
9
  *
10
10
  * Zero NPM dependencies. Pure Node.js built-ins.
11
+ * @implements docguard.adoption-workflow-integrity#FR-004
11
12
  */
12
13
 
13
14
  import { execFileSync, execSync } from 'node:child_process';
@@ -172,6 +173,103 @@ export function getDiffText(dir, ref = 'HEAD~1', pathspec = null) {
172
173
  }
173
174
  }
174
175
 
176
+ const DEFAULT_DIFF_LIMITS = Object.freeze({
177
+ maxPatchBytes: 5 * 1024 * 1024,
178
+ maxInventoryBytes: 5 * 1024 * 1024,
179
+ maxChangedFiles: 5_000,
180
+ timeoutMs: 30_000,
181
+ });
182
+
183
+ function diffFailure(error) {
184
+ if (error?.code === 'ENOBUFS' || /maxBuffer/i.test(error?.message || '')) return 'too-large';
185
+ if (error?.code === 'ETIMEDOUT' || error?.signal === 'SIGTERM') return 'timeout';
186
+ return 'error';
187
+ }
188
+
189
+ function parseNameStatus(raw) {
190
+ const tokens = raw.toString('utf8').split('\0');
191
+ if (tokens.at(-1) === '') tokens.pop();
192
+ const files = [];
193
+ for (let i = 0; i < tokens.length;) {
194
+ const status = tokens[i++];
195
+ if (!status) continue;
196
+ if (/^[RC]/.test(status)) {
197
+ const oldPath = tokens[i++];
198
+ const newPath = tokens[i++];
199
+ if (oldPath && newPath) files.push({ status, oldPath, newPath });
200
+ } else {
201
+ const path = tokens[i++];
202
+ if (path) files.push({ status, oldPath: status.startsWith('D') ? path : null, newPath: status.startsWith('D') ? null : path });
203
+ }
204
+ }
205
+ return files;
206
+ }
207
+
208
+ /**
209
+ * Read a reconciliation diff without converting overflow, timeout, or Git
210
+ * failure into an empty successful range. The path inventory is independent
211
+ * from patch text so binary files, deletes, and renames remain visible.
212
+ */
213
+ export function getDiffSnapshot(dir, ref = 'HEAD~1', options = {}) {
214
+ const limits = { ...DEFAULT_DIFF_LIMITS, ...options };
215
+ const base = {
216
+ status: 'error', ref, commitCount: null, changedFiles: [], patch: '',
217
+ patchBytes: 0, limits, reason: null,
218
+ };
219
+ let inventory;
220
+ try {
221
+ inventory = execFileSync('git', [
222
+ 'diff', '--name-status', '-z', '--no-color', '--no-ext-diff', '--no-textconv', ref, 'HEAD',
223
+ ], {
224
+ cwd: dir, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'],
225
+ maxBuffer: limits.maxInventoryBytes, timeout: limits.timeoutMs,
226
+ });
227
+ } catch (error) {
228
+ const status = diffFailure(error);
229
+ return { ...base, status, reason: status === 'too-large'
230
+ ? 'Changed-file inventory exceeded its byte budget.'
231
+ : status === 'timeout' ? 'Changed-file inventory timed out.' : 'Changed-file inventory could not be read.' };
232
+ }
233
+ const changedFiles = parseNameStatus(inventory);
234
+ if (changedFiles.length > limits.maxChangedFiles) {
235
+ return { ...base, status: 'too-large', changedFiles: changedFiles.slice(0, limits.maxChangedFiles),
236
+ reason: `Changed-file inventory exceeded the ${limits.maxChangedFiles}-path budget.` };
237
+ }
238
+ let commitCount;
239
+ try {
240
+ commitCount = Number(execFileSync('git', ['rev-list', '--count', `${ref}..HEAD`], {
241
+ cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: limits.timeoutMs,
242
+ }).trim());
243
+ if (!Number.isSafeInteger(commitCount) || commitCount < 0) throw new Error('invalid commit count');
244
+ } catch (error) {
245
+ const status = diffFailure(error);
246
+ return { ...base, status, changedFiles, reason: status === 'timeout'
247
+ ? 'Commit-range inspection timed out.' : 'Commit range could not be inspected.' };
248
+ }
249
+ let patch;
250
+ try {
251
+ patch = execFileSync('git', [
252
+ 'diff', '--no-color', '--no-ext-diff', '--no-textconv', ref, 'HEAD',
253
+ ], {
254
+ cwd: dir, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'],
255
+ maxBuffer: limits.maxPatchBytes, timeout: limits.timeoutMs,
256
+ });
257
+ } catch (error) {
258
+ const status = diffFailure(error);
259
+ return { ...base, status, commitCount, changedFiles, reason: status === 'too-large'
260
+ ? 'Patch exceeded its byte budget; use a newer --since revision.'
261
+ : status === 'timeout' ? 'Patch generation timed out; use a newer --since revision.' : 'Patch could not be read.' };
262
+ }
263
+ return {
264
+ ...base,
265
+ status: 'ok',
266
+ commitCount,
267
+ changedFiles,
268
+ patch: patch.toString('utf8'),
269
+ patchBytes: patch.length,
270
+ };
271
+ }
272
+
175
273
  /**
176
274
  * Read a file's contents AS OF a given revision (e.g. the commit where a doc
177
275
  * was last touched), following the `<rev>:<path>` git addressing. Returns null
@@ -61,7 +61,7 @@ const ALWAYS_REJECT_PATH_RE =
61
61
  */
62
62
  export const DEFAULT_DETECTION_IGNORE_DIRS = new Set([
63
63
  'fixtures', '__fixtures__', 'test-fixtures', 'testfixtures', 'testdata',
64
- 'test', 'tests', '__tests__', 'spec', 'specs', '__mocks__', 'mocks',
64
+ 'test', 'tests', '__tests__', 'test-helpers', 'spec', 'specs', '__mocks__', 'mocks',
65
65
  'examples', 'example', 'sample', 'samples',
66
66
  ]);
67
67