docguard-cli 0.36.2 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +23 -18
  2. package/cli/commands/explain.mjs +28 -0
  3. package/cli/commands/guard.mjs +4 -0
  4. package/cli/commands/llms.mjs +3 -2
  5. package/cli/commands/retire.mjs +352 -0
  6. package/cli/commands/specs.mjs +77 -0
  7. package/cli/commands/trace.mjs +11 -1
  8. package/cli/config.mjs +2 -0
  9. package/cli/docguard.mjs +78 -18
  10. package/cli/findings.mjs +54 -0
  11. package/cli/scanners/document-lifecycle.mjs +184 -0
  12. package/cli/scanners/requirement-evidence.mjs +126 -0
  13. package/cli/scanners/spec-registry.mjs +517 -0
  14. package/cli/shared-requirements.mjs +91 -0
  15. package/cli/validators/document-lifecycle.mjs +51 -0
  16. package/cli/validators/spec-registry.mjs +47 -0
  17. package/cli/validators/traceability.mjs +52 -216
  18. package/docs/ai-integration.md +18 -5
  19. package/docs/commands.md +45 -0
  20. package/docs/quickstart.md +1 -1
  21. package/extensions/spec-kit-docguard/README.md +15 -5
  22. package/extensions/spec-kit-docguard/commands/brief.md +34 -0
  23. package/extensions/spec-kit-docguard/commands/preflight.md +52 -0
  24. package/extensions/spec-kit-docguard/extension.yml +18 -5
  25. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  26. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  27. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  28. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  29. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  30. package/extensions/spec-kit-docguard/templates/extensions.yml +13 -6
  31. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  32. package/package.json +1 -1
  33. package/schemas/docguard-config.schema.json +2 -0
  34. package/schemas/docguard-specs.schema.json +162 -0
  35. package/templates/ci/github-actions.yml +1 -1
@@ -433,7 +433,17 @@ function featureBar(score) {
433
433
  function collectTestReferencedIds(projectDir) {
434
434
  const projectFiles = [];
435
435
  scanDir(projectDir, projectDir, projectFiles);
436
- return scanTestFilesForReferences(projectDir, projectFiles, [FEATURE_REQ_RE]);
436
+ const references = scanTestFilesForReferences(projectDir, projectFiles, [FEATURE_REQ_RE]);
437
+ // Feature completion evidence must survive spec retirement and ID reuse.
438
+ // Bare FR-001 references can be useful navigation for the repo-wide matrix,
439
+ // but they can rebind to a different feature after the original spec leaves
440
+ // the working tree. Keep only document-qualified identities here.
441
+ const qualified = new Map();
442
+ for (const [id, refs] of references) {
443
+ const scoped = refs.filter(ref => ref.scope);
444
+ if (scoped.length > 0) qualified.set(id, scoped);
445
+ }
446
+ return qualified;
437
447
  }
438
448
 
439
449
  /**
package/cli/config.mjs CHANGED
@@ -79,6 +79,8 @@ export function loadConfig(projectDir) {
79
79
  security: false,
80
80
  environment: true,
81
81
  freshness: true,
82
+ documentLifecycle: true,
83
+ specRegistry: true,
82
84
  // v0.31.0 — all three default ON. Soft (confidence:low, never break CI),
83
85
  // heuristic (field cases require ongoing precision checks), and quiet when
84
86
  // not applicable (no diff / no API-reference doc). api-doc-smells is
package/cli/docguard.mjs CHANGED
@@ -51,6 +51,8 @@ import { runMemory } from './commands/memory.mjs';
51
51
  import { runDemo } from './commands/demo.mjs';
52
52
  import { runAgent } from './commands/agent.mjs';
53
53
  import { runMcp } from './commands/mcp.mjs';
54
+ import { runArchive } from './commands/retire.mjs';
55
+ import { runSpecs } from './commands/specs.mjs';
54
56
  import { ensureSkills } from './ensure-skills.mjs';
55
57
 
56
58
  // ── Shared constants (imported to break circular dependencies) ──────────
@@ -97,6 +99,8 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
97
99
  ${c.green}report${c.reset} Compliance-evidence bundle — guard + score + ALCOA+ + integrity hash (${c.cyan}--format json${c.reset}, ${c.cyan}--out <file>${c.reset})
98
100
  ${c.green}ci${c.reset} Pipeline gate: guard + score in one command (${c.cyan}--threshold <n>${c.reset}, ${c.cyan}--fail-on-warning${c.reset}, ${c.cyan}--format json${c.reset}; records score history)
99
101
  ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
102
+ ${c.green}retire${c.reset} Remove reviewed docs from active AI context (${c.cyan}--plan${c.reset}; explicit ${c.cyan}--write --path${c.reset})
103
+ ${c.green}specs${c.reset} Track spec lifecycle and evidence (${c.cyan}--check|--write${c.reset}; ${c.cyan}preflight --path <spec>${c.reset})
100
104
  ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map, ${c.cyan}--features${c.reset} for per-feature adherence)
101
105
  ${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
102
106
  ${c.green}watch${c.reset} Live mode: re-run guard on file changes
@@ -109,12 +113,12 @@ ${c.bold}init --with <name>${c.reset} ${c.dim}— optional scaffolders, picked a
109
113
  ${c.dim}llms${c.reset} llms.txt generation
110
114
  ${c.dim}publish${c.reset} External doc-site scaffold (Mintlify) ${c.dim}— experimental${c.reset}
111
115
 
112
- ${c.bold}Deprecation aliases${c.reset} ${c.dim}— still work in v0.20.x with a yellow warning${c.reset}
116
+ ${c.bold}Deprecation aliases${c.reset} ${c.dim}— supported until v1.0 with a yellow warning${c.reset}
113
117
  ${c.dim}setup${c.reset} → ${c.cyan}init --wizard${c.reset}
114
118
  ${c.dim}agents · hooks · badge · llms · publish${c.reset} → ${c.cyan}init --with <name>${c.reset}
115
119
  ${c.dim}impact${c.reset} → ${c.cyan}diff --since <ref>${c.reset}
116
120
  ${c.dim}audit${c.reset} → ${c.green}guard${c.reset} ${c.dim}(permanent — no warning, no removal planned)${c.reset}
117
- ${c.dim}See docs-implementation/MIGRATION-v0.20.md for the full timeline.${c.reset}
121
+ ${c.dim}Run the legacy form to see its replacement.${c.reset}
118
122
 
119
123
  ${c.bold}Options:${c.reset}
120
124
  --dir <path> Project directory (default: current directory)
@@ -129,9 +133,9 @@ ${c.bold}Options:${c.reset}
129
133
  --threshold <n> Minimum score for CI pass (used with ci command)
130
134
  --fail-on-warning Fail CI on warnings (used with ci command)
131
135
  --auto Auto-fix what's possible (used with fix command)
132
- --write Apply deterministic fixes in place (fix command): removes
133
- documented endpoints the OpenAPI spec confirms are gone.
134
- Only edits docguard:generated docs unless --force.
136
+ --write Apply a command's explicit deterministic write path. For fix,
137
+ only edits docguard:generated docs unless --force; specs
138
+ refreshes observed registry evidence; retire requires --path.
135
139
  --plan AI-powered Generate (generate command): scan any project
136
140
  (JS/Python/Rust/Go/Java/…), emit the agent task manifest +
137
141
  code-truth skeleton. Add --write to scaffold, --format json
@@ -308,6 +312,35 @@ const COMMAND_HELP = {
308
312
  ],
309
313
  examples: ['docguard verify --semantic', 'docguard verify --semantic --format json'],
310
314
  },
315
+ retire: {
316
+ summary: 'Remove reviewed docs from active AI context while preserving recovery from a retained Git ref.',
317
+ usage: 'docguard retire [--plan|--check] | --write --path <document> [--path <document>...] --reason <text> [--superseded-by <document>] [--evidence <document>...] [--retention-ref <ref>]',
318
+ flags: [
319
+ ['--plan', 'Read-only lifecycle candidate inventory (default behavior)'],
320
+ ['--check', 'Exit 2 when high-confidence lifecycle candidates remain'],
321
+ ['--fail-on-warning', 'With --check: also gate low-confidence review candidates'],
322
+ ['--write', 'Retire only the explicitly selected clean tracked documents'],
323
+ ['--path <path>', 'Documentation file or document-only directory; repeatable'],
324
+ ['--reason <text>', 'Required explanation recorded in the archive manifest'],
325
+ ['--superseded-by <path>', 'Current document that replaces the archived material'],
326
+ ['--evidence <path>', 'Clean current document that contains the consolidated outcome; repeatable'],
327
+ ['--retention-ref <ref>', 'Branch ref that must retain the source revision'],
328
+ ['--format json', 'Machine-readable plan or result'],
329
+ ],
330
+ examples: ['docguard retire --plan', 'docguard retire --check --format json', 'docguard retire --write --path specs/001-done --reason "Implemented in v1.2"'],
331
+ },
332
+ specs: {
333
+ summary: 'Maintain the deterministic spec lifecycle and evidence registry.',
334
+ usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] [--format json]',
335
+ flags: [
336
+ ['--check', 'Exit 2 when the committed registry is missing, stale, or inconsistent'],
337
+ ['--write', 'Refresh observed evidence while preserving reviewed lifecycle fields'],
338
+ ['preflight', 'Brief prior specs, or gate a generated draft with --path'],
339
+ ['--path <spec>', 'Generated spec to compare against current lifecycle state'],
340
+ ['--format json', 'Machine-readable registry or preflight result'],
341
+ ],
342
+ examples: ['docguard specs --check', 'docguard specs --write', 'docguard specs preflight', 'docguard specs preflight --path specs/007-feature/spec.md'],
343
+ },
311
344
  };
312
345
 
313
346
  function printCommandHelp(command) {
@@ -535,8 +568,26 @@ async function main() {
535
568
  flags.apiKey = args[i + 1];
536
569
  i++;
537
570
  } else if (args[i] === '--path' && args[i + 1]) {
538
- // mcp --transport http: HTTP mount path (default /mcp).
539
- flags.path = args[i + 1];
571
+ if (command === 'retire' || command === 'archive') {
572
+ flags.paths = flags.paths || [];
573
+ flags.paths.push(args[i + 1]);
574
+ } else {
575
+ // mcp --transport http: HTTP mount path (default /mcp).
576
+ flags.path = args[i + 1];
577
+ }
578
+ i++;
579
+ } else if (args[i] === '--reason' && args[i + 1]) {
580
+ flags.reason = args[i + 1];
581
+ i++;
582
+ } else if (args[i] === '--superseded-by' && args[i + 1]) {
583
+ flags.supersededBy = args[i + 1];
584
+ i++;
585
+ } else if (args[i] === '--evidence' && args[i + 1]) {
586
+ flags.evidencePaths = flags.evidencePaths || [];
587
+ flags.evidencePaths.push(args[i + 1]);
588
+ i++;
589
+ } else if (args[i] === '--retention-ref' && args[i + 1]) {
590
+ flags.retentionRef = args[i + 1];
540
591
  i++;
541
592
  } else if (args[i] === '--code') {
542
593
  flags.code = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : '';
@@ -611,10 +662,11 @@ async function main() {
611
662
  // same way runGuardInternal sees everything else.
612
663
  if (flags.noBaseline) config.baseline = false;
613
664
 
614
- // Commands that only READ and REPORT they must never mutate the working
615
- // tree. Scaffolding (ensureSkills .agent/.specify, spawning `specify`)
616
- // belongs to setup/init/generate and the `init --with` family, where the
617
- // user is establishing or expanding their setup, not auditing it.
665
+ // Commands whose normal mode only reads/reports, plus explicit writers whose
666
+ // mutations must stay scoped to their named output. They must never add
667
+ // unrelated setup files. Scaffolding (ensureSkills .agent/.specify,
668
+ // spawning `specify`) belongs to setup/init/generate and `init --with`, where
669
+ // the user is establishing or expanding setup rather than inspecting it.
618
670
  //
619
671
  // v0.26 (field report Bug #3): a bare `docguard guard` used to run
620
672
  // ensureSkills → auto-init Spec Kit → spawn `specify` and write ~9 files into
@@ -624,7 +676,7 @@ async function main() {
624
676
  // `diff`/`impact` only read; `demo` runs against a throwaway fixture.)
625
677
  const READ_ONLY_COMMANDS = new Set([
626
678
  'guard', 'audit', 'score', 'diff', 'impact',
627
- 'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent',
679
+ 'diagnose', 'fix', 'trace', 'explain', 'memory', 'demo', 'agent', 'retire', 'archive', 'specs',
628
680
  // feedback only writes its own .docguard/feedback/ — it must NOT scaffold
629
681
  // skills or touch source, so it's gated out of ensureSkills like the rest.
630
682
  'feedback',
@@ -652,6 +704,7 @@ async function main() {
652
704
  command !== 'setup' &&
653
705
  command !== 'init' &&
654
706
  !READ_ONLY_COMMANDS.has(command) &&
707
+ !(command === 'hooks' && flags.list) &&
655
708
  // Agent-family staleness checks must not bootstrap skills or Spec Kit.
656
709
  !(command === 'agents' && flags.check) &&
657
710
  !headless
@@ -659,10 +712,9 @@ async function main() {
659
712
  ensureSkills(projectDir, flags);
660
713
  }
661
714
 
662
- // v0.20: deprecation aliases. The legacy command keeps working through v0.20
715
+ // v0.20: deprecation aliases. The legacy command keeps working until v1.0
663
716
  // and emits a yellow stderr warning suggesting the new shape. Quiet mode
664
717
  // (e.g. inside hooks) suppresses the warning so CI output stays clean.
665
- // The full deprecation timeline is in docs-implementation/MIGRATION-v0.20.md.
666
718
  const DEPRECATED_COMMANDS = {
667
719
  setup: { since: '0.20', replacement: 'docguard init --wizard' },
668
720
  agents: { since: '0.20', replacement: 'docguard init --with agents' },
@@ -694,7 +746,7 @@ async function main() {
694
746
  if (DROPPED_ALIASES[command]) {
695
747
  console.error(`${c.red}Unknown command: ${command}${c.reset}`);
696
748
  console.error(`${c.yellow}Hint: this alias was removed in v0.20. Try ${c.cyan}docguard ${DROPPED_ALIASES[command]}${c.yellow}.${c.reset}`);
697
- console.error(`${c.dim}See docs-implementation/MIGRATION-v0.20.md for the full list.${c.reset}`);
749
+ console.error(`${c.dim}Use the replacement shown above; removed aliases are not restored.${c.reset}`);
698
750
  process.exit(1);
699
751
  }
700
752
 
@@ -703,7 +755,7 @@ async function main() {
703
755
  if (DEPRECATED_COMMANDS[command] && !flags.quiet && !(command === 'hooks' && flags.claude)) {
704
756
  const { since, replacement } = DEPRECATED_COMMANDS[command];
705
757
  console.error(`${c.yellow}⚠ Deprecated since v${since}:${c.reset} ${c.cyan}docguard ${command}${c.reset} → use ${c.cyan}${replacement}${c.reset}`);
706
- console.error(`${c.dim} The old form still works in v0.20.x but will be removed in v1.0. See MIGRATION-v0.20.md.${c.reset}`);
758
+ console.error(`${c.dim} The old form remains compatible until v1.0.${c.reset}`);
707
759
  }
708
760
 
709
761
  switch (command) {
@@ -748,8 +800,9 @@ async function main() {
748
800
  runAgent(projectDir, config, flags);
749
801
  break;
750
802
  case 'hooks':
751
- if (flags.claude) {
752
- // Agent nudge hook (.claude/settings.json) direct path, no wizard.
803
+ if (flags.claude || flags.list) {
804
+ // Agent nudge and read-only inventory paths bypass the setup wizard.
805
+ // Listing hooks must not refresh unrelated skills in the repository.
753
806
  runHooks(projectDir, config, flags);
754
807
  break;
755
808
  }
@@ -830,6 +883,13 @@ async function main() {
830
883
  case 'memory':
831
884
  runMemory(projectDir, config, flags);
832
885
  break;
886
+ case 'retire':
887
+ case 'archive': // Development alias; `retire` avoids collision with Spec Kit Archive.
888
+ runArchive(projectDir, config, flags);
889
+ break;
890
+ case 'specs':
891
+ runSpecs(projectDir, config, flags);
892
+ break;
833
893
  case 'demo':
834
894
  // v0.21: zero-install "ah-ha" moment — runs guard against a baked-in
835
895
  // fixture (templates/demo-fixture/) and prints curated drift findings
package/cli/findings.mjs CHANGED
@@ -486,6 +486,60 @@ export const CODES = {
486
486
  help: 'Guard reports at most 10 phantom-completion findings (SPK008) per run to avoid noise; this line counts the remainder. Fix or uncheck the reported tasks and re-run guard to surface more, or set `"specKit": { "phantomCheck": false }` in .docguard.json to disable the check.',
487
487
  suppress: null,
488
488
  },
489
+ DLC001: {
490
+ validator: 'documentLifecycle',
491
+ title: 'Terminal document remains in active context',
492
+ help: 'A tracked Markdown file declares itself superseded, deprecated, obsolete, or archived but remains searchable as current repository context. Confirm its outcomes are represented in current docs, then use `docguard retire --write --path <path> --reason <reason>`.',
493
+ suppress: null,
494
+ },
495
+ DLC002: {
496
+ validator: 'documentLifecycle',
497
+ title: 'Completed task list needs lifecycle review',
498
+ help: 'A spec has checked tasks and no open tasks. This is a review signal, not proof that the feature shipped. Confirm release evidence and current documentation before archiving the spec.',
499
+ suppress: null,
500
+ },
501
+ DLC003: {
502
+ validator: 'documentLifecycle',
503
+ title: 'Document lifecycle coverage incomplete',
504
+ help: 'DocGuard could not enumerate or read every tracked Markdown document. Fix Git access or file readability and rerun guard; an incomplete lifecycle scan must not be interpreted as a clean result.',
505
+ suppress: null,
506
+ },
507
+ DLC004: {
508
+ validator: 'documentLifecycle',
509
+ title: 'Retired document remains active',
510
+ help: 'The retirement manifest records this path, but the document still exists in the working tree. Remove it through the reviewed retirement transaction or remove the incorrect manifest event; the two states must agree.',
511
+ suppress: null,
512
+ },
513
+ SPR001: {
514
+ validator: 'specRegistry',
515
+ title: 'Spec registry missing or stale',
516
+ help: 'The committed `.docguard-specs.json` does not match the deterministic projection of current specs, tasks, tests, and archive tombstones. Run `docguard specs --write`, review the diff, and commit it with the behavior change.',
517
+ suppress: null,
518
+ },
519
+ SPR002: {
520
+ validator: 'specRegistry',
521
+ title: 'Spec identity missing or reused',
522
+ help: 'Every active spec needs one immutable lowercase Spec ID in authoritative metadata. IDs survive moves and retirement and may never be reused. Add `**Spec ID**: ` followed by a unique namespaced ID in backticks.',
523
+ suppress: null,
524
+ },
525
+ SPR003: {
526
+ validator: 'specRegistry',
527
+ title: 'Spec registry is invalid',
528
+ help: 'The registry is malformed or contains an unsupported lifecycle value. DocGuard refuses to overwrite invalid reviewed state. Repair the named field, then run `docguard specs --write`.',
529
+ suppress: null,
530
+ },
531
+ SPR004: {
532
+ validator: 'specRegistry',
533
+ title: 'Spec lifecycle contradicts storage',
534
+ help: 'Working-tree presence, lifecycle context, storage state, and the recovery archive disagree. Active specs must be current/working_tree; retired specs must be retired/git_history with a reason and matching archive event.',
535
+ suppress: null,
536
+ },
537
+ SPR005: {
538
+ validator: 'specRegistry',
539
+ title: 'New spec overlaps prior intent',
540
+ help: 'Preflight found lexical overlap with a prior spec. This is a review hint, never proof of duplication or delivery. Inspect the prior lifecycle and evidence before adding an explicit supersedes or related relationship.',
541
+ suppress: null,
542
+ },
489
543
  XRF001: {
490
544
  validator: 'crossReference',
491
545
  title: 'Broken doc link',
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Read-only document lifecycle signals shared by `retire` and guard.
3
+ * Signals identify review candidates; they never authorize deletion.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { shouldIgnore } from '../shared-ignore.mjs';
10
+
11
+ const RETIRED_STATUSES = new Set(['archived', 'deprecated', 'obsolete', 'superseded']);
12
+ const COMPLETION_STATUSES = new Set(['complete', 'completed']);
13
+ const EXCLUDED_PREFIXES = ['.git', '.local', '.docguard'];
14
+ const MANIFEST_PATH = '.docguard-archive.json';
15
+
16
+ export function readRetirementManifest(projectDir) {
17
+ const path = resolve(projectDir, MANIFEST_PATH);
18
+ if (!existsSync(path)) return { ok: true, paths: new Set(), entries: [], retention: null, error: null };
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
21
+ if (parsed?.schemaVersion !== 1 || parsed?.strategy !== 'git-history' || !Array.isArray(parsed.entries)) {
22
+ throw new Error('unsupported schema');
23
+ }
24
+ const globalRecovery = parsed.retention?.recoverability === 'verified'
25
+ && typeof parsed.retention?.ref === 'string'
26
+ && /^(?:sha1|sha256)$/.test(parsed.retention?.objectFormat || '');
27
+ for (const entry of parsed.entries) {
28
+ const path = entry?.path;
29
+ const entryRecovery = entry?.recoverability === 'verified'
30
+ && typeof entry?.retentionRef === 'string'
31
+ && /^(?:sha1|sha256)$/.test(entry?.objectFormat || '');
32
+ if (typeof path !== 'string' || !path || path.startsWith('/')
33
+ || path.replaceAll('\\', '/').split('/').includes('..')
34
+ || typeof entry?.archivedFrom !== 'string'
35
+ || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.archivedFrom)
36
+ || typeof entry?.blob !== 'string'
37
+ || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.blob)
38
+ || typeof entry?.reason !== 'string' || !entry.reason.trim()
39
+ || (entry?.requirementIds !== undefined && (!Array.isArray(entry.requirementIds)
40
+ || entry.requirementIds.some(id => typeof id !== 'string' || !id || id.length > 128 || /[\s#\0]/.test(id))))
41
+ || (entry?.specId !== undefined && (typeof entry.specId !== 'string'
42
+ || !/^[a-z0-9][a-z0-9._-]{2,127}$/.test(entry.specId)))
43
+ || (!globalRecovery && !entryRecovery)) {
44
+ throw new Error(`invalid recovery entry for ${path || '<unknown path>'}`);
45
+ }
46
+ }
47
+ return {
48
+ ok: true,
49
+ paths: new Set(parsed.entries.map(entry => entry.path)),
50
+ entries: parsed.entries,
51
+ retention: parsed.retention || null,
52
+ error: null,
53
+ };
54
+ } catch (error) {
55
+ return { ok: false, paths: new Set(), entries: [], retention: null, error: `${MANIFEST_PATH}: ${error.message}` };
56
+ }
57
+ }
58
+
59
+ function trackedFiles(projectDir) {
60
+ const result = spawnSync('git', ['ls-files', '-z'], {
61
+ cwd: projectDir,
62
+ encoding: 'utf8',
63
+ maxBuffer: 8 * 1024 * 1024,
64
+ });
65
+ if (result.status !== 0) {
66
+ const error = result.error?.message || result.stderr || result.stdout || 'git ls-files failed';
67
+ return {
68
+ ok: false,
69
+ files: [],
70
+ reason: /not a git repository|must be run in a work tree/i.test(error)
71
+ ? 'not-git'
72
+ : 'git-unavailable',
73
+ error: error.trim(),
74
+ };
75
+ }
76
+ return { ok: true, files: result.stdout.split('\0').filter(Boolean), reason: null, error: null };
77
+ }
78
+
79
+ export function parseLifecycleStatus(content) {
80
+ const header = content.split('\n').slice(0, 40).join('\n');
81
+ const match = header.match(/(?:^|\n)\s*(?:#{1,6}\s*)?(?:\*\*)?Status(?::)?(?:\*\*)?\s*:?\s*(?:\*\*)?([^*\n]+)/i);
82
+ return match?.[1]?.trim() || null;
83
+ }
84
+
85
+ function completedTaskSignal(projectDir, path, content, tracked) {
86
+ let taskContent = content;
87
+ if (!/(?:^|\n)\s*- \[[ xX]\]/.test(taskContent) && /(?:^|\/)spec\.md$/i.test(path)) {
88
+ const taskRel = `${dirname(path)}/tasks.md`;
89
+ const taskPath = resolve(projectDir, taskRel);
90
+ if (tracked.has(taskRel) && existsSync(taskPath)) taskContent = readFileSync(taskPath, 'utf8');
91
+ }
92
+ const checked = taskContent.match(/(?:^|\n)\s*- \[[xX]\]/g)?.length || 0;
93
+ const open = taskContent.match(/(?:^|\n)\s*- \[ \]/g)?.length || 0;
94
+ return checked > 0 && open === 0 ? { checked, open } : null;
95
+ }
96
+
97
+ export function scanDocumentLifecycle(projectDir, config = {}) {
98
+ const inventory = trackedFiles(projectDir);
99
+ const retired = readRetirementManifest(projectDir);
100
+ if (!inventory.ok || !retired.ok) {
101
+ return {
102
+ scanned: 0,
103
+ candidates: [],
104
+ coverage: {
105
+ status: 'unavailable',
106
+ reason: inventory.ok ? 'manifest-invalid' : inventory.reason,
107
+ error: inventory.error || retired.error,
108
+ unreadable: [],
109
+ },
110
+ };
111
+ }
112
+ const tracked = new Set(inventory.files);
113
+ const markdown = inventory.files.filter(path => /\.md$/i.test(path));
114
+ const candidates = [];
115
+ const unreadable = [];
116
+ let scanned = 0;
117
+ for (const path of retired.paths) {
118
+ if (existsSync(resolve(projectDir, path))) {
119
+ candidates.push({
120
+ code: 'DLC004',
121
+ path,
122
+ confidence: 'high',
123
+ status: 'retired',
124
+ reason: 'retirement manifest records this path but it remains in the working tree',
125
+ });
126
+ }
127
+ }
128
+ for (const path of markdown) {
129
+ if (EXCLUDED_PREFIXES.some(prefix => path === prefix || path.startsWith(`${prefix}/`))) continue;
130
+ if (shouldIgnore(path, config, 'documentLifecycle')) continue;
131
+ if (retired.paths.has(path) && !existsSync(resolve(projectDir, path))) continue;
132
+ let content;
133
+ try { content = readFileSync(resolve(projectDir, path), 'utf8'); } catch {
134
+ unreadable.push(path);
135
+ continue;
136
+ }
137
+ scanned++;
138
+ const status = parseLifecycleStatus(content);
139
+ const normalizedStatus = status?.trim().toLowerCase() || null;
140
+ if (normalizedStatus && RETIRED_STATUSES.has(normalizedStatus)) {
141
+ candidates.push({
142
+ code: 'DLC001',
143
+ path,
144
+ confidence: 'high',
145
+ status,
146
+ reason: `explicit lifecycle status: ${status}`,
147
+ });
148
+ continue;
149
+ }
150
+ if (normalizedStatus && COMPLETION_STATUSES.has(normalizedStatus)) {
151
+ candidates.push({
152
+ code: 'DLC002',
153
+ path: /(?:^|\/)spec\.md$/i.test(path) ? dirname(path) : path,
154
+ confidence: 'review',
155
+ status,
156
+ reason: `artifact maturity is ${status}; verify persistence policy and backreferences before retirement`,
157
+ });
158
+ continue;
159
+ }
160
+ if (path.startsWith('specs/') && /(?:^|\/)spec\.md$/i.test(path)) {
161
+ const tasks = completedTaskSignal(projectDir, path, content, tracked);
162
+ if (tasks) {
163
+ candidates.push({
164
+ code: 'DLC002',
165
+ path: dirname(path),
166
+ confidence: 'review',
167
+ status,
168
+ reason: `${tasks.checked} checked tasks and no open tasks; confirm the outcome is documented`,
169
+ });
170
+ }
171
+ }
172
+ }
173
+ return {
174
+ scanned,
175
+ candidates: [...new Map(candidates.map(candidate => [candidate.path, candidate])).values()]
176
+ .sort((a, b) => a.path.localeCompare(b.path)),
177
+ coverage: {
178
+ status: unreadable.length > 0 ? 'partial' : 'complete',
179
+ reason: unreadable.length > 0 ? 'unreadable-files' : null,
180
+ error: null,
181
+ unreadable,
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,126 @@
1
+ /** Pure requirement-reference evidence shared by validators and registry projections. */
2
+
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { extname, resolve } from 'node:path';
5
+ import { TEST_PATTERNS } from '../shared-trace-patterns.mjs';
6
+ import { parseJsTs, walk } from './js-ast.mjs';
7
+
8
+ export function isTestSource(file) {
9
+ return /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|rb|php|sh)$/.test(file)
10
+ && (TEST_PATTERNS.some(pattern => pattern.test(file)) || /(?:^|\/)(?:__tests__|tests?)\//.test(file));
11
+ }
12
+
13
+ export function resolveRequirementReferences(definitions, references, retiredDefinitions = new Set()) {
14
+ const byId = new Map();
15
+ for (const [key, definition] of definitions) {
16
+ if (!byId.has(definition.id)) byId.set(definition.id, []);
17
+ byId.get(definition.id).push(key);
18
+ }
19
+ for (const key of retiredDefinitions) {
20
+ const id = key.slice(key.lastIndexOf('#') + 1);
21
+ if (!byId.has(id)) byId.set(id, []);
22
+ if (!byId.get(id).includes(key)) byId.get(id).push(key);
23
+ }
24
+ const resolved = new Map();
25
+ for (const [id, refs] of references) {
26
+ const candidates = byId.get(id) || [];
27
+ for (const ref of refs) {
28
+ let key = null;
29
+ if (ref.scope) {
30
+ const direct = `${ref.scope}#${id}`;
31
+ if (definitions.has(direct)) key = direct;
32
+ else {
33
+ const aliases = candidates.filter(candidate => definitions.get(candidate)?.specId === ref.scope);
34
+ if (aliases.length === 1) key = aliases[0];
35
+ }
36
+ } else if (candidates.length === 1) key = candidates[0];
37
+ if (!key || !definitions.has(key)) continue;
38
+ if (!resolved.has(key)) resolved.set(key, []);
39
+ resolved.get(key).push(ref);
40
+ }
41
+ }
42
+ return resolved;
43
+ }
44
+
45
+ function testDeclarations(content, filename) {
46
+ const declarations = [];
47
+ const comment = (text, line) => {
48
+ for (const [offset, raw] of text.split('\n').entries()) {
49
+ const body = raw.replace(/^\s*\*?\s*/, '');
50
+ if (/^(?:@(?:req|task|covers)\s|Testing\s)/i.test(body)) declarations.push({ text: body, line: line + offset });
51
+ }
52
+ };
53
+ const labelName = /^(?:test|it|describe|context|specify|Run|DisplayName)$/;
54
+ const ext = extname(filename);
55
+ if (/^\.(?:[cm]?[jt]s|[jt]sx)$/.test(ext)) {
56
+ const { ast, ok } = parseJsTs(content, filename);
57
+ if (ok) {
58
+ for (const c of ast.comments || []) comment(c.value, c.loc.start.line);
59
+ const isLabelCall = callee => {
60
+ if (callee?.type === 'Identifier') return labelName.test(callee.name);
61
+ if (callee?.type !== 'MemberExpression' || callee.computed) return false;
62
+ return labelName.test(callee.property.name)
63
+ || (/^(?:only|skip|todo|concurrent|serial)$/.test(callee.property.name) && isLabelCall(callee.object));
64
+ };
65
+ walk(ast.program, node => {
66
+ if (node.type !== 'CallExpression' || !isLabelCall(node.callee)) return;
67
+ const label = node.arguments[0];
68
+ if (label?.type === 'StringLiteral' || (label?.type === 'TemplateLiteral' && label.expressions.length === 0)) {
69
+ declarations.push({ text: content.slice(label.start + 1, label.end - 1), line: label.loc.start.line });
70
+ }
71
+ });
72
+ return declarations.sort((a, b) => a.line - b.line);
73
+ }
74
+ }
75
+
76
+ const tokens = /\/\*[\s\S]*?(?:\*\/|$)|\/\/[^\n]*|\#[^\n]*|"""[\s\S]*?(?:"""|$)|'''[\s\S]*?(?:'''|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`/g;
77
+ const hashComments = /\.(?:py|rb|php|sh)$/.test(ext);
78
+ let end = 0;
79
+ let line = 1;
80
+ let code = '';
81
+ for (const token of content.matchAll(tokens)) {
82
+ const gap = content.slice(end, token.index);
83
+ line += (gap.match(/\n/g) || []).length;
84
+ code += gap;
85
+ const text = token[0];
86
+ if (text.startsWith('//') || text.startsWith('/*') || (hashComments && text.startsWith('#'))) {
87
+ comment(text.replace(/^(?:\/\/|\/\*|#)/, ''), line);
88
+ } else if (/^["'`]/.test(text)
89
+ && /\b(?:test|it|describe|context|specify|Run|DisplayName)(?:\.(?:only|skip|todo|concurrent|serial))*\s*\(?\s*$/.test(code)) {
90
+ declarations.push({ text: text.slice(1, -1), line });
91
+ }
92
+ line += (text.match(/\n/g) || []).length;
93
+ code = text.startsWith('/') || text.startsWith('#') ? code + ' ' : ';';
94
+ end = token.index + text.length;
95
+ }
96
+ return declarations;
97
+ }
98
+
99
+ export function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
100
+ const testRefs = new Map();
101
+ for (const relPath of projectFiles.filter(isTestSource)) {
102
+ const fullPath = resolve(projectDir, relPath);
103
+ if (!existsSync(fullPath)) continue;
104
+ let content;
105
+ try { content = readFileSync(fullPath, 'utf8'); } catch { continue; }
106
+ const hasMatch = patterns.some(pattern => { pattern.lastIndex = 0; return pattern.test(content); });
107
+ if (!hasMatch) continue;
108
+ for (const declaration of testDeclarations(content, relPath)) {
109
+ for (const pattern of patterns) {
110
+ pattern.lastIndex = 0;
111
+ let match;
112
+ while ((match = pattern.exec(declaration.text)) !== null) {
113
+ if (!match[0]) { pattern.lastIndex++; continue; }
114
+ const reqId = match[0];
115
+ if (!testRefs.has(reqId)) testRefs.set(reqId, []);
116
+ const line = declaration.line + (declaration.text.slice(0, match.index).match(/\n/g) || []).length;
117
+ const prefix = declaration.text.slice(0, match.index);
118
+ const qualifier = prefix.match(/([^\s`"'<>()[\]{}]+)#$/);
119
+ const scope = qualifier ? qualifier[1].replaceAll('\\', '/').replace(/^\.\//, '') : null;
120
+ testRefs.get(reqId).push({ file: relPath, line, scope });
121
+ }
122
+ }
123
+ }
124
+ }
125
+ return testRefs;
126
+ }