docguard-cli 0.40.5 → 0.41.1
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/CHANGELOG.md +3246 -0
- package/README.md +25 -16
- package/cli/assessment.mjs +94 -0
- package/cli/commands/ci.mjs +15 -5
- package/cli/commands/diagnose.mjs +20 -13
- package/cli/commands/fix.mjs +14 -45
- package/cli/commands/guard.mjs +53 -25
- package/cli/commands/hooks.mjs +51 -10
- package/cli/commands/init.mjs +15 -0
- package/cli/commands/reconcile.mjs +10 -3
- package/cli/commands/report.mjs +5 -1
- package/cli/commands/score.mjs +2 -1
- package/cli/commands/specs.mjs +15 -4
- package/cli/commands/upgrade.mjs +4 -1
- package/cli/commands/verify.mjs +9 -2
- package/cli/commands/watch.mjs +3 -2
- package/cli/config.mjs +23 -0
- package/cli/evidence/adapters.mjs +14 -0
- package/cli/evidence/manifest.mjs +15 -0
- package/cli/evidence/python-literal.mjs +304 -0
- package/cli/findings.mjs +17 -3
- package/cli/scanners/instruction-audit.mjs +88 -11
- package/cli/scanners/js-ast.mjs +156 -18
- package/cli/scanners/reconciliation.mjs +56 -6
- package/cli/scanners/routes.mjs +84 -9
- package/cli/scanners/spec-registry.mjs +29 -0
- package/cli/shared-git.mjs +98 -0
- package/cli/shared-ignore.mjs +1 -1
- package/cli/shared.mjs +30 -1
- package/cli/validators/api-doc-smells.mjs +2 -2
- package/cli/validators/api-surface.mjs +4 -9
- package/cli/validators/diff-suspicion.mjs +3 -2
- package/cli/validators/docs-sync.mjs +45 -29
- package/cli/validators/environment.mjs +64 -6
- package/cli/validators/metrics-consistency.mjs +52 -11
- package/cli/validators/reference-existence.mjs +4 -2
- package/cli/validators/security.mjs +37 -12
- package/cli/validators/spec-registry.mjs +10 -7
- package/cli/validators/todo-tracking.mjs +31 -11
- package/cli/validators/traceability.mjs +29 -4
- package/cli/writers/junit.mjs +3 -3
- package/cli/writers/sarif.mjs +13 -9
- package/docs/configuration.md +12 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +1 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +2 -1
- package/schemas/docguard-config.schema.json +15 -1
- package/schemas/docguard-evidence.schema.json +12 -0
- package/templates/ci/github-actions.yml +1 -1
- package/templates/evidence-manifest.json +16 -0
package/cli/scanners/js-ast.mjs
CHANGED
|
@@ -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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
.
|
|
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
|
-
|
|
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
|
|
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)
|
|
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',
|
|
416
|
-
//
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 ${
|
|
188
|
+
writes: mechanicalSections.length ? [{ command: `docguard sync --since ${baseRevision} --write`, scope: 'mechanical_fact' }] : [],
|
|
139
189
|
issues: projection.issues,
|
|
140
190
|
};
|
|
141
191
|
}
|
package/cli/scanners/routes.mjs
CHANGED
|
@@ -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
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/cli/shared-git.mjs
CHANGED
|
@@ -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
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -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
|
|