docguard-cli 0.36.2 → 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.
- package/README.md +23 -18
- package/cli/commands/explain.mjs +28 -0
- package/cli/commands/guard.mjs +4 -0
- package/cli/commands/llms.mjs +3 -2
- package/cli/commands/retire.mjs +352 -0
- package/cli/commands/specs.mjs +77 -0
- package/cli/commands/trace.mjs +11 -1
- package/cli/config.mjs +2 -0
- package/cli/docguard.mjs +69 -12
- package/cli/findings.mjs +54 -0
- package/cli/scanners/document-lifecycle.mjs +184 -0
- package/cli/scanners/requirement-evidence.mjs +126 -0
- package/cli/scanners/spec-registry.mjs +517 -0
- package/cli/shared-requirements.mjs +91 -0
- package/cli/validators/document-lifecycle.mjs +51 -0
- package/cli/validators/spec-registry.mjs +47 -0
- package/cli/validators/traceability.mjs +52 -216
- package/docs/ai-integration.md +18 -5
- package/docs/commands.md +45 -0
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +15 -5
- package/extensions/spec-kit-docguard/commands/brief.md +34 -0
- package/extensions/spec-kit-docguard/commands/preflight.md +52 -0
- package/extensions/spec-kit-docguard/extension.yml +18 -5
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/templates/extensions.yml +13 -6
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +2 -0
- package/schemas/docguard-specs.schema.json +162 -0
- package/templates/ci/github-actions.yml +1 -1
package/cli/commands/trace.mjs
CHANGED
|
@@ -433,7 +433,17 @@ function featureBar(score) {
|
|
|
433
433
|
function collectTestReferencedIds(projectDir) {
|
|
434
434
|
const projectFiles = [];
|
|
435
435
|
scanDir(projectDir, projectDir, projectFiles);
|
|
436
|
-
|
|
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}—
|
|
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}
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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] : '';
|
|
@@ -624,7 +675,7 @@ async function main() {
|
|
|
624
675
|
// `diff`/`impact` only read; `demo` runs against a throwaway fixture.)
|
|
625
676
|
const READ_ONLY_COMMANDS = new Set([
|
|
626
677
|
'guard', 'audit', 'score', 'diff', 'impact',
|
|
627
|
-
'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent',
|
|
678
|
+
'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent', 'retire', 'archive', 'specs',
|
|
628
679
|
// feedback only writes its own .docguard/feedback/ — it must NOT scaffold
|
|
629
680
|
// skills or touch source, so it's gated out of ensureSkills like the rest.
|
|
630
681
|
'feedback',
|
|
@@ -659,10 +710,9 @@ async function main() {
|
|
|
659
710
|
ensureSkills(projectDir, flags);
|
|
660
711
|
}
|
|
661
712
|
|
|
662
|
-
// v0.20: deprecation aliases. The legacy command keeps working
|
|
713
|
+
// v0.20: deprecation aliases. The legacy command keeps working until v1.0
|
|
663
714
|
// and emits a yellow stderr warning suggesting the new shape. Quiet mode
|
|
664
715
|
// (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
716
|
const DEPRECATED_COMMANDS = {
|
|
667
717
|
setup: { since: '0.20', replacement: 'docguard init --wizard' },
|
|
668
718
|
agents: { since: '0.20', replacement: 'docguard init --with agents' },
|
|
@@ -694,7 +744,7 @@ async function main() {
|
|
|
694
744
|
if (DROPPED_ALIASES[command]) {
|
|
695
745
|
console.error(`${c.red}Unknown command: ${command}${c.reset}`);
|
|
696
746
|
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}
|
|
747
|
+
console.error(`${c.dim}Use the replacement shown above; removed aliases are not restored.${c.reset}`);
|
|
698
748
|
process.exit(1);
|
|
699
749
|
}
|
|
700
750
|
|
|
@@ -703,7 +753,7 @@ async function main() {
|
|
|
703
753
|
if (DEPRECATED_COMMANDS[command] && !flags.quiet && !(command === 'hooks' && flags.claude)) {
|
|
704
754
|
const { since, replacement } = DEPRECATED_COMMANDS[command];
|
|
705
755
|
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
|
|
756
|
+
console.error(`${c.dim} The old form remains compatible until v1.0.${c.reset}`);
|
|
707
757
|
}
|
|
708
758
|
|
|
709
759
|
switch (command) {
|
|
@@ -830,6 +880,13 @@ async function main() {
|
|
|
830
880
|
case 'memory':
|
|
831
881
|
runMemory(projectDir, config, flags);
|
|
832
882
|
break;
|
|
883
|
+
case 'retire':
|
|
884
|
+
case 'archive': // Development alias; `retire` avoids collision with Spec Kit Archive.
|
|
885
|
+
runArchive(projectDir, config, flags);
|
|
886
|
+
break;
|
|
887
|
+
case 'specs':
|
|
888
|
+
runSpecs(projectDir, config, flags);
|
|
889
|
+
break;
|
|
833
890
|
case 'demo':
|
|
834
891
|
// v0.21: zero-install "ah-ha" moment — runs guard against a baked-in
|
|
835
892
|
// 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
|
+
}
|