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.
Files changed (51) hide show
  1. package/README.md +27 -15
  2. package/cli/commands/agent.mjs +27 -6
  3. package/cli/commands/ci.mjs +3 -0
  4. package/cli/commands/diagnose.mjs +8 -2
  5. package/cli/commands/feedback.mjs +83 -89
  6. package/cli/commands/fix.mjs +4 -0
  7. package/cli/commands/generate.mjs +3 -0
  8. package/cli/commands/guard.mjs +37 -20
  9. package/cli/commands/hooks.mjs +61 -40
  10. package/cli/commands/init.mjs +51 -5
  11. package/cli/commands/memory.mjs +29 -15
  12. package/cli/commands/report.mjs +12 -7
  13. package/cli/commands/score.mjs +39 -19
  14. package/cli/commands/sync.mjs +2 -0
  15. package/cli/commands/watch.mjs +113 -70
  16. package/cli/config.mjs +6 -3
  17. package/cli/docguard.mjs +12 -4
  18. package/cli/findings.mjs +13 -13
  19. package/cli/scanners/memory-plan.mjs +279 -134
  20. package/cli/scanners/project-type.mjs +6 -1
  21. package/cli/scanners/semantic-claims.mjs +176 -26
  22. package/cli/shared-diff.mjs +22 -1
  23. package/cli/shared-doc-roles.mjs +59 -0
  24. package/cli/shared-ignore.mjs +15 -2
  25. package/cli/shared-source.mjs +223 -1
  26. package/cli/validator-coverage.mjs +20 -0
  27. package/cli/validators/api-surface.mjs +94 -70
  28. package/cli/validators/architecture.mjs +19 -5
  29. package/cli/validators/diff-suspicion.mjs +45 -9
  30. package/cli/validators/docs-coverage.mjs +6 -5
  31. package/cli/validators/docs-diff.mjs +51 -7
  32. package/cli/validators/environment.mjs +3 -2
  33. package/cli/validators/freshness.mjs +140 -83
  34. package/cli/validators/schema-sync.mjs +3 -2
  35. package/cli/validators/security.mjs +58 -23
  36. package/cli/validators/structure.mjs +3 -1
  37. package/cli/validators/test-spec.mjs +3 -2
  38. package/cli/validators/todo-tracking.mjs +61 -28
  39. package/cli/validators/traceability.mjs +152 -38
  40. package/docs/configuration.md +41 -0
  41. package/extensions/spec-kit-docguard/extension.yml +2 -3
  42. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  43. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  44. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  45. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  46. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  47. package/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
  48. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
  49. package/package.json +1 -1
  50. package/schemas/docguard-config.schema.json +43 -1
  51. 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
- * Severity policy:
13
- * - documented-but-absent → ERROR when confirmed by an OpenAPI spec
14
- * (docs lie about a real endpoint fail the build);
15
- * downgraded to WARNING on heuristic code-scan only.
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 lists deterministic remove-endpoint actions that
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
- const API_DOC = 'docs-canonical/API-REFERENCE.md';
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 apiDocPath = resolve(projectDir, API_DOC);
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: cmp.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
- // v0.29: migrated to structured findings (API001–API005). Messages are
255
- // byte-identical to the legacy strings resultFromFindings derives the
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
- // documented-but-absent deterministic remove-endpoint fixes
373
- if (documentedButAbsent.length) {
374
- const { shown, extra } = trim(documentedButAbsent);
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 msg = `Documented endpoint not found in code: ${e.method} ${e.path} (${API_DOC})`;
377
- if (confidence === 'spec') {
378
- findings.push(mkFinding({
379
- code: 'API004',
380
- validator: 'apiSurface',
381
- severity: 'error',
382
- message: msg,
383
- location: API_DOC,
384
- suggestion: { kind: 'fix', text: 'Remove the dead endpoint from the doc', command: 'docguard fix --write' },
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: { kind: 'review', text: 'Verify the endpoint really is gone from the code, then remove it from the doc' },
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
- const tail = `…and ${extra} more documented endpoint(s) not found in code`;
402
- if (confidence === 'spec') {
403
- findings.push(mkFinding({
404
- code: 'API004',
405
- validator: 'apiSurface',
406
- severity: 'error',
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
- // Only spec-confirmed absences are safe to auto-remove.
424
- if (confidence === 'spec') {
425
- for (const e of documentedButAbsent) {
426
- fixes.push({ type: 'remove-endpoint', method: e.method, path: e.path, doc: API_DOC });
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 = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
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
- // Recursive. Keyed by bare basename matches this validator's pre-existing
69
- // flat-tree DSP001 message format ("ARCHITECTURE.md describes...").
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), which just had ${h.shared.slice(0, 5).join(', ')}${h.shared.length > 5 ? '…' : ''} removed/changed (${ref}..HEAD) — verify the doc still matches.`,
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 removed symbols (${h.shared.slice(0, 8).join(', ')}) may now be wrong.`,
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 removed domain symbols (${ref}..HEAD) — a broad change; review ${docName} as a whole.`,
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 = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
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 = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
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: 'docs-canonical/ARCHITECTURE.md',
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
- location: 'docs-canonical/ARCHITECTURE.md',
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
- location: 'docs-canonical/TEST-SPEC.md',
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.location,
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 = resolve(dir, 'docs-canonical/ARCHITECTURE.md');
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.toLowerCase().includes(tech.toLowerCase())) {
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 = resolve(dir, 'docs-canonical/TEST-SPEC.md');
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 = 'docs-canonical/ENVIRONMENT.md';
23
- const envDocPath = resolve(projectDir, envDoc);
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.