docguard-cli 0.39.0 → 0.40.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.
- package/README.md +43 -20
- package/cli/commands/agent.mjs +47 -1
- package/cli/commands/explain.mjs +16 -0
- package/cli/commands/fix.mjs +13 -11
- package/cli/commands/generate.mjs +52 -18
- package/cli/commands/guard.mjs +13 -2
- package/cli/commands/mcp.mjs +22 -2
- package/cli/commands/score.mjs +13 -1
- package/cli/commands/sync.mjs +20 -7
- package/cli/commands/verify.mjs +65 -2
- package/cli/config.mjs +3 -0
- package/cli/docguard.mjs +33 -12
- package/cli/evidence/adapters.mjs +200 -0
- package/cli/evidence/evaluate.mjs +185 -0
- package/cli/evidence/manifest.mjs +194 -0
- package/cli/evidence/markdown.mjs +107 -0
- package/cli/findings.mjs +31 -0
- package/cli/release-pr-policy.mjs +107 -0
- package/cli/repository-root.mjs +159 -0
- package/cli/scanners/py-ast.mjs +39 -2
- package/cli/scanners/task-context.mjs +312 -0
- package/cli/shared-doc-roles.mjs +44 -1
- package/cli/shared-source.mjs +101 -28
- package/cli/validators/architecture.mjs +186 -13
- package/cli/validators/environment.mjs +14 -1
- package/cli/validators/evidence.mjs +52 -0
- package/cli/validators/todo-tracking.mjs +45 -2
- package/cli/writers/doc-generators.mjs +31 -17
- package/cli/writers/mechanical.mjs +44 -14
- package/cli/writers/sections.mjs +31 -3
- package/docs/ai-integration.md +31 -6
- package/docs/commands.md +43 -5
- package/docs/configuration.md +11 -3
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/commands/fix.md +4 -2
- package/extensions/spec-kit-docguard/commands/generate.md +6 -1
- package/extensions/spec-kit-docguard/commands/guard.md +3 -2
- package/extensions/spec-kit-docguard/commands/sync.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
- package/package.json +2 -1
- package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
- package/schemas/docguard-agent-context-result.schema.json +95 -0
- package/schemas/docguard-config.schema.json +1 -0
- package/schemas/docguard-evidence.schema.json +169 -0
- package/schemas/docguard-task-context.schema.json +144 -0
- package/templates/AGENTS.md.template +9 -4
- package/templates/ci/github-actions.yml +4 -4
- package/templates/commands/docguard.guard.md +5 -1
- package/templates/commands/docguard.review.md +6 -1
- package/templates/evidence-manifest.json +21 -0
package/cli/commands/score.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyDocRoles, remapDocPath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
2
2
|
/**
|
|
3
|
+
* @implements docguard.evidence-scoped-verification#FR-012
|
|
3
4
|
* Score Command — Calculate CDD maturity score (0-100)
|
|
4
5
|
* Shows category breakdown with weighted scoring.
|
|
5
6
|
*/
|
|
@@ -14,6 +15,7 @@ import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
|
|
|
14
15
|
import { assessAgentReadability } from '../scanners/agent-readability.mjs';
|
|
15
16
|
import { loadHistory, sparkline } from '../writers/history.mjs';
|
|
16
17
|
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
18
|
+
import { coverSemanticClaims, evaluateEvidence } from '../evidence/evaluate.mjs';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Detect whether the project configures a test runner (the "Check 3" of the
|
|
@@ -415,11 +417,21 @@ export function runScoreInternal(projectDir, config) {
|
|
|
415
417
|
/** Evidence boundary shared by human, CI, report, and MCP score consumers. */
|
|
416
418
|
export function buildScoreAssurance(projectDir, config) {
|
|
417
419
|
let unverifiedClaims = null;
|
|
418
|
-
|
|
420
|
+
let declaredEvidence = null;
|
|
421
|
+
try {
|
|
422
|
+
const claims = extractSemanticClaims(projectDir, config);
|
|
423
|
+
const evaluated = evaluateEvidence(projectDir, config);
|
|
424
|
+
unverifiedClaims = coverSemanticClaims(claims, evaluated).unverified;
|
|
425
|
+
} catch { /* unknown, never zero on failure */ }
|
|
426
|
+
try {
|
|
427
|
+
const evaluated = evaluateEvidence(projectDir, config);
|
|
428
|
+
declaredEvidence = { configured: evaluated.exists, status: evaluated.status, summary: evaluated.summary };
|
|
429
|
+
} catch { /* unknown, never clean on failure */ }
|
|
419
430
|
return {
|
|
420
431
|
status: 'unverified',
|
|
421
432
|
factualAccuracy: null,
|
|
422
433
|
unverifiedClaims,
|
|
434
|
+
declaredEvidence,
|
|
423
435
|
limitation: 'Structural maturity is not factual accuracy. Claim discovery is heuristic; uncaptured prose remains unverified.',
|
|
424
436
|
};
|
|
425
437
|
}
|
package/cli/commands/sync.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isMappedDocPath } from '../shared-doc-roles.mjs';
|
|
2
2
|
/**
|
|
3
3
|
* Sync Command — keep the documentation memory ALWAYS UP TO DATE.
|
|
4
4
|
*
|
|
@@ -15,12 +15,13 @@ import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
|
|
|
15
15
|
* @implements docguard.document-lifecycle#FR-010
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
19
|
import { resolve } from 'node:path';
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
21
|
import { c } from '../shared.mjs';
|
|
22
22
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
23
|
-
import { getSection, replaceSection } from '../writers/sections.mjs';
|
|
23
|
+
import { assertOwnedCodeSection, getSection, inspectSections, replaceSection } from '../writers/sections.mjs';
|
|
24
|
+
import { safeWrite } from '../writers/generate-io.mjs';
|
|
24
25
|
import { hasGeneratedMarker } from '../writers/api-reference.mjs';
|
|
25
26
|
import { runSyncTests } from './sync-tests.mjs';
|
|
26
27
|
import { sectionTouchedByChanges } from '../shared-sync-scope.mjs';
|
|
@@ -48,7 +49,6 @@ function gitChangedFiles(projectDir, since) {
|
|
|
48
49
|
*/
|
|
49
50
|
|
|
50
51
|
export function runSync(projectDir, config, flags) {
|
|
51
|
-
if (flags.write) assertDefaultDocWrites(config);
|
|
52
52
|
// v0.28 (field report #10): `--tests` reconciles the hand-maintained TEST-SPEC
|
|
53
53
|
// Source-to-Test Map from disk (ghost-source removal + new co-located pairs) —
|
|
54
54
|
// a distinct path from the generated code-truth section refresh below.
|
|
@@ -62,6 +62,7 @@ export function runSync(projectDir, config, flags) {
|
|
|
62
62
|
const updates = []; // { doc, section, status }
|
|
63
63
|
const reviews = []; // { doc, section, reason }
|
|
64
64
|
const skipped = []; // { doc, reason }
|
|
65
|
+
const pendingWrites = [];
|
|
65
66
|
|
|
66
67
|
for (const doc of plan.docs) {
|
|
67
68
|
const full = resolve(projectDir, doc.path);
|
|
@@ -70,7 +71,11 @@ export function runSync(projectDir, config, flags) {
|
|
|
70
71
|
continue;
|
|
71
72
|
}
|
|
72
73
|
let content = readFileSync(full, 'utf-8');
|
|
73
|
-
|
|
74
|
+
const mapped = isMappedDocPath(config, doc.path);
|
|
75
|
+
if (apply && mapped && inspectSections(content).issues.length) {
|
|
76
|
+
throw new Error(`${doc.path}: malformed or duplicate docguard:section markers; no write was applied.`);
|
|
77
|
+
}
|
|
78
|
+
if (!hasGeneratedMarker(content) && !flags.force && !mapped) {
|
|
74
79
|
skipped.push({ doc: doc.path, reason: 'not marked docguard:generated (use --force to sync anyway)' });
|
|
75
80
|
continue;
|
|
76
81
|
}
|
|
@@ -98,7 +103,11 @@ export function runSync(projectDir, config, flags) {
|
|
|
98
103
|
}
|
|
99
104
|
codeSectionChanged = true;
|
|
100
105
|
updates.push({ doc: doc.path, section: sec.id, status: apply ? 'updated' : 'stale' });
|
|
101
|
-
if (apply) {
|
|
106
|
+
if (apply) {
|
|
107
|
+
if (mapped) assertOwnedCodeSection(content, sec.id, doc.path);
|
|
108
|
+
content = replaceSection(content, sec.id, sec.body).content;
|
|
109
|
+
docChanged = true;
|
|
110
|
+
}
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
// If code changed, the prose around it may need an agent's eyes.
|
|
@@ -110,9 +119,13 @@ export function runSync(projectDir, config, flags) {
|
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
if (apply && docChanged)
|
|
122
|
+
if (apply && docChanged) pendingWrites.push({ full, content });
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
// Authorization for every mapped target completed above. Only now expose
|
|
126
|
+
// writes, preserving backup behavior for both default and mapped layouts.
|
|
127
|
+
for (const pending of pendingWrites) safeWrite(pending.full, pending.content);
|
|
128
|
+
|
|
116
129
|
const result = {
|
|
117
130
|
project: config.projectName,
|
|
118
131
|
since: flags.since || null,
|
package/cli/commands/verify.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* text is the human summary.
|
|
21
21
|
*
|
|
22
22
|
* docguard verify [--semantic | --instructions] [--format json]
|
|
23
|
+
* @implements docguard.evidence-scoped-verification#FR-009
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { basename } from 'node:path';
|
|
@@ -29,6 +30,7 @@ import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/sem
|
|
|
29
30
|
import { auditInstructions } from '../scanners/instruction-audit.mjs';
|
|
30
31
|
import { isGitRepo, getDiffText } from '../shared-git.mjs';
|
|
31
32
|
import { parseUnifiedDiff, activityLabeledDiff } from '../shared-diff.mjs';
|
|
33
|
+
import { coverSemanticClaims, evaluateEvidence } from '../evidence/evaluate.mjs';
|
|
32
34
|
|
|
33
35
|
const CHANGE_CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
|
|
34
36
|
|
|
@@ -68,14 +70,30 @@ function taskTouchesChange(task, changedSet, changedBasenames) {
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
export function runVerify(projectDir, config, flags) {
|
|
73
|
+
const selectedModes = ['semantic', 'instructions', 'evidence'].filter(mode => flags[mode]);
|
|
74
|
+
if (selectedModes.length > 1) {
|
|
75
|
+
const message = `Choose exactly one verify mode; --${selectedModes.join(', --')} cannot be combined.`;
|
|
76
|
+
if (flags.format === 'json') {
|
|
77
|
+
console.log(JSON.stringify({ command: 'verify', status: 'error', error: { code: 'VERIFY_MODE_CONFLICT', message } }, null, 2));
|
|
78
|
+
} else {
|
|
79
|
+
console.error(`${c.red}Error: ${message}${c.reset}`);
|
|
80
|
+
}
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
71
84
|
if (flags.instructions) {
|
|
72
85
|
runInstructionAudit(projectDir, config, flags);
|
|
73
86
|
return;
|
|
74
87
|
}
|
|
88
|
+
if (flags.evidence) {
|
|
89
|
+
runEvidenceVerification(projectDir, config, flags);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
75
92
|
|
|
76
93
|
const isJson = flags.format === 'json';
|
|
77
94
|
const claims = extractSemanticClaims(projectDir, config);
|
|
78
|
-
const
|
|
95
|
+
const evidenceCoverage = coverSemanticClaims(claims, evaluateEvidence(projectDir, config));
|
|
96
|
+
const tasks = buildSemanticVerifyTasks(evidenceCoverage.remaining);
|
|
79
97
|
|
|
80
98
|
// Change-aware staging (feat 6): if --since given, attach the structured diff
|
|
81
99
|
// and flag which claims are about just-changed code (verify those first).
|
|
@@ -92,6 +110,9 @@ export function runVerify(projectDir, config, flags) {
|
|
|
92
110
|
console.log(JSON.stringify({
|
|
93
111
|
command: 'verify --semantic',
|
|
94
112
|
project: config.projectName,
|
|
113
|
+
discoveredClaimCount: evidenceCoverage.total,
|
|
114
|
+
verifiedWithinScope: evidenceCoverage.verifiedWithinScope,
|
|
115
|
+
coveredClaimIds: evidenceCoverage.covered,
|
|
95
116
|
claimCount: tasks.length,
|
|
96
117
|
// How to act on this: each task is a claim to confirm against the code.
|
|
97
118
|
howToVerify: changeContext
|
|
@@ -107,7 +128,10 @@ export function runVerify(projectDir, config, flags) {
|
|
|
107
128
|
console.log(`${c.dim} ${config.projectName} · documented numbers / limits / enums to check against code${c.reset}\n`);
|
|
108
129
|
|
|
109
130
|
if (tasks.length === 0) {
|
|
110
|
-
|
|
131
|
+
const message = evidenceCoverage.total > 0
|
|
132
|
+
? `${evidenceCoverage.verifiedWithinScope} discovered claim(s) are already covered by unique current evidence declarations.`
|
|
133
|
+
: 'No semantic claims found in the canonical docs.';
|
|
134
|
+
console.log(` ${c.green}✅ ${message}${c.reset}`);
|
|
111
135
|
console.log(` ${c.dim}(Looks for numbers with units — days/ms/req-s/GSIs/roles/… — and status/enum lists.)${c.reset}\n`);
|
|
112
136
|
return;
|
|
113
137
|
}
|
|
@@ -120,6 +144,9 @@ export function runVerify(projectDir, config, flags) {
|
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
console.log(` ${c.yellow}${tasks.length} claim(s) to verify against the code:${c.reset}\n`);
|
|
147
|
+
if (evidenceCoverage.verifiedWithinScope > 0) {
|
|
148
|
+
console.log(` ${c.green}✓ ${evidenceCoverage.verifiedWithinScope} additional discovered claim(s) have unique verified-within-scope declarations.${c.reset}\n`);
|
|
149
|
+
}
|
|
123
150
|
if (changeContext) {
|
|
124
151
|
const nChanged = tasks.filter(t => t.aboutChangedCode).length;
|
|
125
152
|
console.log(` ${c.cyan}⚡ ${nChanged} claim(s) are about code changed since ${flags.since}${c.reset} ${c.dim}— verify these first (structured diff in --format json).${c.reset}\n`);
|
|
@@ -140,6 +167,42 @@ export function runVerify(projectDir, config, flags) {
|
|
|
140
167
|
console.log(` ${c.dim}Get the machine task list: ${c.cyan}${cmd}${c.dim}, then read each cited file and confirm the value.${c.reset}\n`);
|
|
141
168
|
}
|
|
142
169
|
|
|
170
|
+
function runEvidenceVerification(projectDir, config, flags) {
|
|
171
|
+
const evaluation = evaluateEvidence(projectDir, config);
|
|
172
|
+
if (flags.format === 'json') {
|
|
173
|
+
console.log(JSON.stringify(evaluation, null, 2));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
console.log(`${c.bold}🔬 DocGuard Verify — declared evidence${c.reset}`);
|
|
177
|
+
console.log(`${c.dim} ${config.projectName} · exact statement-to-source checks${c.reset}\n`);
|
|
178
|
+
if (!evaluation.exists) {
|
|
179
|
+
console.log(` ${c.dim}No .docguard-evidence.json manifest is configured.${c.reset}`);
|
|
180
|
+
console.log(` ${c.dim}Start from templates/evidence-manifest.json; heuristic discovery remains available with docguard verify --semantic.${c.reset}\n`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (evaluation.errors.length) {
|
|
184
|
+
console.log(` ${c.red}Invalid evidence manifest:${c.reset}`);
|
|
185
|
+
for (const error of evaluation.errors) console.log(` ${c.red}✗${c.reset} ${error.message}`);
|
|
186
|
+
console.log('');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const symbols = {
|
|
190
|
+
'verified-within-scope': `${c.green}✓${c.reset}`,
|
|
191
|
+
contradicted: `${c.red}✗${c.reset}`,
|
|
192
|
+
stale: `${c.yellow}↻${c.reset}`,
|
|
193
|
+
inconclusive: `${c.yellow}?${c.reset}`,
|
|
194
|
+
unsupported: `${c.yellow}◇${c.reset}`,
|
|
195
|
+
};
|
|
196
|
+
for (const state of ['contradicted', 'stale', 'inconclusive', 'unsupported', 'verified-within-scope']) {
|
|
197
|
+
const results = evaluation.results.filter(result => result.state === state);
|
|
198
|
+
if (!results.length) continue;
|
|
199
|
+
console.log(` ${c.bold}${state}${c.reset} (${results.length})`);
|
|
200
|
+
for (const result of results) console.log(` ${symbols[state]} ${result.declarationId} · ${result.location} · ${result.message}`);
|
|
201
|
+
console.log('');
|
|
202
|
+
}
|
|
203
|
+
console.log(` ${c.dim}${evaluation.scopeLimitation}${c.reset}\n`);
|
|
204
|
+
}
|
|
205
|
+
|
|
143
206
|
// ── verify --instructions: agent-instruction drift/conflict audit ───────────
|
|
144
207
|
|
|
145
208
|
function runInstructionAudit(projectDir, config, flags) {
|
package/cli/config.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { hasWorkerConfig } from './shared-source.mjs';
|
|
2
2
|
import { applyDocRoles } from './shared-doc-roles.mjs';
|
|
3
3
|
/**
|
|
4
|
+
* @implements docguard.evidence-scoped-verification#FR-010
|
|
4
5
|
* DocGuard — configuration loading.
|
|
5
6
|
*
|
|
6
7
|
* Extracted from docguard.mjs (v0.23.0) to break the demo.mjs → docguard.mjs
|
|
@@ -81,6 +82,7 @@ export function loadConfig(projectDir) {
|
|
|
81
82
|
freshness: true,
|
|
82
83
|
documentLifecycle: true,
|
|
83
84
|
specRegistry: true,
|
|
85
|
+
evidence: true,
|
|
84
86
|
// v0.31.0 — all three default ON. Soft (confidence:low, never break CI),
|
|
85
87
|
// heuristic (field cases require ongoing precision checks), and quiet when
|
|
86
88
|
// not applicable (no diff / no API-reference doc). api-doc-smells is
|
|
@@ -225,6 +227,7 @@ const _KNOWN_VALIDATORS = [
|
|
|
225
227
|
'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
|
|
226
228
|
'schemaSync', 'specKit', 'crossReference', 'generatedStaleness',
|
|
227
229
|
'canonicalSync', 'surfaceSync', 'metricsConsistency',
|
|
230
|
+
'evidence',
|
|
228
231
|
];
|
|
229
232
|
|
|
230
233
|
function _kebabToCamel(k) {
|
package/cli/docguard.mjs
CHANGED
|
@@ -57,6 +57,7 @@ import { runArchive } from './commands/retire.mjs';
|
|
|
57
57
|
import { runSpecs } from './commands/specs.mjs';
|
|
58
58
|
import { runReconcile } from './commands/reconcile.mjs';
|
|
59
59
|
import { ensureSkills } from './ensure-skills.mjs';
|
|
60
|
+
import { detectRepositoryRootGuidance, renderRepositoryRootGuidance } from './repository-root.mjs';
|
|
60
61
|
|
|
61
62
|
// ── Shared constants (imported to break circular dependencies) ──────────
|
|
62
63
|
import { c, PROFILES } from './shared.mjs';
|
|
@@ -94,9 +95,9 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
94
95
|
${c.green}diagnose${c.reset} AI orchestrator — guard → emit fix prompts in one command
|
|
95
96
|
${c.green}fix${c.reset} Generate AI fix instructions for specific docs
|
|
96
97
|
${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
|
|
97
|
-
${c.green}agent${c.reset}
|
|
98
|
+
${c.green}agent${c.reset} Agent task graph or bounded task context (${c.cyan}--task <text>${c.reset}, ${c.cyan}--format json${c.reset})
|
|
98
99
|
${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
|
|
99
|
-
${c.green}verify${c.reset}
|
|
100
|
+
${c.green}verify${c.reset} Check declared local evidence or extract claims for review (${c.cyan}--evidence${c.reset}, ${c.cyan}--semantic${c.reset})
|
|
100
101
|
${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
|
|
101
102
|
${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/report/diagnose as agent tools
|
|
102
103
|
${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})
|
|
@@ -125,7 +126,7 @@ ${c.bold}Deprecation aliases${c.reset} ${c.dim}— supported until v1.0 with a y
|
|
|
125
126
|
${c.dim}Run the legacy form to see its replacement.${c.reset}
|
|
126
127
|
|
|
127
128
|
${c.bold}Options:${c.reset}
|
|
128
|
-
--dir <path> Project directory (default: current directory)
|
|
129
|
+
--dir <path> Project directory (default: current directory; explicit paths suppress ancestor-root guidance)
|
|
129
130
|
--verbose Show detailed output
|
|
130
131
|
--format json Output results as JSON (for CI)
|
|
131
132
|
--fix Auto-create missing files from templates
|
|
@@ -138,7 +139,8 @@ ${c.bold}Options:${c.reset}
|
|
|
138
139
|
--fail-on-warning Fail CI on warnings (used with ci command)
|
|
139
140
|
--auto Auto-fix what's possible (used with fix command)
|
|
140
141
|
--write Apply a command's explicit deterministic write path. For fix,
|
|
141
|
-
|
|
142
|
+
mapped human docs require unique source=code ownership and
|
|
143
|
+
--force cannot grant ownership; specs
|
|
142
144
|
refreshes observed registry evidence; retire requires --path.
|
|
143
145
|
--plan AI-powered Generate (generate command): scan any project
|
|
144
146
|
(JS/Python/Rust/Go/Java/…), emit the agent task manifest +
|
|
@@ -213,13 +215,14 @@ const COMMAND_HELP = {
|
|
|
213
215
|
examples: ['docguard generate', 'docguard generate --plan', 'docguard generate --plan --write', 'docguard generate --plan --format json'],
|
|
214
216
|
},
|
|
215
217
|
agent: {
|
|
216
|
-
summary: '
|
|
217
|
-
usage: 'docguard agent [--profile <name>] [--format json]',
|
|
218
|
+
summary: 'Agent task graph, or a bounded evidence packet for one explicit task.',
|
|
219
|
+
usage: 'docguard agent [--task <text>] [--profile <name>] [--format json]',
|
|
218
220
|
flags: [
|
|
219
|
-
['--
|
|
221
|
+
['--task <text>', 'Select current task-linked requirements and evidence; abstain when relevance is weak'],
|
|
222
|
+
['--format json', 'Machine-readable task graph or deterministic task packet'],
|
|
220
223
|
['--profile <name>', 'Preview a profile (cli/library/standard/…) without running init first'],
|
|
221
224
|
],
|
|
222
|
-
examples: ['docguard agent', 'docguard agent --
|
|
225
|
+
examples: ['docguard agent', 'docguard agent --task "Fix SEC001 in src/config.mjs"', 'docguard agent --task "Implement acme.feature#FR-001" --format json'],
|
|
223
226
|
},
|
|
224
227
|
guard: {
|
|
225
228
|
summary: 'Validate code against canonical docs (all validators).',
|
|
@@ -307,14 +310,15 @@ const COMMAND_HELP = {
|
|
|
307
310
|
examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --fixture-manifest feedback.json --reduce --preview --format json'],
|
|
308
311
|
},
|
|
309
312
|
verify: {
|
|
310
|
-
summary: '
|
|
311
|
-
usage: 'docguard verify [--semantic|--instructions] [--format json]',
|
|
313
|
+
summary: 'Verify exact declared evidence, extract heuristic semantic claims, or audit agent instructions.',
|
|
314
|
+
usage: 'docguard verify [--evidence|--semantic|--instructions] [--format json]',
|
|
312
315
|
flags: [
|
|
316
|
+
['--evidence', 'Evaluate `.docguard-evidence.json` using local bounded JSON, collection, oasdiff, and Buf evidence'],
|
|
313
317
|
['--semantic', 'Extract documented numbers/limits/enums to verify against code (the current — and default — mode)'],
|
|
314
318
|
['--instructions', 'Audit AGENTS.md/CLAUDE.md for duplicate, contradictory, and stale-pointer rules (deterministic findings + agent conflict tasks)'],
|
|
315
319
|
['--format json', 'Machine-readable task list (the agent-executable artifact)'],
|
|
316
320
|
],
|
|
317
|
-
examples: ['docguard verify --
|
|
321
|
+
examples: ['docguard verify --evidence', 'docguard verify --evidence --format json', 'docguard verify --semantic --format json'],
|
|
318
322
|
},
|
|
319
323
|
retire: {
|
|
320
324
|
summary: 'Remove reviewed docs from active AI context while preserving recovery from a retained Git ref.',
|
|
@@ -391,6 +395,7 @@ async function main() {
|
|
|
391
395
|
// Parse flags
|
|
392
396
|
const flags = {
|
|
393
397
|
dir: '.',
|
|
398
|
+
dirExplicit: false,
|
|
394
399
|
verbose: false,
|
|
395
400
|
format: 'text',
|
|
396
401
|
fix: false,
|
|
@@ -401,6 +406,7 @@ async function main() {
|
|
|
401
406
|
for (let i = 1; i < args.length; i++) {
|
|
402
407
|
if (args[i] === '--dir' && args[i + 1]) {
|
|
403
408
|
flags.dir = args[i + 1];
|
|
409
|
+
flags.dirExplicit = true;
|
|
404
410
|
i++;
|
|
405
411
|
} else if (args[i] === '--verbose') {
|
|
406
412
|
flags.verbose = true;
|
|
@@ -438,6 +444,8 @@ async function main() {
|
|
|
438
444
|
// v0.28 (field report #5): `docguard verify --semantic` extracts
|
|
439
445
|
// documented numbers/enums/limits for the agent to check against code.
|
|
440
446
|
flags.semantic = true;
|
|
447
|
+
} else if (args[i] === '--evidence') {
|
|
448
|
+
flags.evidence = true;
|
|
441
449
|
} else if (args[i] === '--instructions') {
|
|
442
450
|
// v0.30: `docguard verify --instructions` audits AGENTS.md/CLAUDE.md for
|
|
443
451
|
// duplicate/contradictory/stale rules (MemoryLint-inspired).
|
|
@@ -544,6 +552,8 @@ async function main() {
|
|
|
544
552
|
// v0.21: `docguard demo --keep` doesn't delete the temp fixture after
|
|
545
553
|
// running (useful for poking around what DocGuard set up).
|
|
546
554
|
flags.keep = true;
|
|
555
|
+
} else if (args[i] === '--task') {
|
|
556
|
+
flags.task = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : '';
|
|
547
557
|
} else if (!args[i].startsWith('--') && i > 0) {
|
|
548
558
|
// Positional args go into flags.args for commands that take them (e.g.
|
|
549
559
|
// `docguard trace --reverse <path>`). Skip the command itself (i === 0).
|
|
@@ -696,8 +706,18 @@ async function main() {
|
|
|
696
706
|
|
|
697
707
|
if (!headless) printBanner();
|
|
698
708
|
|
|
709
|
+
const rootGuidance = detectRepositoryRootGuidance(projectDir, {
|
|
710
|
+
explicitDir: flags.dirExplicit,
|
|
711
|
+
argv: args,
|
|
712
|
+
});
|
|
713
|
+
if (rootGuidance) {
|
|
714
|
+
process.stderr.write(renderRepositoryRootGuidance(rootGuidance, { machine: jsonMode }) + '\n');
|
|
715
|
+
}
|
|
699
716
|
const config = loadConfig(projectDir);
|
|
700
|
-
|
|
717
|
+
// Init and diagnose --auto can scaffold several unrelated files and retain
|
|
718
|
+
// their legacy default-layout contract. Generate, sync, and fix perform their
|
|
719
|
+
// own target/section authorization so mapped layouts can use bounded writers.
|
|
720
|
+
if (['init', 'setup'].includes(command) || command === 'diagnose' && flags.auto) assertDefaultDocWrites(config);
|
|
701
721
|
|
|
702
722
|
// `--no-baseline` disables the committed adoption baseline for this run —
|
|
703
723
|
// threaded through config so guard, ci, report, and mcp all honor it the
|
|
@@ -839,6 +859,7 @@ async function main() {
|
|
|
839
859
|
case 'agent':
|
|
840
860
|
// v0.26 (field report §2): one-shot, dependency-ordered task graph with
|
|
841
861
|
// pre-filled code-truth + per-task verify. Read-only; JSON by default.
|
|
862
|
+
// @implements docguard.task-specific-agent-context#FR-010
|
|
842
863
|
runAgent(projectDir, config, flags);
|
|
843
864
|
break;
|
|
844
865
|
case 'hooks':
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-only source adapters for evidence-scoped verification.
|
|
3
|
+
* @implements docguard.evidence-scoped-verification#FR-002
|
|
4
|
+
* @implements docguard.evidence-scoped-verification#FR-003
|
|
5
|
+
* @implements docguard.evidence-scoped-verification#FR-004
|
|
6
|
+
* @implements docguard.evidence-scoped-verification#FR-005
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
11
|
+
import { buildIgnoreFilter, compileGlob, DEFAULT_IGNORE_DIRS, relPosix } from '../shared-ignore.mjs';
|
|
12
|
+
|
|
13
|
+
const MAX_COLLECTION_FILES = 20_000;
|
|
14
|
+
const MAX_REPORT_FINDINGS = 10_000;
|
|
15
|
+
const OASDIFF_LEVELS = new Set(['ERR', 'WARN', 'INFO', 'NONE']);
|
|
16
|
+
|
|
17
|
+
const answer = (status, reasonCode, message, extra = {}) => ({ ...extra, status, reasonCode, message });
|
|
18
|
+
|
|
19
|
+
export function resolveJsonPointer(value, pointer) {
|
|
20
|
+
if (pointer === '') return { found: true, value };
|
|
21
|
+
if (typeof pointer !== 'string' || !pointer.startsWith('/')) return { found: false, reason: 'invalid-json-pointer' };
|
|
22
|
+
let current = value;
|
|
23
|
+
for (const raw of pointer.slice(1).split('/')) {
|
|
24
|
+
if (/~(?:[^01]|$)/.test(raw)) return { found: false, reason: 'invalid-json-pointer-escape' };
|
|
25
|
+
const token = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
26
|
+
if (Array.isArray(current)) {
|
|
27
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(token)) return { found: false, reason: 'invalid-array-index' };
|
|
28
|
+
const index = Number(token);
|
|
29
|
+
if (!Number.isSafeInteger(index) || index >= current.length) return { found: false, reason: 'unresolved-json-pointer' };
|
|
30
|
+
current = current[index];
|
|
31
|
+
} else if (current && typeof current === 'object' && Object.hasOwn(current, token)) {
|
|
32
|
+
current = current[token];
|
|
33
|
+
} else {
|
|
34
|
+
return { found: false, reason: 'unresolved-json-pointer' };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { found: true, value: current };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lexicalBase(pattern) {
|
|
41
|
+
const parts = [];
|
|
42
|
+
for (const part of pattern.split('/')) {
|
|
43
|
+
if (/[*?{]/.test(part)) break;
|
|
44
|
+
parts.push(part);
|
|
45
|
+
}
|
|
46
|
+
return parts.join('/') || '.';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Count regular, non-symlink files with a bounded walk and the shared ignore contract. */
|
|
50
|
+
export function countEvidenceCollection(projectDir, pattern, config = {}) {
|
|
51
|
+
if (typeof pattern !== 'string' || pattern.startsWith('/') || /[\\:\0]/.test(pattern)
|
|
52
|
+
|| pattern.split('/').some(part => part === '..' || part.toLowerCase() === '.local' || /^\.env(?:\.|$)/i.test(part))) {
|
|
53
|
+
return answer('inconclusive', 'unsafe-collection-base', 'Collection pattern is unsafe.');
|
|
54
|
+
}
|
|
55
|
+
let root;
|
|
56
|
+
try { root = realpathSync(projectDir); } catch { return answer('inconclusive', 'repository-unavailable', 'Repository root is unavailable.'); }
|
|
57
|
+
let matcher;
|
|
58
|
+
try { matcher = compileGlob(pattern); } catch { return answer('unsupported', 'unsupported-glob', 'Collection glob cannot be compiled.'); }
|
|
59
|
+
const ignored = buildIgnoreFilter(config.ignore || []);
|
|
60
|
+
const lexical = lexicalBase(pattern);
|
|
61
|
+
let base = root;
|
|
62
|
+
try {
|
|
63
|
+
for (const part of lexical.split('/').filter(item => item && item !== '.')) {
|
|
64
|
+
base = resolve(base, part);
|
|
65
|
+
if (lstatSync(base).isSymbolicLink()) return answer('inconclusive', 'symlink', 'Collection base traverses a symlink.');
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code === 'ENOENT') return answer('ok', 'collection-read', 'Collection evaluated.', { value: 0, inputHashes: [] });
|
|
69
|
+
return answer('inconclusive', 'collection-unavailable', 'Collection base is unavailable.');
|
|
70
|
+
}
|
|
71
|
+
const relBase = relative(root, base);
|
|
72
|
+
if (isAbsolute(relBase) || relBase === '..' || relBase.startsWith(`..${sep}`)) {
|
|
73
|
+
return answer('inconclusive', 'unsafe-collection-base', 'Collection base leaves the repository.');
|
|
74
|
+
}
|
|
75
|
+
let baseStat;
|
|
76
|
+
try { baseStat = lstatSync(base); } catch (error) {
|
|
77
|
+
if (error?.code === 'ENOENT') return answer('ok', 'collection-read', 'Collection evaluated.', { value: 0, inputHashes: [] });
|
|
78
|
+
return answer('inconclusive', 'collection-unavailable', 'Collection base is unavailable.');
|
|
79
|
+
}
|
|
80
|
+
if (baseStat.isSymbolicLink()) return answer('inconclusive', 'symlink', 'Collection base is a symlink.');
|
|
81
|
+
let visited = 0;
|
|
82
|
+
let matched = 0;
|
|
83
|
+
const walk = path => {
|
|
84
|
+
let entries;
|
|
85
|
+
try { entries = readdirSync(path, { withFileTypes: true }); }
|
|
86
|
+
catch { throw new Error('collection-unreadable'); }
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (++visited > MAX_COLLECTION_FILES) throw new Error('collection-budget');
|
|
89
|
+
if (DEFAULT_IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
90
|
+
const full = resolve(path, entry.name);
|
|
91
|
+
const rel = relPosix(root, full);
|
|
92
|
+
if (ignored(rel)) continue;
|
|
93
|
+
let stat;
|
|
94
|
+
try { stat = lstatSync(full); } catch { throw new Error('collection-unreadable'); }
|
|
95
|
+
if (stat.isSymbolicLink()) continue;
|
|
96
|
+
if (stat.isDirectory()) walk(full);
|
|
97
|
+
else if (stat.isFile() && matcher.test(rel)) matched++;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
if (baseStat.isFile()) matched = matcher.test(relPosix(root, base)) ? 1 : 0;
|
|
102
|
+
else if (baseStat.isDirectory()) walk(base);
|
|
103
|
+
else return answer('inconclusive', 'collection-not-file-or-directory', 'Collection base is not a regular file or directory.');
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return answer('inconclusive', error.message, error.message === 'collection-budget'
|
|
106
|
+
? `Collection exceeded the ${MAX_COLLECTION_FILES}-entry budget.`
|
|
107
|
+
: 'Collection could not be read completely.');
|
|
108
|
+
}
|
|
109
|
+
return answer('ok', 'collection-read', 'Collection evaluated.', { value: matched, inputHashes: [] });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function currentInputs(source, read) {
|
|
113
|
+
const hashes = [];
|
|
114
|
+
for (const input of source.inputs) {
|
|
115
|
+
const snapshot = read(input.path);
|
|
116
|
+
if (snapshot.content === null) {
|
|
117
|
+
return answer('inconclusive', `input-${snapshot.evidence.reason}`, `Cannot safely read declared input ${input.path}.`, { inputHashes: hashes });
|
|
118
|
+
}
|
|
119
|
+
hashes.push({ path: input.path, declared: input.sha256, current: snapshot.evidence.hash });
|
|
120
|
+
}
|
|
121
|
+
const stale = hashes.filter(input => input.declared !== input.current);
|
|
122
|
+
if (stale.length) return answer('stale', 'input-digest-mismatch', `${stale.length} declared input digest(s) no longer match.`, { inputHashes: hashes });
|
|
123
|
+
return answer('ok', 'inputs-current', 'Declared report inputs are current.', { inputHashes: hashes });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readReport(source, read) {
|
|
127
|
+
if (source.adapterVersion !== 1) return answer('unsupported', 'adapter-version', `Adapter version ${source.adapterVersion} is unsupported.`);
|
|
128
|
+
const inputs = currentInputs(source, read);
|
|
129
|
+
if (inputs.status !== 'ok') return inputs;
|
|
130
|
+
const snapshot = read(source.path);
|
|
131
|
+
if (snapshot.content === null) {
|
|
132
|
+
return answer('inconclusive', `report-${snapshot.evidence.reason}`, `Cannot safely read saved ${source.adapter} report ${source.path}.`, { inputHashes: inputs.inputHashes });
|
|
133
|
+
}
|
|
134
|
+
return answer('ok', 'report-read', 'Saved report is current and readable.', {
|
|
135
|
+
content: snapshot.content,
|
|
136
|
+
sourceEvidence: snapshot.evidence,
|
|
137
|
+
inputHashes: inputs.inputHashes,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function oasdiffReport(source, read) {
|
|
142
|
+
if (!['breaking', 'changelog'].includes(source.command)) return answer('unsupported', 'oasdiff-command', `oasdiff command ${source.command} is unsupported.`);
|
|
143
|
+
const report = readReport(source, read);
|
|
144
|
+
if (report.status !== 'ok') return report;
|
|
145
|
+
let parsed;
|
|
146
|
+
try { parsed = JSON.parse(report.content); }
|
|
147
|
+
catch { return answer('inconclusive', 'malformed-oasdiff-json', 'Saved oasdiff report is not valid JSON.', report); }
|
|
148
|
+
if (!Array.isArray(parsed) || parsed.length > MAX_REPORT_FINDINGS) {
|
|
149
|
+
return answer('unsupported', 'unsupported-oasdiff-shape', 'Saved oasdiff report must be a bounded JSON array.', report);
|
|
150
|
+
}
|
|
151
|
+
const valid = parsed.every(change => change && typeof change === 'object' && !Array.isArray(change)
|
|
152
|
+
&& typeof change.id === 'string' && change.id.length > 0
|
|
153
|
+
&& (typeof change.level === 'number' || OASDIFF_LEVELS.has(change.level)));
|
|
154
|
+
if (!valid) return answer('unsupported', 'unsupported-oasdiff-change', 'Saved oasdiff report contains an unknown change shape.', report);
|
|
155
|
+
return answer('ok', 'oasdiff-report', 'Saved oasdiff report evaluated.', { ...report, value: parsed.length });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function bufReport(source, read) {
|
|
159
|
+
if (source.command !== 'breaking') return answer('unsupported', 'buf-command', `Buf command ${source.command} is unsupported.`);
|
|
160
|
+
const report = readReport(source, read);
|
|
161
|
+
if (report.status !== 'ok') return report;
|
|
162
|
+
const lines = report.content.split(/\r?\n/).filter(line => line.trim());
|
|
163
|
+
if (lines.length > MAX_REPORT_FINDINGS) return answer('unsupported', 'buf-finding-budget', `Saved Buf report exceeds ${MAX_REPORT_FINDINGS} findings.`, report);
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
let finding;
|
|
166
|
+
try { finding = JSON.parse(line); }
|
|
167
|
+
catch { return answer('inconclusive', 'malformed-buf-jsonl', 'Saved Buf report contains malformed JSON Lines.', report); }
|
|
168
|
+
if (!finding || typeof finding !== 'object' || Array.isArray(finding)
|
|
169
|
+
|| typeof finding.path !== 'string' || !finding.path
|
|
170
|
+
|| typeof finding.type !== 'string' || !finding.type
|
|
171
|
+
|| typeof finding.message !== 'string' || !finding.message) {
|
|
172
|
+
return answer('unsupported', 'unsupported-buf-finding', 'Saved Buf report contains an unknown violation shape.', report);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return answer('ok', 'buf-report', 'Saved Buf report evaluated.', { ...report, value: lines.length });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function readEvidenceSource(projectDir, declaration, read, config = {}) {
|
|
179
|
+
const source = declaration.source;
|
|
180
|
+
if (source.adapter === 'json-pointer') {
|
|
181
|
+
const snapshot = read(source.path);
|
|
182
|
+
if (snapshot.content === null) return answer('inconclusive', `source-${snapshot.evidence.reason}`, `Cannot safely read JSON source ${source.path}.`);
|
|
183
|
+
let parsed;
|
|
184
|
+
try { parsed = JSON.parse(snapshot.content); }
|
|
185
|
+
catch { return answer('inconclusive', 'malformed-source-json', `JSON source ${source.path} is malformed.`, { sourceEvidence: snapshot.evidence, inputHashes: [] }); }
|
|
186
|
+
const selected = resolveJsonPointer(parsed, source.pointer);
|
|
187
|
+
if (!selected.found) return answer('inconclusive', selected.reason, `JSON Pointer ${source.pointer || '<root>'} did not resolve.`, { sourceEvidence: snapshot.evidence, inputHashes: [] });
|
|
188
|
+
return answer('ok', 'json-pointer-resolved', 'JSON Pointer resolved.', { value: selected.value, sourceEvidence: snapshot.evidence, inputHashes: [] });
|
|
189
|
+
}
|
|
190
|
+
if (source.adapter === 'collection-count') {
|
|
191
|
+
const result = countEvidenceCollection(projectDir, source.glob, config);
|
|
192
|
+
if (result.status === 'ok' && result.value === 0 && !source.allowEmpty) {
|
|
193
|
+
return answer('inconclusive', 'empty-collection-not-allowed', 'Collection matched no files and allowEmpty is false.', result);
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
if (source.adapter === 'oasdiff') return oasdiffReport(source, read);
|
|
198
|
+
if (source.adapter === 'buf') return bufReport(source, read);
|
|
199
|
+
return answer('unsupported', 'unsupported-adapter', `Adapter ${source.adapter} is unsupported.`);
|
|
200
|
+
}
|