docguard-cli 0.36.1 → 0.37.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 (39) hide show
  1. package/README.md +23 -18
  2. package/cli/commands/diagnose.mjs +3 -22
  3. package/cli/commands/explain.mjs +28 -0
  4. package/cli/commands/guard.mjs +4 -0
  5. package/cli/commands/llms.mjs +3 -2
  6. package/cli/commands/retire.mjs +352 -0
  7. package/cli/commands/specs.mjs +77 -0
  8. package/cli/commands/trace.mjs +24 -35
  9. package/cli/config.mjs +2 -0
  10. package/cli/docguard.mjs +74 -14
  11. package/cli/findings.mjs +54 -0
  12. package/cli/scanners/document-lifecycle.mjs +184 -0
  13. package/cli/scanners/requirement-evidence.mjs +126 -0
  14. package/cli/scanners/spec-registry.mjs +517 -0
  15. package/cli/shared-requirements.mjs +91 -0
  16. package/cli/validators/docs-coverage.mjs +111 -61
  17. package/cli/validators/document-lifecycle.mjs +51 -0
  18. package/cli/validators/schema-sync.mjs +16 -11
  19. package/cli/validators/spec-registry.mjs +47 -0
  20. package/cli/validators/traceability.mjs +73 -199
  21. package/docs/ai-integration.md +18 -5
  22. package/docs/commands.md +45 -0
  23. package/docs/configuration.md +15 -0
  24. package/docs/quickstart.md +1 -1
  25. package/extensions/spec-kit-docguard/README.md +15 -5
  26. package/extensions/spec-kit-docguard/commands/brief.md +34 -0
  27. package/extensions/spec-kit-docguard/commands/preflight.md +52 -0
  28. package/extensions/spec-kit-docguard/extension.yml +18 -5
  29. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  30. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  31. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  32. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  33. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  34. package/extensions/spec-kit-docguard/templates/extensions.yml +13 -6
  35. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  36. package/package.json +1 -1
  37. package/schemas/docguard-config.schema.json +2 -0
  38. package/schemas/docguard-specs.schema.json +162 -0
  39. package/templates/ci/github-actions.yml +1 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Requirement identity parsing shared by traceability and lifecycle recovery.
3
+ * Only declaration-shaped Markdown counts; prose, comments, code fences, and
4
+ * example sections cannot mint identities.
5
+ */
6
+
7
+ export const DEFAULT_REQ_PATTERNS = [
8
+ /\b(REQ)-(\d{2,4})\b/g,
9
+ /\b(FR)-(\d{2,4})\b/g,
10
+ /\b(NFR)-(\d{2,4})\b/g,
11
+ /\b(US)-(\d{2,4})\b/g,
12
+ /\b(STORY)-(\d{2,4})\b/g,
13
+ /\b(AC)-(\d{2,4})\b/g,
14
+ /\b(UC)-(\d{2,4})\b/g,
15
+ /\b(SYS)-(\d{2,4})\b/g,
16
+ /\b(ARCH)-(\d{2,4})\b/g,
17
+ /\b(MOD)-(\d{2,4})\b/g,
18
+ /\b(SC)-(\d{2,4})\b/g,
19
+ /(?<=\[[ xX]\]\s|@(?:req|task|covers)\s)(T)(\d{3,4})\b/g,
20
+ ];
21
+
22
+ export function requirementPatterns(config = {}) {
23
+ const customPattern = config.traceability?.requirementPattern;
24
+ return customPattern
25
+ ? [new RegExp(customPattern, 'g')]
26
+ : DEFAULT_REQ_PATTERNS;
27
+ }
28
+
29
+ /**
30
+ * Return path-qualified identities from one Markdown artifact.
31
+ * @returns {Map<string, {id:string, file:string, line:number, text:string}>}
32
+ */
33
+ export function collectRequirementIdsFromContent(content, docName, patterns = DEFAULT_REQ_PATTERNS) {
34
+ const reqIds = new Map();
35
+ const hasMatch = patterns.some(pattern => {
36
+ pattern.lastIndex = 0;
37
+ return pattern.test(content);
38
+ });
39
+ if (!hasMatch) return reqIds;
40
+
41
+ const lines = content.split('\n');
42
+ let fence = null;
43
+ let exampleLevel = null;
44
+ let inComment = false;
45
+ for (let i = 0; i < lines.length; i++) {
46
+ let line = lines[i];
47
+ if (/^(?: {4}|\t)/.test(line) && !fence && !inComment) continue;
48
+ const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
49
+ if (fence) {
50
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length
51
+ && line.slice(marker[0].length).trim() === '') fence = null;
52
+ continue;
53
+ }
54
+ if (marker) { fence = marker[1]; continue; }
55
+ line = line.replace(/<!--[\s\S]*?-->/g, '');
56
+ if (inComment) {
57
+ const close = line.indexOf('-->');
58
+ if (close < 0) continue;
59
+ line = line.slice(close + 3);
60
+ inComment = false;
61
+ }
62
+ const open = line.indexOf('<!--');
63
+ if (open >= 0) { line = line.slice(0, open); inComment = true; }
64
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*)/);
65
+ if (heading) {
66
+ if (exampleLevel !== null && heading[1].length <= exampleLevel) exampleLevel = null;
67
+ if (exampleLevel === null && /^(?:(?:requirement|task)[ -]+)?(?:examples?|ID[ -]+(?:formats?|syntax|examples?)|(?:formats?|syntax)[ -]+(?:of[ -]+)?IDs?)\b/i.test(heading[2])) {
68
+ exampleLevel = heading[1].length;
69
+ }
70
+ }
71
+ if (exampleLevel !== null) continue;
72
+ for (const pattern of patterns) {
73
+ pattern.lastIndex = 0;
74
+ let match;
75
+ while ((match = pattern.exec(line)) !== null) {
76
+ const prefix = line.slice(0, match.index);
77
+ if (!/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+|\|\s*)?[\s*`_]*$/.test(prefix)) {
78
+ if (!match[0].length) pattern.lastIndex++;
79
+ continue;
80
+ }
81
+ const reqId = match[0];
82
+ if (!reqId.length) { pattern.lastIndex++; continue; }
83
+ const key = `${docName}#${reqId}`;
84
+ if (!reqIds.has(key)) {
85
+ reqIds.set(key, { id: reqId, file: docName, line: i + 1, text: line.trim() });
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return reqIds;
91
+ }
@@ -19,10 +19,12 @@ import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
19
19
  * existing tests are unaffected; guard just renders richer output.
20
20
  */
21
21
 
22
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
23
- import { resolve, join, relative, basename, extname } from 'node:path';
22
+ import { existsSync, readFileSync, readdirSync, statSync, lstatSync } from 'node:fs';
23
+ import { resolve, join, relative, basename, extname, isAbsolute } from 'node:path';
24
24
  import { resolveSourceRoots } from '../shared-source.mjs';
25
- import { shouldIgnore, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
25
+ import { shouldIgnore, walkFiles as sharedWalkFiles, buildIgnoreFilter, mergeIgnoreFile, DEFAULT_IGNORE_DIRS } from '../shared-ignore.mjs';
26
+ import { resolveDocDirs } from '../shared.mjs';
27
+ import { parseJsTs, walk } from '../scanners/js-ast.mjs';
26
28
  import { detectIaC, hasInfrastructureHeading, buildIaCWarning } from '../scanners/iac.mjs';
27
29
  import { mkFinding, resultFromFindings } from '../findings.mjs';
28
30
 
@@ -73,7 +75,7 @@ export function validateDocsCoverage(projectDir, config) {
73
75
  let total = 0;
74
76
 
75
77
  // Collect all doc content for searching
76
- const allDocContent = collectDocContent(projectDir);
78
+ const allDocContent = collectDocContent(projectDir, config);
77
79
  if (!allDocContent) {
78
80
  // Literal legacy shape (no findings key) — tests deepEqual this exact object.
79
81
  return { errors: [], warnings: [], passed: 0, total: 0 };
@@ -172,7 +174,7 @@ function checkConfigFiles(projectDir, allDocContent, config = {}) {
172
174
  code: 'DCV001',
173
175
  validator: 'docsCoverage',
174
176
  severity: 'warn',
175
- message: `Config file "${entry}" exists but is not mentioned in any documentation. Document its purpose in ARCHITECTURE.md or README.md`,
177
+ message: `Config file "${entry}" exists but is not mentioned in scanned supported Markdown or extension YAML documentation. Document its purpose in ARCHITECTURE.md or README.md`,
176
178
  location: entry,
177
179
  suggestion: { kind: 'fix', text: 'Explain what this config file does in ARCHITECTURE.md or README.md' },
178
180
  }));
@@ -372,42 +374,60 @@ function checkIaCDocumentation(projectDir, iac, config = {}) {
372
374
  }
373
375
 
374
376
  /**
375
- * Check 4: Config files that code actually READS are documented.
376
- *
377
- * Scans source code for resolve(dir, '.configname') and existsSync('.configname')
378
- * patterns — these are configs the project USES. Avoids matching config names
379
- * sitting in arrays (scan patterns for detecting other projects' configs).
377
+ * Check 4: Distinguish direct file IO from config-like path expressions.
378
+ * Parsed calls exclude comments and example strings. Unparsed text supplies
379
+ * review candidates only; it cannot establish file IO or documentation need.
380
380
  */
381
381
  function checkCodeReferencedConfigs(projectDir, allDocContent, config = {}) {
382
382
  const findings = [];
383
383
  let passed = 0;
384
384
  let total = 0;
385
-
386
385
  const lowerDocContent = allDocContent.toLowerCase();
387
- const foundConfigs = new Set();
388
-
389
- // Only match config filenames inside function calls that actually USE the file:
390
- // resolve(dir, '.docguardignore'), existsSync('.env.example'), readFileSync('vitest.config.ts')
391
- const usageRegex = /(?:resolve|join|existsSync|readFileSync|accessSync|writeFileSync)\s*\([^)]*['"`]([^'"`\n]{2,})['"`]/g;
386
+ const foundConfigs = new Map();
387
+ const methods = new Set(['resolve', 'join', 'existsSync', 'accessSync', 'readFileSync', 'writeFileSync']);
388
+ const directIO = new Set(['readFileSync', 'writeFileSync']);
392
389
 
393
390
  const scanFile = (filePath) => {
394
- const ext = extname(filePath);
395
- if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) return;
391
+ if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(extname(filePath))) return;
396
392
  let content;
397
393
  try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
398
-
399
- usageRegex.lastIndex = 0;
400
- let match;
401
- while ((match = usageRegex.exec(content)) !== null) {
402
- const name = match[1];
403
- // Must be a dotfile (.something) or *.config.* not a path
404
- if (name.includes('/') || name.startsWith('..')) continue;
405
- const isDotConfig = name.startsWith('.') && name.length > 2;
406
- const isNamedConfig = /^[\w-]+\.config\.\w+$/.test(name);
407
- if (!isDotConfig && !isNamedConfig) continue;
408
- // Skip bare extensions
409
- if (/^\.[a-z]{1,4}$/i.test(name)) continue;
410
- foundConfigs.add(name);
394
+ if (!/(?:resolve|join|existsSync|accessSync|readFileSync|writeFileSync)\s*\(/.test(content)) return;
395
+ // Parsing cannot yield a config candidate without a matching literal prefix.
396
+ // Keep every escaped source conservatively: escapes may encode that prefix.
397
+ if (!/['"\x60](?:\.(?![./])|[\w-]+\.config\.)|\\/.test(content)) return;
398
+ const source = relative(projectDir, filePath).split('\\').join('/');
399
+ const record = (name, method, line, parsed) => {
400
+ if (typeof name !== 'string' || name.includes('/') || name.includes('\\') || name.startsWith('..')) return;
401
+ if (!(name.startsWith('.') && name.length > 2) && !/^[\w-]+\.config\.\w+$/.test(name)) return;
402
+ if (/^\.[a-z]{1,4}$/i.test(name) || COMMON_DOTFILES.has(name)) return;
403
+ const direct = parsed && directIO.has(method);
404
+ // Prefer concrete IO when the same name also occurs in a path expression.
405
+ if (!foundConfigs.has(name) || (direct && !foundConfigs.get(name).direct)) {
406
+ foundConfigs.set(name, { direct, parsed, method, source: source + ':' + line });
407
+ }
408
+ };
409
+ const { ast, ok } = parseJsTs(content, filePath);
410
+ if (ok && !ast.errors?.length) {
411
+ walk(ast, node => {
412
+ if (node.type !== 'CallExpression') return;
413
+ const callee = node.callee;
414
+ const method = callee.type === 'Identifier' ? callee.name
415
+ : callee.type === 'MemberExpression' && !callee.computed ? callee.property.name : null;
416
+ if (!methods.has(method)) return;
417
+ // IO and existence checks use argument one as the path; join/resolve
418
+ // may supply a filename in any segment. No dynamic-path evaluation.
419
+ const args = ['join', 'resolve'].includes(method) ? node.arguments : node.arguments.slice(0, 1);
420
+ for (const arg of args) {
421
+ const value = arg.type === 'StringLiteral' ? arg.value
422
+ : arg.type === 'TemplateLiteral' && arg.expressions.length === 0 ? arg.quasis[0].value.cooked : null;
423
+ record(value, method, node.loc.start.line, true);
424
+ }
425
+ });
426
+ } else {
427
+ const pattern = /\b(resolve|join|existsSync|accessSync|readFileSync|writeFileSync)\s*\([^)]*?['"\x60]([^'"\x60\n]{2,})['"\x60]/g;
428
+ for (const match of content.matchAll(pattern)) {
429
+ record(match[2], match[1], content.slice(0, match.index).split('\n').length, false);
430
+ }
411
431
  }
412
432
  };
413
433
 
@@ -415,19 +435,26 @@ function checkCodeReferencedConfigs(projectDir, allDocContent, config = {}) {
415
435
  walkFiles(rootDir, scanFile);
416
436
  }
417
437
 
418
- for (const configName of foundConfigs) {
419
- if (COMMON_DOTFILES.has(configName)) continue;
438
+ for (const [configName, evidence] of foundConfigs) {
420
439
  total++;
421
440
  if (lowerDocContent.includes(configName.toLowerCase())) {
422
441
  passed++;
423
442
  } else {
443
+ const scope = 'scanned supported Markdown or extension YAML documentation';
444
+ const context = evidence.parsed ? evidence.method + ' call' : 'unparsed source text (parser unavailable or failed)';
424
445
  findings.push(mkFinding({
425
446
  code: 'DCV004',
426
447
  validator: 'docsCoverage',
427
448
  severity: 'warn',
428
- message: `Code references config file "${configName}" but no documentation mentions it. Add it to README.md or ARCHITECTURE.md`,
449
+ confidence: evidence.direct ? 'high' : 'low',
450
+ message: evidence.direct
451
+ ? 'Direct ' + context + ' references config file "' + configName + '" at ' + evidence.source + ', but it is not mentioned in ' + scope + '.'
452
+ : 'Config-like path "' + configName + '" appears in ' + context + ' at ' + evidence.source + '; file use and documentation need are unverified. No mention was found in ' + scope + '.',
453
+ // Preserve the published filename location; source context is in the message.
429
454
  location: configName,
430
- suggestion: { kind: 'fix', text: 'Describe this config file (purpose and format) in README.md or ARCHITECTURE.md' },
455
+ suggestion: evidence.direct
456
+ ? { kind: 'fix', text: 'Describe this config file (purpose and format) in a supported documentation file' }
457
+ : { kind: 'review', text: 'Inspect this source reference: it may be a directory, generated output, or example. Document it only if appropriate.' },
431
458
  }));
432
459
  }
433
460
  }
@@ -500,36 +527,59 @@ function checkReadmeSections(projectDir) {
500
527
  /**
501
528
  * Collect all documentation content into a single searchable string.
502
529
  */
503
- function collectDocContent(projectDir) {
504
- const docPaths = [];
505
-
506
- const rootDocs = ['README.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'STANDARD.md'];
507
- for (const doc of rootDocs) {
508
- const p = resolve(projectDir, doc);
509
- if (existsSync(p)) docPaths.push(p);
510
- }
511
-
512
- for (const doc of listCanonicalDocs(projectDir)) docPaths.push(doc.abs); // recursive
513
-
514
- const extDir = resolve(projectDir, 'extensions');
515
- if (existsSync(extDir)) {
516
- walkFiles(extDir, (f) => {
517
- if (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml')) {
518
- docPaths.push(f);
530
+ function collectDocContent(projectDir, config = {}) {
531
+ const docPaths = new Set();
532
+ const visited = new Set();
533
+ const isIgnored = buildIgnoreFilter(mergeIgnoreFile(projectDir, { ...config }).ignore);
534
+
535
+ // Check every ancestor before traversal: the shared walker follows symlinks.
536
+ // Explicit homes must remain scoped inside the project, including private aliases.
537
+ const safePath = (path) => {
538
+ const normalized = path.replace(/\\/g, '/');
539
+ if (isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized) || normalized.includes('\0')) return null;
540
+ const parts = normalized.split('/').filter(p => p && p !== '.');
541
+ if (!parts.length || parts.some(p => p === '..' || ['.local', '.git'].includes(p.toLowerCase())
542
+ || /^\.env(?:\.|$)/i.test(p) || DEFAULT_IGNORE_DIRS.has(p) || IGNORE_DIRS.has(p))) return null;
543
+ let current = resolve(projectDir);
544
+ let rel = '';
545
+ try {
546
+ for (const part of parts) {
547
+ rel = rel ? rel + '/' + part : part;
548
+ if (isIgnored(rel) || isIgnored(rel + '/')) return null;
549
+ current = join(current, part);
550
+ if (lstatSync(current).isSymbolicLink()) return null;
519
551
  }
520
- });
521
- }
522
-
523
- for (const docsDir of ['docs', 'docs-implementation']) {
524
- const d = resolve(projectDir, docsDir);
525
- if (existsSync(d)) {
526
- walkFiles(d, (f) => {
527
- if (f.endsWith('.md')) docPaths.push(f);
528
- });
552
+ return current;
553
+ } catch { return null; }
554
+ };
555
+ const addDoc = (rel) => {
556
+ const abs = safePath(rel);
557
+ if (abs && lstatSync(abs).isFile()) docPaths.add(abs);
558
+ };
559
+ const walkDocs = (rel) => {
560
+ const dir = safePath(rel);
561
+ if (!dir || visited.has(dir)) return;
562
+ visited.add(dir);
563
+ let entries;
564
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
565
+ for (const entry of entries) {
566
+ if (entry.isSymbolicLink()) continue;
567
+ const path = relative(projectDir, join(dir, entry.name)).split('\\').join('/');
568
+ if (entry.isDirectory() && !entry.name.startsWith('.')) walkDocs(path);
569
+ else if (entry.isFile() && (/\.md$/i.test(entry.name)
570
+ || (path.startsWith('extensions/') && /\.ya?ml$/i.test(entry.name)))) addDoc(path);
529
571
  }
572
+ };
573
+
574
+ for (const doc of ['README.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'STANDARD.md']) addDoc(doc);
575
+ for (const dir of resolveDocDirs(projectDir, config)) walkDocs(dir);
576
+ // Resolve roles directly so raw callers get the same boundaries as loadConfig.
577
+ for (const role of Object.keys(config.docs?.roles || {})) {
578
+ const abs = resolveDocRole(projectDir, config, role);
579
+ addDoc(relative(projectDir, abs));
530
580
  }
531
581
 
532
- if (docPaths.length === 0) return null;
582
+ if (docPaths.size === 0) return null;
533
583
  const parts = [];
534
584
  for (const p of docPaths) {
535
585
  try { parts.push(readFileSync(p, 'utf-8')); } catch { /* skip */ }
@@ -0,0 +1,51 @@
1
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
2
+ import { scanDocumentLifecycle } from '../scanners/document-lifecycle.mjs';
3
+
4
+ /**
5
+ * Finds terminal lifecycle declarations and specs whose task list is complete.
6
+ * Both remain review signals; `retire --write` still requires explicit paths.
7
+ */
8
+ export function validateDocumentLifecycle(projectDir, config = {}) {
9
+ const { scanned, candidates, coverage } = scanDocumentLifecycle(projectDir, config);
10
+ if (coverage.status === 'unavailable' && coverage.reason === 'not-git') {
11
+ return resultFromFindings([], { passed: 0, total: 0, applicable: false });
12
+ }
13
+ const findings = candidates.map(candidate => mkFinding({
14
+ code: candidate.code,
15
+ validator: 'documentLifecycle',
16
+ severity: 'warn',
17
+ confidence: candidate.confidence === 'high' ? 'high' : 'low',
18
+ message: candidate.code === 'DLC001'
19
+ ? `${candidate.path} declares terminal lifecycle status "${candidate.status}" but remains in active AI context.`
20
+ : candidate.code === 'DLC004'
21
+ ? `${candidate.path} is recorded as retired but remains in the working tree.`
22
+ : `${candidate.path} has ${candidate.reason}`,
23
+ location: candidate.path,
24
+ suggestion: {
25
+ kind: 'review',
26
+ text: 'Confirm shipped outcomes are represented in current docs, then archive the explicit path.',
27
+ command: `docguard retire --write --path ${candidate.path} --reason "<why this is no longer current>"`,
28
+ },
29
+ }));
30
+ if (coverage.status !== 'complete') {
31
+ findings.push(mkFinding({
32
+ code: 'DLC003',
33
+ validator: 'documentLifecycle',
34
+ severity: 'warn',
35
+ confidence: 'high',
36
+ message: coverage.status === 'unavailable'
37
+ ? `Document lifecycle coverage is unavailable: ${coverage.error}`
38
+ : `Document lifecycle coverage is partial; ${coverage.unreadable.length} tracked Markdown file(s) could not be read.`,
39
+ location: coverage.unreadable[0] || null,
40
+ suggestion: {
41
+ kind: 'review',
42
+ text: 'Restore readable Git/document access and rerun guard; an incomplete scan cannot prove lifecycle hygiene.',
43
+ },
44
+ }));
45
+ }
46
+ return resultFromFindings(findings, {
47
+ passed: Math.max(0, scanned - candidates.length),
48
+ total: scanned + (coverage.status === 'unavailable' ? 1 : 0),
49
+ applicable: scanned > 0 || coverage.status !== 'complete',
50
+ });
51
+ }
@@ -10,15 +10,14 @@ import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
10
10
  * Zero NPM runtime dependencies — pure Node.js built-ins only.
11
11
  */
12
12
 
13
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
14
- import { resolve, join, relative, extname, basename } from 'node:path';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { resolve, relative, basename } from 'node:path';
15
15
  import { resolveSourceRoots } from '../shared-source.mjs';
16
- import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
16
+ import { DEFAULT_IGNORE_DIRS, relPosix, shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
17
17
  import { mkFinding, resultFromFindings } from '../findings.mjs';
18
18
 
19
19
  const IGNORE_DIRS = new Set([
20
- 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
21
- '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
20
+ ...DEFAULT_IGNORE_DIRS,
22
21
  '.amplify-hosting', '.serverless',
23
22
  ]);
24
23
 
@@ -192,7 +191,8 @@ function detectAllModels(projectDir, config = {}) {
192
191
  * Find schema files for a given detector configuration.
193
192
  */
194
193
  function findSchemaFiles(projectDir, detector, config = {}) {
195
- const files = [];
194
+ // Deduplicate files, not model names: separate schemas may share a name.
195
+ const files = new Set();
196
196
 
197
197
  // Monorepo-aware: resolve each searchDir against the project root AND every
198
198
  // configured source root (config.sourceRoot + workspaces), so schemas under
@@ -203,19 +203,24 @@ function findSchemaFiles(projectDir, detector, config = {}) {
203
203
  for (const base of bases) {
204
204
  for (const searchDir of detector.searchDirs) {
205
205
  const dir = resolve(base, searchDir);
206
- if (seenDirs.has(dir) || !existsSync(dir)) continue;
206
+ const rel = relPosix(projectDir, dir);
207
+ if (seenDirs.has(dir) || !existsSync(dir) ||
208
+ rel.split('/').some(part => IGNORE_DIRS.has(part)) ||
209
+ shouldIgnore(rel + '/', config)) continue;
207
210
  seenDirs.add(dir);
208
- scanSchemaDir(dir, detector.filePattern, files);
211
+ scanSchemaDir(dir, detector.filePattern, files, projectDir, config);
209
212
  }
210
213
  }
211
214
 
212
- return files;
215
+ return [...files];
213
216
  }
214
217
 
215
218
  // v0.29 consolidation: traversal delegates to the shared canonical walker.
216
- function scanSchemaDir(dir, filePattern, files) {
219
+ function scanSchemaDir(dir, filePattern, files, projectDir, config) {
217
220
  sharedWalkFiles(dir, (full) => {
218
- if (filePattern.test(basename(full))) files.push(full);
221
+ if (filePattern.test(basename(full)) && !shouldIgnore(relPosix(projectDir, full), config)) {
222
+ files.add(full);
223
+ }
219
224
  }, { ignoreDirs: IGNORE_DIRS });
220
225
  }
221
226
 
@@ -0,0 +1,47 @@
1
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
2
+ import { projectSpecRegistry, SPEC_REGISTRY_PATH } from '../scanners/spec-registry.mjs';
3
+
4
+ export function validateSpecRegistry(projectDir, config = {}) {
5
+ const projection = projectSpecRegistry(projectDir, config);
6
+ if (projection.detected === 0 && !projection.exists) {
7
+ return resultFromFindings([], { passed: 0, total: 0, applicable: false });
8
+ }
9
+ const findings = projection.issues.map(issue => mkFinding({
10
+ code: issue.code,
11
+ validator: 'specRegistry',
12
+ severity: 'warn',
13
+ confidence: 'high',
14
+ message: issue.message,
15
+ location: issue.path,
16
+ suggestion: {
17
+ kind: 'review',
18
+ text: issue.code === 'SPR002'
19
+ ? 'Assign a unique immutable Spec ID in the authoritative spec metadata.'
20
+ : 'Resolve the lifecycle or registry integrity conflict, then refresh the registry.',
21
+ command: 'docguard specs --write',
22
+ },
23
+ }));
24
+ if (!projection.current && projection.issues.length === 0) {
25
+ findings.push(mkFinding({
26
+ code: 'SPR001',
27
+ validator: 'specRegistry',
28
+ severity: 'warn',
29
+ confidence: 'high',
30
+ message: projection.exists
31
+ ? `${SPEC_REGISTRY_PATH} does not match the current deterministic spec evidence projection.`
32
+ : `${SPEC_REGISTRY_PATH} is missing while active specifications exist.`,
33
+ location: SPEC_REGISTRY_PATH,
34
+ suggestion: {
35
+ kind: 'fix',
36
+ text: 'Refresh derived evidence without changing reviewed lifecycle fields.',
37
+ command: 'docguard specs --write',
38
+ },
39
+ }));
40
+ }
41
+ const checks = Math.max(1, projection.registry.specs.length + projection.registry.tombstones.length);
42
+ return resultFromFindings(findings, {
43
+ passed: Math.max(0, checks - findings.length),
44
+ total: checks,
45
+ applicable: true,
46
+ });
47
+ }