docguard-cli 0.36.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -18
- package/cli/commands/diagnose.mjs +3 -22
- 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 +24 -35
- package/cli/config.mjs +2 -0
- package/cli/docguard.mjs +74 -14
- 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/docs-coverage.mjs +111 -61
- package/cli/validators/document-lifecycle.mjs +51 -0
- package/cli/validators/schema-sync.mjs +16 -11
- package/cli/validators/spec-registry.mjs +47 -0
- package/cli/validators/traceability.mjs +73 -199
- package/docs/ai-integration.md +18 -5
- package/docs/commands.md +45 -0
- package/docs/configuration.md +15 -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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Manage and preflight the deterministic spec lifecycle registry. */
|
|
2
|
+
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { safeWrite } from '../writers/generate-io.mjs';
|
|
5
|
+
import { preflightSpec, projectSpecRegistry, SPEC_REGISTRY_PATH } from '../scanners/spec-registry.mjs';
|
|
6
|
+
|
|
7
|
+
function printIssues(issues) {
|
|
8
|
+
for (const issue of issues) console.log(` ${issue.code} ${issue.path}: ${issue.message}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function printResult(result) {
|
|
12
|
+
if (result.command === 'preflight') {
|
|
13
|
+
console.log(`Spec preflight: ${result.status}`);
|
|
14
|
+
console.log(`Current specs: ${result.briefing.length}`);
|
|
15
|
+
for (const spec of result.briefing) {
|
|
16
|
+
console.log(` ${spec.specId} — ${spec.approval}/${spec.delivery}; tasks ${spec.taskCompletion.checked}/${spec.taskCompletion.total}; test evidence ${spec.testEvidence}`);
|
|
17
|
+
}
|
|
18
|
+
if (result.blockers.length) {
|
|
19
|
+
console.log('Blockers:');
|
|
20
|
+
printIssues(result.blockers);
|
|
21
|
+
}
|
|
22
|
+
if (result.overlaps.length) {
|
|
23
|
+
console.log('Review-only semantic overlap:');
|
|
24
|
+
for (const overlap of result.overlaps) console.log(` ${(overlap.similarity * 100).toFixed(0)}% ${overlap.specId} (${overlap.path})`);
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.log(`Spec registry: ${result.status}`);
|
|
29
|
+
console.log(`Registry: ${SPEC_REGISTRY_PATH}`);
|
|
30
|
+
console.log(`Specs: ${result.specs}; tombstones: ${result.tombstones}`);
|
|
31
|
+
if (result.issues.length) printIssues(result.issues);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runSpecs(projectDir, config, flags = {}) {
|
|
35
|
+
try {
|
|
36
|
+
const action = flags.args?.[0] || null;
|
|
37
|
+
if (action && action !== 'preflight') throw new Error(`Unknown specs action: ${action}`);
|
|
38
|
+
if (flags.check && flags.write) throw new Error('Use either --check or --write, not both.');
|
|
39
|
+
|
|
40
|
+
if (action === 'preflight') {
|
|
41
|
+
if (flags.write) throw new Error('Specs preflight is read-only.');
|
|
42
|
+
const result = { command: 'preflight', ...preflightSpec(projectDir, config, flags.path || null) };
|
|
43
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
44
|
+
else printResult(result);
|
|
45
|
+
if (result.status === 'BLOCKED') process.exitCode = 2;
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const projection = projectSpecRegistry(projectDir, config);
|
|
50
|
+
if (flags.write && projection.issues.length > 0) {
|
|
51
|
+
throw new Error(`Registry refresh refused: ${projection.issues.map(issue => issue.message).join(' ')}`);
|
|
52
|
+
}
|
|
53
|
+
let status = projection.current ? 'CURRENT' : projection.exists ? 'STALE' : 'MISSING';
|
|
54
|
+
if (flags.write && !projection.current) {
|
|
55
|
+
safeWrite(resolve(projectDir, SPEC_REGISTRY_PATH), projection.serialized);
|
|
56
|
+
status = 'WRITTEN';
|
|
57
|
+
}
|
|
58
|
+
const result = {
|
|
59
|
+
command: 'specs',
|
|
60
|
+
status,
|
|
61
|
+
registry: SPEC_REGISTRY_PATH,
|
|
62
|
+
specs: projection.registry.specs.length,
|
|
63
|
+
tombstones: projection.registry.tombstones.length,
|
|
64
|
+
issues: projection.issues,
|
|
65
|
+
};
|
|
66
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
67
|
+
else printResult(result);
|
|
68
|
+
if (flags.check && (status !== 'CURRENT' || result.issues.length > 0)) process.exitCode = 2;
|
|
69
|
+
return result;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const result = { command: 'specs', status: 'ERROR', error: error.message };
|
|
72
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
73
|
+
else console.error(`Error: ${error.message}`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/cli/commands/trace.mjs
CHANGED
|
@@ -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
|
|
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,31 @@ function featureBar(score) {
|
|
|
425
426
|
}
|
|
426
427
|
|
|
427
428
|
/**
|
|
428
|
-
* Collect
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
let m;
|
|
446
|
-
while ((m = FEATURE_REQ_RE.exec(content)) !== null) ids.add(m[0]);
|
|
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);
|
|
447
445
|
}
|
|
448
|
-
return
|
|
446
|
+
return qualified;
|
|
449
447
|
}
|
|
450
448
|
|
|
451
449
|
/**
|
|
452
450
|
* Compute the four adherence signals for one detected spec-kit feature.
|
|
453
451
|
* Each signal: { applicable, value (0..1 | null), ...n/m detail fields }.
|
|
454
452
|
*/
|
|
455
|
-
function computeFeatureSignals(projectDir, feature, testRefIds) {
|
|
453
|
+
function computeFeatureSignals(projectDir, feature, testRefIds, definitions) {
|
|
456
454
|
// ── artifactCompleteness — always measurable ──
|
|
457
455
|
const artifactValue = (feature.hasSpec ? 0.4 : 0)
|
|
458
456
|
+ (feature.hasPlan ? 0.3 : 0)
|
|
@@ -483,21 +481,11 @@ function computeFeatureSignals(projectDir, feature, testRefIds) {
|
|
|
483
481
|
}
|
|
484
482
|
}
|
|
485
483
|
|
|
486
|
-
// ── reqCoverage — spec.md IDs
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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));
|
|
484
|
+
// ── reqCoverage — spec.md IDs declared in eligible test annotations or labels ──
|
|
485
|
+
const specPath = feature.specPath ? relative(projectDir, feature.specPath).replaceAll('\\', '/') : null;
|
|
486
|
+
const specIds = [...definitions.values()].filter(def => def.file === specPath).map(def => def.id);
|
|
487
|
+
const covered = specIds.filter(id => testRefIds.has(`${specPath}#${id}`));
|
|
488
|
+
const uncovered = specIds.filter(id => !testRefIds.has(`${specPath}#${id}`));
|
|
501
489
|
|
|
502
490
|
return {
|
|
503
491
|
reqCoverage: {
|
|
@@ -606,10 +594,11 @@ export function runTraceFeatures(projectDir, config, flags) {
|
|
|
606
594
|
return;
|
|
607
595
|
}
|
|
608
596
|
|
|
609
|
-
const
|
|
597
|
+
const definitions = collectRequirementIds(projectDir, config, [FEATURE_REQ_RE]);
|
|
598
|
+
const testRefIds = resolveRequirementReferences(definitions, collectTestReferencedIds(projectDir));
|
|
610
599
|
|
|
611
600
|
const features = speckit.specs.map(f => {
|
|
612
|
-
const signals = computeFeatureSignals(projectDir, f, testRefIds);
|
|
601
|
+
const signals = computeFeatureSignals(projectDir, f, testRefIds, definitions);
|
|
613
602
|
const score = scoreFromSignals(signals);
|
|
614
603
|
const weakest = weakestSignal(signals);
|
|
615
604
|
const needsFix = weakest !== null && signals[weakest].value < 1;
|
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',
|
|
@@ -652,15 +703,16 @@ async function main() {
|
|
|
652
703
|
command !== 'setup' &&
|
|
653
704
|
command !== 'init' &&
|
|
654
705
|
!READ_ONLY_COMMANDS.has(command) &&
|
|
706
|
+
// Agent-family staleness checks must not bootstrap skills or Spec Kit.
|
|
707
|
+
!(command === 'agents' && flags.check) &&
|
|
655
708
|
!headless
|
|
656
709
|
) {
|
|
657
710
|
ensureSkills(projectDir, flags);
|
|
658
711
|
}
|
|
659
712
|
|
|
660
|
-
// v0.20: deprecation aliases. The legacy command keeps working
|
|
713
|
+
// v0.20: deprecation aliases. The legacy command keeps working until v1.0
|
|
661
714
|
// and emits a yellow stderr warning suggesting the new shape. Quiet mode
|
|
662
715
|
// (e.g. inside hooks) suppresses the warning so CI output stays clean.
|
|
663
|
-
// The full deprecation timeline is in docs-implementation/MIGRATION-v0.20.md.
|
|
664
716
|
const DEPRECATED_COMMANDS = {
|
|
665
717
|
setup: { since: '0.20', replacement: 'docguard init --wizard' },
|
|
666
718
|
agents: { since: '0.20', replacement: 'docguard init --with agents' },
|
|
@@ -692,7 +744,7 @@ async function main() {
|
|
|
692
744
|
if (DROPPED_ALIASES[command]) {
|
|
693
745
|
console.error(`${c.red}Unknown command: ${command}${c.reset}`);
|
|
694
746
|
console.error(`${c.yellow}Hint: this alias was removed in v0.20. Try ${c.cyan}docguard ${DROPPED_ALIASES[command]}${c.yellow}.${c.reset}`);
|
|
695
|
-
console.error(`${c.dim}
|
|
747
|
+
console.error(`${c.dim}Use the replacement shown above; removed aliases are not restored.${c.reset}`);
|
|
696
748
|
process.exit(1);
|
|
697
749
|
}
|
|
698
750
|
|
|
@@ -701,7 +753,7 @@ async function main() {
|
|
|
701
753
|
if (DEPRECATED_COMMANDS[command] && !flags.quiet && !(command === 'hooks' && flags.claude)) {
|
|
702
754
|
const { since, replacement } = DEPRECATED_COMMANDS[command];
|
|
703
755
|
console.error(`${c.yellow}⚠ Deprecated since v${since}:${c.reset} ${c.cyan}docguard ${command}${c.reset} → use ${c.cyan}${replacement}${c.reset}`);
|
|
704
|
-
console.error(`${c.dim} The old form
|
|
756
|
+
console.error(`${c.dim} The old form remains compatible until v1.0.${c.reset}`);
|
|
705
757
|
}
|
|
706
758
|
|
|
707
759
|
switch (command) {
|
|
@@ -733,8 +785,9 @@ async function main() {
|
|
|
733
785
|
}
|
|
734
786
|
break;
|
|
735
787
|
case 'agents':
|
|
736
|
-
//
|
|
737
|
-
|
|
788
|
+
// A staleness check must bypass init's scaffolding and skill installation.
|
|
789
|
+
if (flags.check) runAgents(projectDir, config, flags);
|
|
790
|
+
else await runInit(projectDir, config, { ...flags, with: ['agents'], skipPrompts: true });
|
|
738
791
|
break;
|
|
739
792
|
case 'generate':
|
|
740
793
|
runGenerate(projectDir, config, flags);
|
|
@@ -827,6 +880,13 @@ async function main() {
|
|
|
827
880
|
case 'memory':
|
|
828
881
|
runMemory(projectDir, config, flags);
|
|
829
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;
|
|
830
890
|
case 'demo':
|
|
831
891
|
// v0.21: zero-install "ah-ha" moment — runs guard against a baked-in
|
|
832
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
|
+
}
|