docguard-cli 0.34.9 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -15
- package/cli/commands/agent.mjs +27 -6
- package/cli/commands/ci.mjs +3 -0
- package/cli/commands/diagnose.mjs +8 -2
- package/cli/commands/feedback.mjs +83 -89
- package/cli/commands/fix.mjs +4 -0
- package/cli/commands/generate.mjs +3 -0
- package/cli/commands/guard.mjs +37 -20
- package/cli/commands/hooks.mjs +61 -40
- package/cli/commands/init.mjs +51 -5
- package/cli/commands/memory.mjs +29 -15
- package/cli/commands/report.mjs +12 -7
- package/cli/commands/score.mjs +39 -19
- package/cli/commands/sync.mjs +2 -0
- package/cli/commands/watch.mjs +113 -70
- package/cli/config.mjs +6 -3
- package/cli/docguard.mjs +12 -4
- package/cli/findings.mjs +13 -13
- package/cli/scanners/memory-plan.mjs +279 -134
- package/cli/scanners/project-type.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +176 -26
- package/cli/shared-diff.mjs +22 -1
- package/cli/shared-doc-roles.mjs +59 -0
- package/cli/shared-ignore.mjs +15 -2
- package/cli/shared-source.mjs +223 -1
- package/cli/validator-coverage.mjs +20 -0
- package/cli/validators/api-surface.mjs +94 -70
- package/cli/validators/architecture.mjs +19 -5
- package/cli/validators/diff-suspicion.mjs +45 -9
- package/cli/validators/docs-coverage.mjs +6 -5
- package/cli/validators/docs-diff.mjs +51 -7
- package/cli/validators/environment.mjs +3 -2
- package/cli/validators/freshness.mjs +140 -83
- package/cli/validators/schema-sync.mjs +3 -2
- package/cli/validators/security.mjs +58 -23
- package/cli/validators/structure.mjs +3 -1
- package/cli/validators/test-spec.mjs +3 -2
- package/cli/validators/todo-tracking.mjs +61 -28
- package/cli/validators/traceability.mjs +152 -38
- package/docs/configuration.md +41 -0
- package/extensions/spec-kit-docguard/extension.yml +2 -3
- 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/extensions.yml +1 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +43 -1
- package/templates/ci/github-actions.yml +51 -11
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Coverage describes what ran; a successful gate is not exhaustive assurance. */
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolveDocRole, docRolePath } from './shared-doc-roles.mjs';
|
|
4
|
+
const PREREQUISITES = { testSpec: 'testSpec', environment: 'environment', apiSurface: 'apiReference', architecture: 'architecture' };
|
|
5
|
+
const STATES = new Set(['checked', 'partial', 'disabled', 'not-applicable', 'missing-prerequisite', 'unsupported', 'no-matches', 'error']);
|
|
6
|
+
export function describeCheckCoverage(projectDir, config, result) {
|
|
7
|
+
if (result.status === 'skipped') return { status: 'disabled', reason: 'Disabled by the effective configuration or selected command scope.' };
|
|
8
|
+
if (result.note?.startsWith('declared N/A')) return { status: 'not-applicable', reason: result.note };
|
|
9
|
+
if (STATES.has(result.applicability?.status) && typeof result.applicability.reason === 'string') return result.applicability;
|
|
10
|
+
if (result.total > 0) return { status: 'checked', reason: 'Completed the declared checks; this does not establish exhaustive language, framework, or semantic coverage.' };
|
|
11
|
+
const role = PREREQUISITES[result.key];
|
|
12
|
+
if (role && !existsSync(resolveDocRole(projectDir, config, role))) return { status: 'missing-prerequisite', reason: 'No document available for role ' + role + ': ' + docRolePath(config, role) };
|
|
13
|
+
return { status: 'no-matches', reason: result.note || 'No checkable inputs matched this detector. This does not establish that the project has no relevant behavior.' };
|
|
14
|
+
}
|
|
15
|
+
export function summarizeCheckCoverage(results) {
|
|
16
|
+
const counts = Object.fromEntries([...STATES].map(status => [status, 0]));
|
|
17
|
+
for (const result of results) counts[result.applicability.status]++;
|
|
18
|
+
return { counts, limitations: results.filter(r => r.applicability.status !== 'checked').map(r => ({ key: r.key, name: r.name, ...r.applicability })),
|
|
19
|
+
limitation: 'Check coverage is distinct from document inventory and factual accuracy. Unsupported or unmatched inputs remain unverified.' };
|
|
20
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* API-Surface Validator — Detects drift between the documented API surface
|
|
3
4
|
* (docs-canonical/API-REFERENCE.md) and the project's actual API surface.
|
|
@@ -9,18 +10,16 @@
|
|
|
9
10
|
* 1. OpenAPI spec (sourceRoot/workspace-aware) → high confidence
|
|
10
11
|
* 2. Monorepo-aware code route scan → lower confidence (warn only)
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - present-but-undocumented → WARNING (a real route missing from the docs).
|
|
13
|
+
* OpenAPI remains the contract authority, not proof of runtime absence.
|
|
14
|
+
* Contract omissions are errors with independent code evidence. Removal needs
|
|
15
|
+
* both a contract omission and a nonempty code scan without the endpoint;
|
|
16
|
+
* code-present and unknown-coverage omissions stay review-only.
|
|
17
17
|
*
|
|
18
18
|
* Also flags MULTIPLE OpenAPI specs in the repo that disagree on their endpoint
|
|
19
19
|
* set (e.g. a served spec and a generated spec that have diverged).
|
|
20
20
|
*
|
|
21
21
|
* Returns { errors, warnings, passed, total, fixes, authoritativeSpec } — the
|
|
22
|
-
* `fixes` array
|
|
23
|
-
* `docguard fix --write` can apply without an LLM.
|
|
22
|
+
* `fixes` array contains only omissions corroborated by a nonempty code scan.
|
|
24
23
|
*/
|
|
25
24
|
|
|
26
25
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -33,7 +32,7 @@ import { relPosix } from '../shared-ignore.mjs';
|
|
|
33
32
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
34
33
|
|
|
35
34
|
const MAX_REPORTED = 15;
|
|
36
|
-
|
|
35
|
+
|
|
37
36
|
|
|
38
37
|
/** Walk up from a dir to the nearest enclosing package.json directory. */
|
|
39
38
|
function nearestPackageDir(projectDir, startDir) {
|
|
@@ -180,10 +179,11 @@ export function resolveApiSurface(projectDir, config) {
|
|
|
180
179
|
* Compute API-surface drift in a structured, reusable form.
|
|
181
180
|
* Used by the validator AND by `docguard fix --write`.
|
|
182
181
|
* @returns {{ applicable, confidence, source, documented, documentedButAbsent,
|
|
183
|
-
* presentButUndocumented, matched }}
|
|
182
|
+
* contractMismatches, presentButUndocumented, matched }}
|
|
184
183
|
*/
|
|
185
184
|
export function computeApiSurfaceDrift(projectDir, config) {
|
|
186
|
-
const
|
|
185
|
+
const API_DOC = docRolePath(config, 'apiReference');
|
|
186
|
+
const apiDocPath = resolveDocRole(projectDir, config, 'apiReference');
|
|
187
187
|
if (!existsSync(apiDocPath)) {
|
|
188
188
|
return { applicable: false, confidence: 'none', source: null,
|
|
189
189
|
documented: [], documentedButAbsent: [], presentButUndocumented: [], matched: [] };
|
|
@@ -198,12 +198,34 @@ export function computeApiSurfaceDrift(projectDir, config) {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
const cmp = compareEndpoints(documented, surface.endpoints);
|
|
201
|
+
// The legacy writer treats spec-confidence documentedButAbsent as deletable.
|
|
202
|
+
// Keep contract omissions separate from their removable subset. An omission
|
|
203
|
+
// alone cannot authorize removal; code-present and unknown results are excluded.
|
|
204
|
+
let contractMismatches = [];
|
|
205
|
+
if (surface.confidence === 'spec' && cmp.documentedButAbsent.length) {
|
|
206
|
+
const framework = detectFramework(projectDir, config);
|
|
207
|
+
const routes = scanRoutesDeep(projectDir, { framework }, {}, { config });
|
|
208
|
+
const routeKeys = new Set(routes.map(r => endpointKey(r.method, r.path)));
|
|
209
|
+
contractMismatches = cmp.documentedButAbsent.map(endpoint => ({
|
|
210
|
+
...endpoint,
|
|
211
|
+
authority: { kind: 'openapi', source: surface.source, status: 'not-declared', confidence: 'high' },
|
|
212
|
+
codeEvidence: {
|
|
213
|
+
status: routeKeys.has(endpointKey(endpoint.method, endpoint.path))
|
|
214
|
+
? 'present' : routes.length ? 'not-found' : 'unknown',
|
|
215
|
+
confidence: 'low',
|
|
216
|
+
},
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
201
219
|
return {
|
|
202
220
|
applicable: true,
|
|
203
221
|
confidence: surface.confidence,
|
|
204
222
|
source: surface.source,
|
|
205
223
|
documented,
|
|
206
|
-
documentedButAbsent:
|
|
224
|
+
documentedButAbsent: surface.confidence === 'spec'
|
|
225
|
+
? contractMismatches.filter(e => e.codeEvidence.status === 'not-found')
|
|
226
|
+
.map(({ method, path }) => ({ method, path }))
|
|
227
|
+
: cmp.documentedButAbsent,
|
|
228
|
+
contractMismatches,
|
|
207
229
|
presentButUndocumented: cmp.presentButUndocumented,
|
|
208
230
|
matched: cmp.matched,
|
|
209
231
|
};
|
|
@@ -251,11 +273,10 @@ export function computeSpecVsRouteDrift(projectDir, config) {
|
|
|
251
273
|
};
|
|
252
274
|
}
|
|
253
275
|
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// errors/warnings arrays from the same findings; `fixes` and
|
|
257
|
-
// `authoritativeSpec` are preserved.
|
|
276
|
+
// Findings retain the authority behind each mismatch; contract authority
|
|
277
|
+
// must never be presented as proof that no implementation exists.
|
|
258
278
|
export function validateApiSurface(projectDir, config) {
|
|
279
|
+
const API_DOC = docRolePath(config, 'apiReference');
|
|
259
280
|
const findings = [];
|
|
260
281
|
const fixes = [];
|
|
261
282
|
const trim = (arr) => {
|
|
@@ -365,66 +386,69 @@ export function validateApiSurface(projectDir, config) {
|
|
|
365
386
|
};
|
|
366
387
|
}
|
|
367
388
|
|
|
368
|
-
const { documentedButAbsent, presentButUndocumented, matched, confidence, source } = drift;
|
|
369
|
-
const total = matched.length + documentedButAbsent.length + presentButUndocumented.length + specRouteTotal;
|
|
389
|
+
const { documentedButAbsent, contractMismatches = [], presentButUndocumented, matched, confidence, source } = drift;
|
|
390
|
+
const total = matched.length + (confidence === 'spec' ? contractMismatches.length : documentedButAbsent.length) + presentButUndocumented.length + specRouteTotal;
|
|
370
391
|
const passed = matched.length + specRoutePassed;
|
|
371
392
|
|
|
372
|
-
//
|
|
373
|
-
if (
|
|
374
|
-
const { shown, extra } = trim(
|
|
393
|
+
// Spec omissions stay actionable without masquerading as runtime absence.
|
|
394
|
+
if (contractMismatches.length) {
|
|
395
|
+
const { shown, extra } = trim(contractMismatches);
|
|
375
396
|
for (const e of shown) {
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
code
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
} else {
|
|
387
|
-
findings.push(mkFinding({
|
|
388
|
-
code: 'API004',
|
|
389
|
-
validator: 'apiSurface',
|
|
390
|
-
severity: 'warn',
|
|
391
|
-
// The "[code-scan — verify]" suffix marks this as heuristic-only:
|
|
392
|
-
// the route scanner may simply not see the endpoint's registration.
|
|
393
|
-
confidence: 'low',
|
|
394
|
-
message: `${msg} [code-scan — verify]`,
|
|
397
|
+
const codeDescription = e.codeEvidence.status === 'present'
|
|
398
|
+
? 'A matching route was extracted from code.'
|
|
399
|
+
: e.codeEvidence.status === 'not-found'
|
|
400
|
+
? 'No matching route was extracted from code; scanner coverage may be incomplete.'
|
|
401
|
+
: 'Code presence is unknown: no routes were extracted.';
|
|
402
|
+
findings.push({
|
|
403
|
+
...mkFinding({
|
|
404
|
+
code: 'API004', validator: 'apiSurface', severity: 'error',
|
|
405
|
+
confidence: 'high', // certain contract omission, NOT certain code absence
|
|
406
|
+
message: `Documented endpoint missing from OpenAPI contract (${source}): ${e.method} ${e.path} (${API_DOC}). ${codeDescription}`,
|
|
395
407
|
location: API_DOC,
|
|
396
|
-
suggestion:
|
|
397
|
-
|
|
398
|
-
|
|
408
|
+
suggestion: e.codeEvidence.status === 'not-found'
|
|
409
|
+
? { kind: 'fix', text: 'Remove the endpoint omitted from the contract and not found by the code scan; verify scanner coverage before applying', command: 'docguard fix --write' }
|
|
410
|
+
: { kind: 'review', text: e.codeEvidence.status === 'present'
|
|
411
|
+
? 'Reconcile the implementation with the intended contract; update OpenAPI if the route is intended. Preserve the documented endpoint during review.'
|
|
412
|
+
: 'Reconcile the contract and documentation with the intended API. Verify implementation coverage and whether the endpoint was removed before editing documentation.' },
|
|
413
|
+
}),
|
|
414
|
+
evidence: { authority: e.authority, code: e.codeEvidence },
|
|
415
|
+
});
|
|
399
416
|
}
|
|
400
417
|
if (extra > 0) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
message: tail,
|
|
408
|
-
location: API_DOC,
|
|
409
|
-
suggestion: { kind: 'fix', text: 'Remove the dead endpoints from the doc', command: 'docguard fix --write' },
|
|
410
|
-
}));
|
|
411
|
-
} else {
|
|
412
|
-
findings.push(mkFinding({
|
|
413
|
-
code: 'API004',
|
|
414
|
-
validator: 'apiSurface',
|
|
415
|
-
severity: 'warn',
|
|
416
|
-
confidence: 'low',
|
|
417
|
-
message: tail,
|
|
418
|
-
location: API_DOC,
|
|
419
|
-
suggestion: { kind: 'review', text: 'Verify each documented endpoint against the code, then prune the doc' },
|
|
420
|
-
}));
|
|
421
|
-
}
|
|
418
|
+
findings.push(mkFinding({
|
|
419
|
+
code: 'API004', validator: 'apiSurface', severity: 'error',
|
|
420
|
+
message: `…and ${extra} more documented endpoint(s) missing from OpenAPI contract (${source}); code presence requires individual review`,
|
|
421
|
+
location: API_DOC,
|
|
422
|
+
suggestion: { kind: 'review', text: 'Reconcile each contract omission with code and intended behavior before editing documentation' },
|
|
423
|
+
}));
|
|
422
424
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Only the contract-and-code corroborated subset reaches mechanical writes.
|
|
428
|
+
if (confidence === 'spec') {
|
|
429
|
+
for (const e of documentedButAbsent) {
|
|
430
|
+
fixes.push({ type: 'remove-endpoint', method: e.method, path: e.path, doc: API_DOC });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Without a spec, a negative scan remains a low-confidence review candidate.
|
|
435
|
+
if (confidence !== 'spec' && documentedButAbsent.length) {
|
|
436
|
+
const { shown, extra } = trim(documentedButAbsent);
|
|
437
|
+
for (const e of shown) {
|
|
438
|
+
findings.push(mkFinding({
|
|
439
|
+
code: 'API004', validator: 'apiSurface', severity: 'warn', confidence: 'low',
|
|
440
|
+
message: `Documented endpoint not found in code: ${e.method} ${e.path} (${API_DOC}) [code-scan — verify]`,
|
|
441
|
+
location: API_DOC,
|
|
442
|
+
suggestion: { kind: 'review', text: 'Verify the endpoint really is gone from the code, then remove it from the doc' },
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
if (extra > 0) {
|
|
446
|
+
findings.push(mkFinding({
|
|
447
|
+
code: 'API004', validator: 'apiSurface', severity: 'warn', confidence: 'low',
|
|
448
|
+
message: `…and ${extra} more documented endpoint(s) not found by the code scanner`,
|
|
449
|
+
location: API_DOC,
|
|
450
|
+
suggestion: { kind: 'review', text: 'Verify each documented endpoint against the code before editing documentation' },
|
|
451
|
+
}));
|
|
428
452
|
}
|
|
429
453
|
}
|
|
430
454
|
|
|
@@ -436,7 +460,7 @@ export function validateApiSurface(projectDir, config) {
|
|
|
436
460
|
code: 'API005',
|
|
437
461
|
validator: 'apiSurface',
|
|
438
462
|
severity: 'warn',
|
|
439
|
-
message: `Undocumented endpoint in code: ${e.method} ${e.path} — add it to ${API_DOC}`,
|
|
463
|
+
message: `Undocumented endpoint in ${confidence === 'spec' ? `OpenAPI contract (${source})` : 'code'}: ${e.method} ${e.path} — add it to ${API_DOC}`,
|
|
440
464
|
location: API_DOC,
|
|
441
465
|
suggestion: { kind: 'fix', text: `Document the endpoint in ${API_DOC}` },
|
|
442
466
|
}));
|
|
@@ -446,7 +470,7 @@ export function validateApiSurface(projectDir, config) {
|
|
|
446
470
|
code: 'API005',
|
|
447
471
|
validator: 'apiSurface',
|
|
448
472
|
severity: 'warn',
|
|
449
|
-
message: `…and ${extra} more undocumented endpoint(s) in code`,
|
|
473
|
+
message: `…and ${extra} more undocumented endpoint(s) in ${confidence === 'spec' ? `OpenAPI contract (${source})` : 'code'}`,
|
|
450
474
|
location: API_DOC,
|
|
451
475
|
suggestion: { kind: 'fix', text: `Document the remaining endpoints in ${API_DOC}` },
|
|
452
476
|
}));
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
19
19
|
import { resolve, join, extname, relative, dirname, basename } from 'node:path';
|
|
20
|
-
import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
20
|
+
import { shouldIgnore, isNonProductPath, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
21
21
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
22
|
+
import { resolveDocRole } from '../shared-doc-roles.mjs';
|
|
22
23
|
|
|
23
24
|
const IGNORE_DIRS = new Set([
|
|
24
25
|
'node_modules', '.git', '.next', 'dist', 'build',
|
|
@@ -32,10 +33,12 @@ const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx']);
|
|
|
32
33
|
// byte-identical to the legacy strings — resultFromFindings derives the
|
|
33
34
|
// errors/warnings arrays from the same findings array (acc), which the
|
|
34
35
|
// helpers below mutate in place.
|
|
35
|
-
export function validateArchitecture(projectDir, config) {
|
|
36
|
+
export function validateArchitecture(projectDir, config = {}) {
|
|
36
37
|
const acc = { findings: [], passed: 0, total: 0 };
|
|
38
|
+
let applicability = { status: 'checked', reason: 'JS/TS static import graph inspected; arbitrary runtime dependencies are not resolved' };
|
|
37
39
|
const compose = () => ({
|
|
38
40
|
name: 'architecture',
|
|
41
|
+
applicability,
|
|
39
42
|
...resultFromFindings(acc.findings, { passed: acc.passed, total: acc.total }),
|
|
40
43
|
});
|
|
41
44
|
|
|
@@ -47,6 +50,14 @@ export function validateArchitecture(projectDir, config) {
|
|
|
47
50
|
|
|
48
51
|
// ── 2. Auto-detect import graph ──
|
|
49
52
|
const importGraph = buildImportGraph(projectDir, config);
|
|
53
|
+
if (importGraph.unsupportedFiles.length > 0) {
|
|
54
|
+
applicability = {
|
|
55
|
+
status: importGraph.files.length > 0 ? 'partial' : 'unsupported',
|
|
56
|
+
reason: 'Python import graph analysis is unsupported: relative imports, package paths, src-layout resolution and dynamic imports are not verified; JS/TS findings, when present, are retained',
|
|
57
|
+
};
|
|
58
|
+
} else if (importGraph.files.length === 0) {
|
|
59
|
+
applicability = { status: 'not-applicable', reason: 'No supported JS/TS source files found for import graph analysis' };
|
|
60
|
+
}
|
|
50
61
|
if (importGraph.files.length === 0) return compose();
|
|
51
62
|
|
|
52
63
|
// ── 3. Detect circular dependencies ──
|
|
@@ -67,8 +78,8 @@ export function validateArchitecture(projectDir, config) {
|
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
// ── 4. Check layer boundaries from ARCHITECTURE.md ──
|
|
70
|
-
const archPath =
|
|
71
|
-
if (existsSync(archPath)) {
|
|
81
|
+
const archPath = resolveDocRole(projectDir, config, 'architecture');
|
|
82
|
+
if (archPath && existsSync(archPath)) {
|
|
72
83
|
const archContent = readFileSync(archPath, 'utf-8');
|
|
73
84
|
const declaredLayers = parseLayerBoundaries(archContent);
|
|
74
85
|
|
|
@@ -151,9 +162,12 @@ function validateConfigLayers(projectDir, config, layers, acc) {
|
|
|
151
162
|
* @returns {{files: string[], edges: {from,to,dynamic}[], fileMap: Map<string,string[]>}}
|
|
152
163
|
*/
|
|
153
164
|
export function buildImportGraph(projectDir, config) {
|
|
154
|
-
const graph = { files: [], edges: [], fileMap: new Map() };
|
|
165
|
+
const graph = { files: [], edges: [], fileMap: new Map(), unsupportedFiles: [] };
|
|
155
166
|
|
|
156
167
|
const allFiles = getFilesRecursive(projectDir, config, projectDir);
|
|
168
|
+
graph.unsupportedFiles = allFiles
|
|
169
|
+
.filter(f => extname(f) === '.py' && !isNonProductPath(relative(projectDir, f).replace(/\\/g, '/'), config))
|
|
170
|
+
.map(f => relative(projectDir, f));
|
|
157
171
|
const codeFiles = allFiles.filter(f => CODE_EXTENSIONS.has(extname(f)));
|
|
158
172
|
|
|
159
173
|
for (const file of codeFiles) {
|
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
22
23
|
import { resolve, basename } from 'node:path';
|
|
23
|
-
import { isGitRepo, getDiffText } from '../shared-git.mjs';
|
|
24
|
+
import { isGitRepo, getDiffText, fileContentAtRev } from '../shared-git.mjs';
|
|
24
25
|
import { parseUnifiedDiff, removedTokens, tokenize, tokenOverlap } from '../shared-diff.mjs';
|
|
26
|
+
import { parseJsTs } from '../scanners/js-ast.mjs';
|
|
25
27
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
26
28
|
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
27
29
|
|
|
@@ -65,9 +67,8 @@ function indexDocs(projectDir) {
|
|
|
65
67
|
docs.set(name, { lines: content.split('\n'), tokens: tokenize(content) });
|
|
66
68
|
} catch { /* skip unreadable */ }
|
|
67
69
|
};
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
for (const doc of listCanonicalDocs(projectDir)) add(basename(doc.rel), doc.abs);
|
|
70
|
+
// Preserve paths: nested documents with identical basenames are distinct.
|
|
71
|
+
for (const doc of listCanonicalDocs(projectDir)) add(doc.rel, doc.abs);
|
|
71
72
|
// Agent-instruction files are documentation too — they routinely name code.
|
|
72
73
|
for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
73
74
|
const p = resolve(projectDir, agent);
|
|
@@ -94,6 +95,39 @@ function referenceKind(docLines, file) {
|
|
|
94
95
|
return null;
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// Compare full snapshots: indentation cannot be inferred from isolated hunks.
|
|
99
|
+
// Isolated Python parses stdin data; project code is never imported/executed.
|
|
100
|
+
// Missing interpreters or parse failures retain the token-based evidence.
|
|
101
|
+
function samePythonAst(projectDir, ref, file) {
|
|
102
|
+
const before = fileContentAtRev(projectDir, ref, file.oldPath);
|
|
103
|
+
const after = fileContentAtRev(projectDir, 'HEAD', file.newPath);
|
|
104
|
+
if (before === null || after === null) return false;
|
|
105
|
+
const script = 'import ast,json,sys; a,b=json.load(sys.stdin); print(ast.dump(ast.parse(a)) == ast.dump(ast.parse(b)))';
|
|
106
|
+
for (const command of ['python3', 'python']) {
|
|
107
|
+
const result = spawnSync(command, ['-I', '-S', '-c', script], {
|
|
108
|
+
input: JSON.stringify([before, after]), encoding: 'utf-8',
|
|
109
|
+
timeout: 4000, maxBuffer: 1024 * 1024,
|
|
110
|
+
});
|
|
111
|
+
if (result.status === 0) return result.stdout.trim() === 'True';
|
|
112
|
+
if (result.error?.code !== 'ENOENT') return false;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Ignore parser bookkeeping only; retain every semantic AST field.
|
|
118
|
+
function sameJsAst(projectDir, ref, file) {
|
|
119
|
+
const before = fileContentAtRev(projectDir, ref, file.oldPath);
|
|
120
|
+
const after = fileContentAtRev(projectDir, 'HEAD', file.newPath);
|
|
121
|
+
if (before === null || after === null) return false;
|
|
122
|
+
const oldTree = parseJsTs(before, file.oldPath);
|
|
123
|
+
const newTree = parseJsTs(after, file.newPath);
|
|
124
|
+
if (!oldTree.ok || !newTree.ok || oldTree.ast.errors?.length || newTree.ast.errors?.length) return false;
|
|
125
|
+
const metadata = new Set(['start', 'end', 'loc', 'extra', 'comments',
|
|
126
|
+
'leadingComments', 'trailingComments', 'innerComments', 'tokens', 'errors']);
|
|
127
|
+
const normalize = ast => JSON.stringify(ast, (key, value) => metadata.has(key) ? undefined : value);
|
|
128
|
+
return normalize(oldTree.ast) === normalize(newTree.ast);
|
|
129
|
+
}
|
|
130
|
+
|
|
97
131
|
export function validateDiffSuspicion(projectDir, config = {}) {
|
|
98
132
|
const cfg = config.diffSuspicion || {};
|
|
99
133
|
const minOverlap = Number.isInteger(cfg.minOverlap) ? cfg.minOverlap : 2;
|
|
@@ -109,8 +143,10 @@ export function validateDiffSuspicion(projectDir, config = {}) {
|
|
|
109
143
|
);
|
|
110
144
|
// Precompute removed-token sets; drop files whose change removed nothing.
|
|
111
145
|
const changed = changedFiles
|
|
112
|
-
.map(f => ({ path: f.newPath, removed: removedTokens(f) }))
|
|
113
|
-
.filter(f => f.removed.size > 0)
|
|
146
|
+
.map(f => ({ file: f, path: f.newPath, removed: removedTokens(f) }))
|
|
147
|
+
.filter(f => f.removed.size > 0)
|
|
148
|
+
.filter(f => !f.path.endsWith('.py') || !samePythonAst(projectDir, ref, f.file))
|
|
149
|
+
.filter(f => !/\.[cm]?[jt]sx?$/.test(f.path) || !sameJsAst(projectDir, ref, f.file));
|
|
114
150
|
|
|
115
151
|
if (changed.length === 0) {
|
|
116
152
|
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
@@ -146,10 +182,10 @@ export function validateDiffSuspicion(projectDir, config = {}) {
|
|
|
146
182
|
validator: 'diff-suspicion',
|
|
147
183
|
severity: 'warn',
|
|
148
184
|
confidence: 'low',
|
|
149
|
-
message: `${docName} describes ${h.path} (${h.kind} ref),
|
|
185
|
+
message: `${docName} describes ${h.path} (${h.kind} ref), and overlaps old-side diff tokens: ${h.shared.slice(0, 5).join(', ')}${h.shared.length > 5 ? '…' : ''} or removed declaration names (${ref}..HEAD) — possible drift; review whether the documentation is affected.`,
|
|
150
186
|
location: { file: docName },
|
|
151
187
|
suggestion: {
|
|
152
|
-
summary: `Re-read ${docName} against the current ${h.path}; the
|
|
188
|
+
summary: `Re-read ${docName} against the current ${h.path}; the old-side tokens (${h.shared.slice(0, 8).join(', ')}) do not establish a semantic contradiction.`,
|
|
153
189
|
},
|
|
154
190
|
}));
|
|
155
191
|
}
|
|
@@ -159,7 +195,7 @@ export function validateDiffSuspicion(projectDir, config = {}) {
|
|
|
159
195
|
validator: 'diff-suspicion',
|
|
160
196
|
severity: 'warn',
|
|
161
197
|
confidence: 'low',
|
|
162
|
-
message: `${docName} references ${hits.length - maxPerDoc} more changed file(s) with
|
|
198
|
+
message: `${docName} references ${hits.length - maxPerDoc} more changed file(s) with old-side token overlap (${ref}..HEAD) — a broad change; review ${docName} as a whole.`,
|
|
163
199
|
location: { file: docName },
|
|
164
200
|
suggestion: { summary: `${docName} looks broadly affected by this change set — review it end-to-end rather than line by line.` },
|
|
165
201
|
}));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Docs-Coverage Validator — Detects code features not referenced in docs.
|
|
3
4
|
*
|
|
@@ -113,7 +114,7 @@ export function validateDocsCoverage(projectDir, config) {
|
|
|
113
114
|
findings.push(...readmeChecks.findings);
|
|
114
115
|
|
|
115
116
|
// ── Check 6: IaC-aware Infrastructure documentation ──
|
|
116
|
-
const iacChecks = checkIaCDocumentation(projectDir, iac);
|
|
117
|
+
const iacChecks = checkIaCDocumentation(projectDir, iac, config);
|
|
117
118
|
total += iacChecks.total;
|
|
118
119
|
passed += iacChecks.passed;
|
|
119
120
|
findings.push(...iacChecks.findings);
|
|
@@ -233,7 +234,7 @@ function checkSourceDirs(projectDir, allDocContent, config = {}, iac = { isIaC:
|
|
|
233
234
|
let passed = 0;
|
|
234
235
|
let total = 0;
|
|
235
236
|
|
|
236
|
-
const archPath =
|
|
237
|
+
const archPath = resolveDocRole(projectDir, config, 'architecture');
|
|
237
238
|
if (!existsSync(archPath)) return { findings, passed, total };
|
|
238
239
|
|
|
239
240
|
let archContent;
|
|
@@ -336,11 +337,11 @@ function isInsideIaCPackage(relPath, packageDirs) {
|
|
|
336
337
|
* has no Infrastructure heading. Suppresses the generic per-directory
|
|
337
338
|
* warnings that would otherwise fire for bin/, lib/, modules/, handlers/, etc.
|
|
338
339
|
*/
|
|
339
|
-
function checkIaCDocumentation(projectDir, iac) {
|
|
340
|
+
function checkIaCDocumentation(projectDir, iac, config = {}) {
|
|
340
341
|
const findings = [];
|
|
341
342
|
if (!iac || !iac.isIaC) return { findings, passed: 0, total: 0 };
|
|
342
343
|
|
|
343
|
-
const archPath =
|
|
344
|
+
const archPath = resolveDocRole(projectDir, config, 'architecture');
|
|
344
345
|
if (!existsSync(archPath)) {
|
|
345
346
|
// No ARCHITECTURE.md at all — structure validator will catch that.
|
|
346
347
|
// Don't double-warn here.
|
|
@@ -363,7 +364,7 @@ function checkIaCDocumentation(projectDir, iac) {
|
|
|
363
364
|
validator: 'docsCoverage',
|
|
364
365
|
severity: 'warn',
|
|
365
366
|
message: buildIaCWarning(tool),
|
|
366
|
-
location: '
|
|
367
|
+
location: docRolePath(config, 'architecture'),
|
|
367
368
|
suggestion: { kind: 'fix', text: `Add an "Infrastructure" section to ARCHITECTURE.md covering the ${tool.label} layout` },
|
|
368
369
|
}));
|
|
369
370
|
}
|
|
@@ -18,6 +18,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
18
18
|
import { resolve, join, extname, basename, relative } from 'node:path';
|
|
19
19
|
import { shouldIgnore, globMatch, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
20
20
|
import { collectPackageJsons, detectDocker, resolveSourceRoots } from '../shared-source.mjs';
|
|
21
|
+
import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
21
22
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
22
23
|
|
|
23
24
|
const IGNORE_DIRS = new Set([
|
|
@@ -38,7 +39,7 @@ const CODE_EXTENSIONS = new Set([
|
|
|
38
39
|
const DRIFT_FINDINGS = {
|
|
39
40
|
'Tech Stack': {
|
|
40
41
|
code: 'DDF001',
|
|
41
|
-
|
|
42
|
+
role: 'architecture',
|
|
42
43
|
suggestion: {
|
|
43
44
|
kind: 'review',
|
|
44
45
|
text: 'Reconcile the Tech Stack in ARCHITECTURE.md with the actual dependencies — document the new tech or remove stale entries',
|
|
@@ -46,7 +47,7 @@ const DRIFT_FINDINGS = {
|
|
|
46
47
|
},
|
|
47
48
|
'Test Files': {
|
|
48
49
|
code: 'DDF002',
|
|
49
|
-
|
|
50
|
+
role: 'testSpec',
|
|
50
51
|
suggestion: {
|
|
51
52
|
kind: 'review',
|
|
52
53
|
text: 'Reconcile TEST-SPEC.md with the test files on disk — document new tests or remove stale entries',
|
|
@@ -58,7 +59,7 @@ const DRIFT_FINDINGS = {
|
|
|
58
59
|
* Validate doc-code alignment — compares canonical docs vs source code.
|
|
59
60
|
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
60
61
|
*/
|
|
61
|
-
export function validateDocsDiff(projectDir, config) {
|
|
62
|
+
export function validateDocsDiff(projectDir, config = {}) {
|
|
62
63
|
const findings = [];
|
|
63
64
|
let passed = 0;
|
|
64
65
|
let total = 0;
|
|
@@ -105,7 +106,7 @@ export function validateDocsDiff(projectDir, config) {
|
|
|
105
106
|
validator: 'docsDiff',
|
|
106
107
|
severity: 'warn',
|
|
107
108
|
message: `${result.title} drift: ${parts.join('; ')}`,
|
|
108
|
-
location: meta.
|
|
109
|
+
location: docRolePath(config, meta.role),
|
|
109
110
|
suggestion: meta.suggestion,
|
|
110
111
|
}));
|
|
111
112
|
}
|
|
@@ -116,8 +117,51 @@ export function validateDocsDiff(projectDir, config) {
|
|
|
116
117
|
|
|
117
118
|
// ── Diff Functions (lightweight versions for validator) ──────────────────
|
|
118
119
|
|
|
120
|
+
/** Evaluate mentions locally; optionality is not evidence of absence or use. */
|
|
121
|
+
function mentionsCurrentTechnology(content, tech, technologies) {
|
|
122
|
+
const escape = value => value.replace(/[.*+?^\x24{}()|[\]\\]/g, character => '\\' + character);
|
|
123
|
+
const names = new RegExp('\\b(?:' + technologies.map(escape).join('|') + ')\\b', 'gi');
|
|
124
|
+
const sentences = content.replace(/<!--[^]*?-->/g, '').replace(/[\x60*]/g, '')
|
|
125
|
+
.split(/\n|[;!?]|\.(?=\s|$)/);
|
|
126
|
+
for (const sentence of sentences) {
|
|
127
|
+
let subject = null;
|
|
128
|
+
for (let clause of sentence.split(/\b(?:but|whereas|while|however)\b/i)) {
|
|
129
|
+
let mentions = [...clause.matchAll(names)];
|
|
130
|
+
// Carry an omitted subject only within the same sentence, and only for
|
|
131
|
+
// an explicit continuation predicate ("but [it] is used ...").
|
|
132
|
+
if (!mentions.length && subject && /^\s*,?\s*(?:it\s+)?(?:is|was|remains)\s+/i.test(clause)) {
|
|
133
|
+
clause = subject + ' ' + clause.trim().replace(/^,\s*/, '').replace(/^it\s+/i, '');
|
|
134
|
+
mentions = [...clause.matchAll(names)];
|
|
135
|
+
}
|
|
136
|
+
if (mentions.length) subject = mentions.at(-1)[0];
|
|
137
|
+
// A leading "No" governs every item in a subject list, including tools
|
|
138
|
+
// outside our vocabulary. Stop at the predicate; later claims stay live.
|
|
139
|
+
const negativeList = /^\s*No\s+(.+?)\s+(?:is|are|was|were)\s+used\b/i.exec(clause);
|
|
140
|
+
const negativeListEnd = negativeList &&
|
|
141
|
+
!/\b(?:is|are|was|were|uses?|used|requires?|required)\b/i.test(negativeList[1])
|
|
142
|
+
? clause.indexOf(negativeList[1]) + negativeList[1].length : -1;
|
|
143
|
+
for (let i = 0; i < mentions.length; i++) {
|
|
144
|
+
const mention = mentions[i];
|
|
145
|
+
if (mention[0].toLowerCase() !== tech.toLowerCase()) continue;
|
|
146
|
+
if (mention.index < negativeListEnd) continue;
|
|
147
|
+
const before = clause.slice(i ? mentions[i - 1].index + mentions[i - 1][0].length : 0, mention.index);
|
|
148
|
+
const after = clause.slice(mention.index + mention[0].length, mentions[i + 1]?.index ?? clause.length)
|
|
149
|
+
.replace(/^\s*[|:(—-]\s*/, ' ').trim();
|
|
150
|
+
const negativeBefore = /\b(?:no|without|neither|nor|not(?:\s+using|\s+used)?|(?:do|does|did)\s+not\s+use|never\s+used?|no\s+longer\s+(?:use|using)|(?:migrated|moved)\s+(?:away\s+)?from)\s*$/i;
|
|
151
|
+
const historicalBefore = /\b(?:previously|formerly|historically|once)\s+(?:(?:we\s+)?(?:used?|using)\s+)?$|\bused\s+to\s+use\s*$/i;
|
|
152
|
+
const negativeAfter = /^(?:(?:is|was|are|were)\s+)?(?:not\s+(?:used|using|supported|adopted)|no\s+longer\s+(?:used|supported)|unused|removed|retired)\b/i;
|
|
153
|
+
const optionalAfter = /^(?:(?:is|was|are|were)\s+)?(?:not\s+(?:needed|required)|optional)\b/i;
|
|
154
|
+
const historicalAfter = /^(?:(?:is|was|were)\s+)?(?:previously|formerly|historically|once)\b|^(?:was|were)\s+used\b/i;
|
|
155
|
+
if (!negativeBefore.test(before) && !historicalBefore.test(before) &&
|
|
156
|
+
!negativeAfter.test(after) && !optionalAfter.test(after) && !historicalAfter.test(after)) return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
119
163
|
export function diffTechStack(dir, config = {}) {
|
|
120
|
-
const archPath =
|
|
164
|
+
const archPath = resolveDocRole(dir, config, 'architecture');
|
|
121
165
|
if (!existsSync(archPath)) return null;
|
|
122
166
|
|
|
123
167
|
// Monorepo-aware: merge dependencies across the root package + the source-root
|
|
@@ -134,7 +178,7 @@ export function diffTechStack(dir, config = {}) {
|
|
|
134
178
|
'TypeScript', 'Tailwind', 'Docker', 'Terraform'];
|
|
135
179
|
|
|
136
180
|
for (const tech of techPatterns) {
|
|
137
|
-
if (archContent
|
|
181
|
+
if (mentionsCurrentTechnology(archContent, tech, techPatterns)) {
|
|
138
182
|
docTech.add(tech);
|
|
139
183
|
}
|
|
140
184
|
}
|
|
@@ -177,7 +221,7 @@ export function diffTechStack(dir, config = {}) {
|
|
|
177
221
|
* Always ignores node_modules via globMatch().
|
|
178
222
|
*/
|
|
179
223
|
function diffTests(dir, config) {
|
|
180
|
-
const testSpecPath =
|
|
224
|
+
const testSpecPath = resolveDocRole(dir, config, 'testSpec');
|
|
181
225
|
if (!existsSync(testSpecPath)) return null;
|
|
182
226
|
|
|
183
227
|
// Strip fenced code blocks first — they contain shell commands like
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Environment Validator — Checks ENVIRONMENT.md docs and .env.example
|
|
3
4
|
* Now respects projectTypeConfig (e.g., skip env checks for CLI tools)
|
|
@@ -19,8 +20,8 @@ export function validateEnvironment(projectDir, config) {
|
|
|
19
20
|
let total = 0;
|
|
20
21
|
const ptc = config.projectTypeConfig || {};
|
|
21
22
|
|
|
22
|
-
const envDoc = '
|
|
23
|
-
const envDocPath =
|
|
23
|
+
const envDoc = docRolePath(config, 'environment');
|
|
24
|
+
const envDocPath = resolveDocRole(projectDir, config, 'environment');
|
|
24
25
|
if (!existsSync(envDocPath)) {
|
|
25
26
|
// Structure validator catches missing files. Keep the exact legacy shape
|
|
26
27
|
// here (no `findings` key) — tests deep-equal this early return.
|