docguard-cli 0.36.1 → 0.36.2

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.
@@ -101,10 +101,8 @@ const FIX_INSTRUCTIONS = {
101
101
  autoFixable: false,
102
102
  },
103
103
  'Freshness': {
104
- action: 'Review stale documents',
105
- command: 'docguard fix --doc',
106
- llmCommand: '/docguard.fix --doc',
107
- description: 'Documents haven\'t been reviewed since recent code changes. Re-run fix --doc for each stale doc.',
104
+ action: 'Review document evidence against relevant changes',
105
+ description: 'Review signals do not establish incorrect documentation. Check whether the documentation or implementation needs a change; preserve approved intent.',
108
106
  autoFixable: false,
109
107
  },
110
108
  // ── Routed (Phase F): these used to fall through to a generic "Manual review needed" ──
@@ -257,23 +255,6 @@ export function runDiagnose(projectDir, config, flags) {
257
255
  }
258
256
  }
259
257
 
260
- // Detect stale docs from freshness and map to specific fix --doc targets
261
- for (const issue of issues) {
262
- if (issue.validator === 'Freshness' && !issue.docTarget) {
263
- const match = issue.message.match(/([\w-]+\.md)/i);
264
- if (match) {
265
- const docName = match[1].toLowerCase().replace('.md', '');
266
- const docMap = { 'architecture': 'architecture', 'data-model': 'data-model', 'security': 'security', 'test-spec': 'test-spec', 'environment': 'environment' };
267
- issue.docTarget = docMap[docName] || null;
268
- if (issue.docTarget) {
269
- issue.command = agentMode === 'llm'
270
- ? `/docguard.fix --doc ${issue.docTarget}`
271
- : `docguard fix --doc ${issue.docTarget}`;
272
- }
273
- }
274
- }
275
- }
276
-
277
258
  // ── Step 4: Output ──
278
259
  if (flags.format === 'json') {
279
260
  outputJSON(guardData, scoreData, issues);
@@ -497,7 +478,7 @@ function outputPrompt(projectDir, guardData, scoreData, issues, flags, agentMode
497
478
  } else {
498
479
  lines.push('After making all fixes, run: docguard guard');
499
480
  }
500
- lines.push('Expected result: All checks pass (0 errors, 0 warnings)');
481
+ lines.push('Expected result: Resolve verified defects; explain remaining review signals and unsupported checks. Do not rewrite correct documents merely to remove warnings.');
501
482
  lines.push(`Structural baseline: ${scoreData.score}/100. Resolve evidenced defects; verify material claims separately.`);
502
483
  lines.push('Preserve approved requirements when implementation disagrees. A higher score is not proof of factual correctness.');
503
484
 
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
10
  import { resolve, join, extname, basename, relative, dirname } from 'node:path';
11
11
  import { c } from '../shared.mjs';
12
12
  import { detectSpecKit } from '../scanners/speckit.mjs';
13
+ import { scanTestFilesForReferences, collectRequirementIds, resolveRequirementReferences } from '../validators/traceability.mjs';
13
14
  import { listCanonicalDocs } from '../shared-ignore.mjs';
14
15
 
15
16
  const IGNORE_DIRS = new Set([
@@ -378,7 +379,7 @@ function scanDir(rootDir, dir, files) {
378
379
  // repo-wide scores that `docguard score` produces. Deterministic signals only —
379
380
  // no LLM judgment:
380
381
  //
381
- // reqCoverage 40% FR-/SC- IDs in spec.md referenced by any test file
382
+ // reqCoverage 40% FR-/SC- IDs in spec.md explicitly annotated or labeled in test sources
382
383
  // taskCompletion 25% checked/total `- [x]` tasks in tasks.md
383
384
  // taskEvidence 20% checked tasks whose line names an existing file
384
385
  // artifactCompleteness 15% spec.md (40%) + plan.md (30%) + tasks.md (30%)
@@ -425,34 +426,21 @@ function featureBar(score) {
425
426
  }
426
427
 
427
428
  /**
428
- * Collect every FR-/SC- ID referenced anywhere in a test file, once for the
429
- * whole project. Test-file discovery mirrors the traceability validator's
430
- * scanTestFilesForReferences(): TEST_PATTERNS __tests__/ tests?/ dirs, and
431
- * any occurrence of the ID in file content counts (not just @req lines).
429
+ * Collect declared FR-/SC- test links once for the whole project, using the
430
+ * validator's source eligibility and annotation/label parser. This is positive
431
+ * linkage evidence, independent of validator findings or suppression policy.
432
432
  */
433
433
  function collectTestReferencedIds(projectDir) {
434
434
  const projectFiles = [];
435
435
  scanDir(projectDir, projectDir, projectFiles);
436
- const testFiles = projectFiles.filter(f =>
437
- TEST_PATTERNS.some(p => p.test(f)) || /__tests__\//.test(f) || /tests?\//.test(f)
438
- );
439
-
440
- const ids = new Set();
441
- for (const rel of testFiles) {
442
- let content;
443
- try { content = readFileSync(resolve(projectDir, rel), 'utf-8'); } catch { continue; }
444
- FEATURE_REQ_RE.lastIndex = 0;
445
- let m;
446
- while ((m = FEATURE_REQ_RE.exec(content)) !== null) ids.add(m[0]);
447
- }
448
- return ids;
436
+ return scanTestFilesForReferences(projectDir, projectFiles, [FEATURE_REQ_RE]);
449
437
  }
450
438
 
451
439
  /**
452
440
  * Compute the four adherence signals for one detected spec-kit feature.
453
441
  * Each signal: { applicable, value (0..1 | null), ...n/m detail fields }.
454
442
  */
455
- function computeFeatureSignals(projectDir, feature, testRefIds) {
443
+ function computeFeatureSignals(projectDir, feature, testRefIds, definitions) {
456
444
  // ── artifactCompleteness — always measurable ──
457
445
  const artifactValue = (feature.hasSpec ? 0.4 : 0)
458
446
  + (feature.hasPlan ? 0.3 : 0)
@@ -483,21 +471,11 @@ function computeFeatureSignals(projectDir, feature, testRefIds) {
483
471
  }
484
472
  }
485
473
 
486
- // ── reqCoverage — spec.md IDs that appear in ANY test file ──
487
- const specIds = [];
488
- if (feature.hasSpec && feature.specPath) {
489
- try {
490
- const spec = readFileSync(feature.specPath, 'utf-8');
491
- const seen = new Set();
492
- FEATURE_REQ_RE.lastIndex = 0;
493
- let m;
494
- while ((m = FEATURE_REQ_RE.exec(spec)) !== null) {
495
- if (!seen.has(m[0])) { seen.add(m[0]); specIds.push(m[0]); }
496
- }
497
- } catch { /* unreadable spec → no IDs */ }
498
- }
499
- const covered = specIds.filter(id => testRefIds.has(id));
500
- const uncovered = specIds.filter(id => !testRefIds.has(id));
474
+ // ── reqCoverage — spec.md IDs declared in eligible test annotations or labels ──
475
+ const specPath = feature.specPath ? relative(projectDir, feature.specPath).replaceAll('\\', '/') : null;
476
+ const specIds = [...definitions.values()].filter(def => def.file === specPath).map(def => def.id);
477
+ const covered = specIds.filter(id => testRefIds.has(`${specPath}#${id}`));
478
+ const uncovered = specIds.filter(id => !testRefIds.has(`${specPath}#${id}`));
501
479
 
502
480
  return {
503
481
  reqCoverage: {
@@ -606,10 +584,11 @@ export function runTraceFeatures(projectDir, config, flags) {
606
584
  return;
607
585
  }
608
586
 
609
- const testRefIds = collectTestReferencedIds(projectDir);
587
+ const definitions = collectRequirementIds(projectDir, config, [FEATURE_REQ_RE]);
588
+ const testRefIds = resolveRequirementReferences(definitions, collectTestReferencedIds(projectDir));
610
589
 
611
590
  const features = speckit.specs.map(f => {
612
- const signals = computeFeatureSignals(projectDir, f, testRefIds);
591
+ const signals = computeFeatureSignals(projectDir, f, testRefIds, definitions);
613
592
  const score = scoreFromSignals(signals);
614
593
  const weakest = weakestSignal(signals);
615
594
  const needsFix = weakest !== null && signals[weakest].value < 1;
package/cli/docguard.mjs CHANGED
@@ -652,6 +652,8 @@ async function main() {
652
652
  command !== 'setup' &&
653
653
  command !== 'init' &&
654
654
  !READ_ONLY_COMMANDS.has(command) &&
655
+ // Agent-family staleness checks must not bootstrap skills or Spec Kit.
656
+ !(command === 'agents' && flags.check) &&
655
657
  !headless
656
658
  ) {
657
659
  ensureSkills(projectDir, flags);
@@ -733,8 +735,9 @@ async function main() {
733
735
  }
734
736
  break;
735
737
  case 'agents':
736
- // v0.20: deprecated dispatches through init --with
737
- await runInit(projectDir, config, { ...flags, with: ['agents'], skipPrompts: true });
738
+ // A staleness check must bypass init's scaffolding and skill installation.
739
+ if (flags.check) runAgents(projectDir, config, flags);
740
+ else await runInit(projectDir, config, { ...flags, with: ['agents'], skipPrompts: true });
738
741
  break;
739
742
  case 'generate':
740
743
  runGenerate(projectDir, config, flags);
@@ -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 */ }
@@ -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
 
@@ -297,6 +297,9 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
297
297
 
298
298
  // ── Step 2: Scan test files for requirement ID references ──
299
299
  const testRefs = scanTestFilesForReferences(projectDir, projectFiles, patterns);
300
+ const resolvedRefs = resolveRequirementReferences(reqIds, testRefs);
301
+ const definitionCounts = new Map();
302
+ for (const def of reqIds.values()) definitionCounts.set(def.id, (definitionCounts.get(def.id) || 0) + 1);
300
303
 
301
304
  // ── Step 3: Report traceability results ──
302
305
 
@@ -308,14 +311,15 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
308
311
  let testCorpus = null;
309
312
 
310
313
  // Check each documented requirement has at least one test reference
311
- for (const [reqId, location] of reqIds) {
314
+ for (const [key, location] of reqIds) {
315
+ const reqId = location.id;
312
316
  total++;
313
- if (testRefs.has(reqId)) {
317
+ if (resolvedRefs.has(key)) {
314
318
  passed++;
315
319
  } else {
316
320
  // Try to recover a likely-but-unannotated test via TF-IDF cosine.
317
321
  let softHint = '';
318
- let softText = `Review existing tests for this requirement. If a test verifies it, add an @req ${reqId} annotation or requirement ID test label; write a test only if behavioral coverage is actually missing.`;
322
+ let softText = `Review existing tests for this requirement. If a test verifies it, add an @req ${key} annotation or requirement ID test label; write a test only if behavioral coverage is actually missing.`;
319
323
  const queryText = location.text && location.text.length > reqId.length ? location.text : reqId;
320
324
  if (testCorpus === null) testCorpus = buildTestCorpus(projectDir, projectFiles);
321
325
  if (testCorpus.length > 0) {
@@ -324,14 +328,14 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
324
328
  if (top && top.score >= softThreshold) {
325
329
  const pct = (top.score * 100).toFixed(0);
326
330
  softHint = ` — IR soft-match: ${top.id} (${pct}% similar) may already cover it`;
327
- softText = `Review ${top.id} as a candidate (${pct}% text similarity, not coverage evidence). Add @req ${reqId} only if it verifies the requirement; otherwise inspect other tests before deciding a new test is needed.`;
331
+ softText = `Review ${top.id} as a candidate (${pct}% text similarity, not coverage evidence). Add @req ${key} only if it verifies the requirement; otherwise inspect other tests before deciding a new test is needed.`;
328
332
  }
329
333
  }
330
334
  findings.push(mkFinding({
331
335
  code: 'TRC004',
332
336
  validator: 'traceability',
333
337
  severity: 'warn',
334
- message: `Requirement ${reqId} (${location.file}:${location.line}) has no recognized test annotation or label; behavioral coverage is unknown.${softHint}`,
338
+ message: `Requirement ${reqId} (${location.file}:${location.line}) has no recognized test annotation or label; behavioral coverage is unknown.${definitionCounts.get(reqId) > 1 ? ` This ID occurs in multiple documents; use ${key} to disambiguate test references.` : ""}${softHint}`,
335
339
  location: `${location.file}:${location.line}`,
336
340
  suggestion: { kind: 'review', text: softText },
337
341
  }));
@@ -340,15 +344,16 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
340
344
 
341
345
  // Check for orphaned test refs (tests referencing non-existent requirements)
342
346
  for (const [reqId, refs] of testRefs) {
343
- if (!reqIds.has(reqId)) {
347
+ const orphan = refs.find(ref => ref.scope ? !reqIds.has(`${ref.scope}#${reqId}`) : !definitionCounts.has(reqId));
348
+ if (orphan) {
344
349
  total++;
345
350
  findings.push(mkFinding({
346
351
  code: 'TRC005',
347
352
  validator: 'traceability',
348
353
  severity: 'warn',
349
- message: `Test references ${reqId} (${refs[0].file}:${refs[0].line}) but no requirement ` +
354
+ message: `Test references ${orphan.scope ? `${orphan.scope}#` : ""}${reqId} (${orphan.file}:${orphan.line}) but no requirement ` +
350
355
  `with this ID exists in documentation. Remove the reference or add the requirement to docs`,
351
- location: `${refs[0].file}:${refs[0].line}`,
356
+ location: `${orphan.file}:${orphan.line}`,
352
357
  suggestion: { kind: 'review', text: 'Remove the stale reference, or add the requirement to the documentation' },
353
358
  }));
354
359
  }
@@ -357,7 +362,7 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
357
362
  return { findings, passed, total };
358
363
  }
359
364
 
360
- function collectRequirementIds(projectDir, config, patterns) {
365
+ export function collectRequirementIds(projectDir, config, patterns = DEFAULT_REQ_PATTERNS) {
361
366
  const reqIds = new Map(); // reqId → { file, line }
362
367
  const docSearchPaths = getRequirementDocPaths(projectDir, config);
363
368
 
@@ -371,7 +376,7 @@ function collectRequirementIds(projectDir, config, patterns) {
371
376
  if (!hasMatch) continue;
372
377
 
373
378
  const lines = content.split('\n');
374
- const docName = relative(projectDir, docPath);
379
+ const docName = relative(projectDir, docPath).replaceAll("\\", "/");
375
380
 
376
381
  let fence = null;
377
382
  let exampleLevel = null;
@@ -418,8 +423,9 @@ function collectRequirementIds(projectDir, config, patterns) {
418
423
  }
419
424
  const reqId = match[0];
420
425
  if (!reqId.length) { pattern.lastIndex++; continue; }
421
- if (!reqIds.has(reqId)) {
422
- reqIds.set(reqId, { file: docName, line: i + 1, text: line.trim() });
426
+ const key = `${docName}#${reqId}`;
427
+ if (!reqIds.has(key)) {
428
+ reqIds.set(key, { id: reqId, file: docName, line: i + 1, text: line.trim() });
423
429
  }
424
430
  }
425
431
  }
@@ -429,6 +435,26 @@ function collectRequirementIds(projectDir, config, patterns) {
429
435
  return reqIds;
430
436
  }
431
437
 
438
+ /** Resolve positive test links without sharing evidence between document scopes. */
439
+ export function resolveRequirementReferences(definitions, references) {
440
+ const byId = new Map();
441
+ for (const [key, definition] of definitions) {
442
+ if (!byId.has(definition.id)) byId.set(definition.id, []);
443
+ byId.get(definition.id).push(key);
444
+ }
445
+ const resolved = new Map();
446
+ for (const [id, refs] of references) {
447
+ const candidates = byId.get(id) || [];
448
+ for (const ref of refs) {
449
+ const key = ref.scope ? `${ref.scope}#${id}` : candidates.length === 1 ? candidates[0] : null;
450
+ if (!key || !definitions.has(key)) continue;
451
+ if (!resolved.has(key)) resolved.set(key, []);
452
+ resolved.get(key).push(ref);
453
+ }
454
+ }
455
+ return resolved;
456
+ }
457
+
432
458
  // A mention in fixture data is not a coverage declaration. Keep the same ID
433
459
  // patterns, but apply them only to annotations and test labels. In particular,
434
460
  // prose discussing an annotation ("never annotates @req ...") is not one.
@@ -495,7 +521,14 @@ function testDeclarations(content, filename) {
495
521
  return declarations;
496
522
  }
497
523
 
498
- function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
524
+ /**
525
+ * Read explicit requirement annotations and test labels from eligible test sources.
526
+ * Shared by validation and feature scoring; fixture data is not linkage evidence.
527
+ * Callers supply project-relative candidate paths and global requirement regexes.
528
+ * No files are written and no findings or suppression policy are consulted.
529
+ * @returns {Map<string, Array<{file: string, line: number}>>} ID to declaration locations
530
+ */
531
+ export function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
499
532
  const testFiles = projectFiles.filter(isTestSource);
500
533
 
501
534
  const testRefs = new Map(); // reqId → [{ file, line }]
@@ -520,7 +553,12 @@ function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
520
553
  const reqId = match[0];
521
554
  if (!testRefs.has(reqId)) testRefs.set(reqId, []);
522
555
  const line = declaration.line + (declaration.text.slice(0, match.index).match(/\n/g) || []).length;
523
- testRefs.get(reqId).push({ file: relPath, line });
556
+ // A document qualifier is repository-relative and exact; never fall
557
+ // back to a bare ID when a supplied qualifier fails to resolve.
558
+ const prefix = declaration.text.slice(0, match.index);
559
+ const qualifier = prefix.match(/([^\s`"'<>()[\]{}]+)#$/);
560
+ const scope = qualifier ? qualifier[1].replaceAll('\\', '/').replace(/^\.\//, '') : null;
561
+ testRefs.get(reqId).push({ file: relPath, line, scope });
524
562
  }
525
563
  }
526
564
  }
@@ -198,3 +198,18 @@ These statuses skip currentness assertions; they do not hide structural or other
198
198
  Guard JSON includes checkCoverage and an applicability record per validator. States distinguish checked, partial, disabled, not-applicable, missing-prerequisite, unsupported, no-matches, and error. A passing gate means the selected policy passed; it does not mean unsupported languages or unmatched inputs were examined. CI and reports preserve this disclosure. Python import-graph analysis remains unsupported; mixed Python/JS projects disclose partial architecture coverage.
199
199
 
200
200
  Wrangler configuration supplies evidence for Worker classification. Supported typed Worker bindings participate in environment extraction without executing configuration or application code. Dynamic names, alias/dataflow tracking, and unsupported forms remain outside this bounded analysis. The existing optional Babel parser resolves lexical bindings; the fallback covers ordinary tested scopes and has lower syntax coverage.
201
+
202
+ ### Evidence boundaries in documentation and schema scans
203
+
204
+ Documentation-coverage filename checks search supported Markdown in conventional and explicitly configured homes, mapped role files, and supported extension metadata. Configured homes extend the defaults. Excluded files, private paths and symlinks cannot satisfy a documentation reference. This search does not establish absence of an explanation in external sites, RST, or unsupported formats.
205
+
206
+ A parsed direct file-read/write call supplies stronger path evidence than a path construction or existence check. Ambiguous paths remain low-confidence review signals; a directory name alone does not establish a configuration file. The bounded detector does not resolve arbitrary import aliases or dynamic paths. Schema synchronization applies source exclusions and deduplicates overlapping roots by source file, preserving models in distinct files even when names match.
207
+
208
+ Feature requirement scoring recognizes eligible test annotations and labels using the validator parser. An arbitrary fixture string is not linkage evidence, and a recognized link is not proof of behavioral coverage. Duplicate requirement IDs across separate specifications remain a known scoping limitation.
209
+
210
+
211
+ ### Requirement identity across documents
212
+
213
+ Requirement definitions are identified by repository-relative document path plus ID. A bare test annotation such as `@req FR-001` earns linkage credit only when that ID is defined in one document. When features reuse an ID, qualify the declaration: `@req specs/payments/spec.md#FR-001`. The same spelling works in a test label. Use forward slashes; an optional leading `./` is accepted. Qualifiers are exact repository-relative paths, not paths relative to the test file.
214
+
215
+ Validation and `trace --features` share definition parsing and reference resolution. A qualified reference credits only its target document. Ambiguous bare references credit neither feature and produce a review finding for each unresolved definition. A wrong qualifier is an orphan reference and never falls back to a bare match. Repeated mentions within one document do not create additional identities. Linkage remains evidence of a declaration, not proof of behavioral correctness; lifecycle and arbitrary verification-link semantics are separate concerns.
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.36.1"
6
+ version: "0.36.2"
7
7
  description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
8
8
  author: "Ricardo Accioly"
9
9
  repository: "https://github.com/raccioly/docguard"
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.36.1
9
+ version: 0.36.2
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.36.1 -->
12
+ <!-- docguard:version: 0.36.2 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15
 
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
7
7
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
8
8
  metadata:
9
9
  author: docguard
10
- version: 0.36.1
10
+ version: 0.36.2
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.36.1 -->
13
+ <!-- docguard:version: 0.36.2 -->
14
14
 
15
15
  # DocGuard Guard Skill
16
16
 
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.36.1
9
+ version: 0.36.2
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.36.1 -->
12
+ <!-- docguard:version: 0.36.2 -->
13
13
 
14
14
  # DocGuard Review Skill
15
15
 
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.36.1
9
+ version: 0.36.2
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.36.1 -->
12
+ <!-- docguard:version: 0.36.2 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
4
4
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
5
5
  metadata:
6
6
  author: docguard
7
- version: 0.36.1
7
+ version: 0.36.2
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.36.1 -->
10
+ <!-- docguard:version: 0.36.2 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
 
37
37
  - name: Install DocGuard
38
- run: npm install --global --ignore-scripts docguard-cli@0.36.1
38
+ run: npm install --global --ignore-scripts docguard-cli@0.36.2
39
39
 
40
40
  - name: Run DocGuard
41
41
  shell: bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,7 @@ jobs:
31
31
  node-version: '20'
32
32
 
33
33
  - name: Install DocGuard
34
- run: npm install --global --ignore-scripts docguard-cli@0.36.1
34
+ run: npm install --global --ignore-scripts docguard-cli@0.36.2
35
35
 
36
36
  - name: Run DocGuard
37
37
  shell: bash