docguard-cli 0.32.0 → 0.33.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/PHILOSOPHY.md +1 -0
- package/PRIVACY.md +45 -0
- package/README.md +68 -28
- package/cli/commands/ci.mjs +52 -13
- package/cli/commands/guard.mjs +80 -0
- package/cli/commands/mcp.mjs +16 -0
- package/cli/commands/report.mjs +200 -0
- package/cli/commands/score.mjs +55 -1
- package/cli/docguard.mjs +56 -11
- package/cli/shared-git.mjs +23 -0
- package/cli/validators/docs-coverage.mjs +5 -0
- package/cli/writers/baseline.mjs +84 -0
- package/cli/writers/history.mjs +82 -0
- package/cli/writers/junit.mjs +103 -0
- package/docs/commands.md +30 -2
- package/docs/configuration.md +14 -0
- package/docs/faq.md +31 -0
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- 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/package.json +4 -3
- package/schemas/docguard-config.schema.json +5 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report Command — Compliance-evidence bundle for audits.
|
|
3
|
+
*
|
|
4
|
+
* `docguard report` runs guard + score internally and emits a deterministic
|
|
5
|
+
* evidence report: who/what/when (git commit, branch, tool version), the
|
|
6
|
+
* guard verdict per validator, the findings summary, the CDD score with its
|
|
7
|
+
* ALCOA+ data-integrity attributes, and the mechanical-fix history. An
|
|
8
|
+
* `integrity` sha256 over the canonical JSON payload makes the bundle
|
|
9
|
+
* tamper-evident — re-running `docguard report --format json` at the same
|
|
10
|
+
* commit reproduces the same evidence. The generation timestamp and the
|
|
11
|
+
* ALCOA+ section are excluded from the hash for that reason (both are
|
|
12
|
+
* wall-clock-relative; ALCOA's Contemporaneous attribute also depends on
|
|
13
|
+
* file mtimes, which reset on a fresh clone).
|
|
14
|
+
*
|
|
15
|
+
* Report is EVIDENCE, not a gate: it always exits 0. `guard` and `ci` remain
|
|
16
|
+
* the commands that fail builds. This split matters for auditors — evidence
|
|
17
|
+
* collection must not change behavior depending on what it observes.
|
|
18
|
+
*
|
|
19
|
+
* Output: markdown to stdout by default, `--format json` for the machine
|
|
20
|
+
* bundle, `--out <file>` to write either format to a file instead.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { resolve as resolvePath, dirname } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { c } from '../shared.mjs';
|
|
28
|
+
import { runGuardInternal } from './guard.mjs';
|
|
29
|
+
import { runScoreInternal, computeAlcoaCompliance } from './score.mjs';
|
|
30
|
+
import { getHeadInfo, isGitRepo } from '../shared-git.mjs';
|
|
31
|
+
import { loadFixMemory } from '../writers/fix-memory.mjs';
|
|
32
|
+
|
|
33
|
+
const _PKG = JSON.parse(readFileSync(resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8'));
|
|
34
|
+
const CLI_VERSION = _PKG.version;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the evidence payload. Pure gather — no printing, no exit. The
|
|
38
|
+
* `integrity` hash covers everything EXCEPT `generatedAt` and the hash
|
|
39
|
+
* itself, so the same tree state always yields the same hash.
|
|
40
|
+
*/
|
|
41
|
+
export function buildReport(projectDir, config) {
|
|
42
|
+
const guardData = runGuardInternal(projectDir, config);
|
|
43
|
+
const scoreData = runScoreInternal(projectDir, config);
|
|
44
|
+
const alcoa = computeAlcoaCompliance(projectDir, config, scoreData.categories);
|
|
45
|
+
const git = isGitRepo(projectDir) ? getHeadInfo(projectDir) : null;
|
|
46
|
+
const fixMemory = loadFixMemory(projectDir);
|
|
47
|
+
|
|
48
|
+
// Findings grouped by stable code — auditors care about "how many of
|
|
49
|
+
// which class", not the per-file noise. Codeless findings group as OTHER.
|
|
50
|
+
const byCode = new Map();
|
|
51
|
+
for (const f of guardData.findings || []) {
|
|
52
|
+
const code = f.code || 'OTHER';
|
|
53
|
+
const entry = byCode.get(code) || { code, severity: f.severity, count: 0, sample: null };
|
|
54
|
+
entry.count++;
|
|
55
|
+
if (!entry.sample && f.message) entry.sample = f.message;
|
|
56
|
+
byCode.set(code, entry);
|
|
57
|
+
}
|
|
58
|
+
const findingsSummary = [...byCode.values()].sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
|
|
59
|
+
|
|
60
|
+
const payload = {
|
|
61
|
+
tool: { name: 'docguard', version: CLI_VERSION },
|
|
62
|
+
project: {
|
|
63
|
+
name: config.projectName,
|
|
64
|
+
profile: config.profile || 'standard',
|
|
65
|
+
type: config.projectType || 'unknown',
|
|
66
|
+
},
|
|
67
|
+
git: git ? { commit: git.commit, branch: git.branch, dirty: git.dirty } : null,
|
|
68
|
+
guard: {
|
|
69
|
+
status: guardData.status,
|
|
70
|
+
passed: guardData.passed,
|
|
71
|
+
total: guardData.total,
|
|
72
|
+
errors: guardData.errors,
|
|
73
|
+
warnings: guardData.warnings,
|
|
74
|
+
// Audit-critical (H3): evidence must disclose what a committed baseline
|
|
75
|
+
// is suppressing — "no findings" with a hidden baseline is false green.
|
|
76
|
+
baselineSuppressed: guardData.baselineSuppressed || 0,
|
|
77
|
+
validators: (guardData.validators || [])
|
|
78
|
+
.filter(v => v.status !== 'skipped')
|
|
79
|
+
.map(v => ({ name: v.name, status: v.status })),
|
|
80
|
+
},
|
|
81
|
+
findings: findingsSummary,
|
|
82
|
+
score: {
|
|
83
|
+
score: scoreData.score,
|
|
84
|
+
grade: scoreData.grade,
|
|
85
|
+
categories: scoreData.categories,
|
|
86
|
+
},
|
|
87
|
+
alcoa: {
|
|
88
|
+
score: alcoa.score,
|
|
89
|
+
met: alcoa.met,
|
|
90
|
+
total: alcoa.total,
|
|
91
|
+
attributes: alcoa.attributes.map(a => ({
|
|
92
|
+
name: a.name, met: a.met, evidence: a.evidence, gap: a.gap,
|
|
93
|
+
})),
|
|
94
|
+
},
|
|
95
|
+
fixHistory: {
|
|
96
|
+
entries: fixMemory.entries.length,
|
|
97
|
+
lastApplied: fixMemory.entries.length
|
|
98
|
+
? fixMemory.entries.reduce((max, e) => (e.appliedAt > max ? e.appliedAt : max), '')
|
|
99
|
+
: null,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Integrity scope (M3): the hash covers the git-stable sections only. The
|
|
104
|
+
// ALCOA+ block is excluded because its Contemporaneous attribute derives
|
|
105
|
+
// from file mtimes vs now — it drifts with wall-clock time and resets on a
|
|
106
|
+
// fresh clone, which would break "same commit ⇒ same hash".
|
|
107
|
+
const { alcoa: _unhashed, ...hashable } = payload;
|
|
108
|
+
const integrity = 'sha256:' + createHash('sha256').update(JSON.stringify(hashable)).digest('hex');
|
|
109
|
+
return { ...payload, generatedAt: new Date().toISOString(), integrity };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toMarkdown(r) {
|
|
113
|
+
const lines = [];
|
|
114
|
+
const gitLine = r.git
|
|
115
|
+
? `commit \`${r.git.commit.slice(0, 12)}\`${r.git.branch ? ` (${r.git.branch})` : ' (detached HEAD)'}${r.git.dirty ? ' — **uncommitted changes present**' : ''}`
|
|
116
|
+
: 'not a git repository';
|
|
117
|
+
|
|
118
|
+
lines.push(`# Documentation Compliance Report — ${r.project.name}`);
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(`Generated ${r.generatedAt} by DocGuard v${r.tool.version} · ${gitLine}`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push('## Summary');
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push('| Metric | Value |');
|
|
125
|
+
lines.push('|--------|-------|');
|
|
126
|
+
lines.push(`| CDD Score | ${r.score.score}/100 (${r.score.grade}) |`);
|
|
127
|
+
lines.push(`| Guard | ${r.guard.status.toUpperCase()} — ${r.guard.passed}/${r.guard.total} checks, ${r.guard.errors} error(s), ${r.guard.warnings} warning(s) |`);
|
|
128
|
+
if (r.guard.baselineSuppressed > 0) {
|
|
129
|
+
lines.push(`| Baseline | ⚠️ ${r.guard.baselineSuppressed} pre-existing finding(s) suppressed by \`.docguard.baseline.json\` — not reflected in the counts above |`);
|
|
130
|
+
}
|
|
131
|
+
lines.push(`| ALCOA+ data integrity | ${r.alcoa.score}% (${r.alcoa.met}/${r.alcoa.total} attributes) |`);
|
|
132
|
+
lines.push(`| Profile | ${r.project.profile} (${r.project.type}) |`);
|
|
133
|
+
lines.push('');
|
|
134
|
+
|
|
135
|
+
lines.push('## Validators');
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push('| Validator | Status |');
|
|
138
|
+
lines.push('|-----------|--------|');
|
|
139
|
+
for (const v of r.guard.validators) {
|
|
140
|
+
const icon = v.status === 'pass' ? '✅' : v.status === 'warn' ? '⚠️' : v.status === 'na' ? '➖' : '❌';
|
|
141
|
+
lines.push(`| ${v.name} | ${icon} ${v.status} |`);
|
|
142
|
+
}
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
lines.push('## Findings');
|
|
146
|
+
lines.push('');
|
|
147
|
+
if (r.findings.length === 0) {
|
|
148
|
+
lines.push(r.guard.baselineSuppressed > 0
|
|
149
|
+
? `No new findings beyond the ${r.guard.baselineSuppressed} suppressed by the committed baseline (run \`docguard guard --no-baseline\` for the full picture).`
|
|
150
|
+
: 'No findings — documentation matches the implementation at this commit.');
|
|
151
|
+
} else {
|
|
152
|
+
lines.push('| Code | Severity | Count | Example |');
|
|
153
|
+
lines.push('|------|----------|------:|---------|');
|
|
154
|
+
for (const f of r.findings) {
|
|
155
|
+
lines.push(`| ${f.code} | ${f.severity} | ${f.count} | ${(f.sample || '').replace(/\|/g, '\\|')} |`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
lines.push('');
|
|
159
|
+
|
|
160
|
+
lines.push('## ALCOA+ Attributes');
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push('| Attribute | Met | Evidence / Gap |');
|
|
163
|
+
lines.push('|-----------|-----|----------------|');
|
|
164
|
+
for (const a of r.alcoa.attributes) {
|
|
165
|
+
lines.push(`| ${a.name} | ${a.met ? '✅' : '❌'} | ${(a.met ? a.evidence : a.gap) || '—'} |`);
|
|
166
|
+
}
|
|
167
|
+
lines.push('');
|
|
168
|
+
|
|
169
|
+
lines.push('## Fix History');
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(r.fixHistory.entries
|
|
172
|
+
? `${r.fixHistory.entries} mechanical fix(es) on record (\`.docguard/fixed.json\`), last applied ${r.fixHistory.lastApplied}.`
|
|
173
|
+
: 'No mechanical fixes on record.');
|
|
174
|
+
lines.push('');
|
|
175
|
+
|
|
176
|
+
lines.push('## Integrity');
|
|
177
|
+
lines.push('');
|
|
178
|
+
lines.push(`\`${r.integrity}\` — sha256 over the canonical JSON payload, excluding \`generatedAt\`, this hash, and the \`alcoa\` section (its Contemporaneous attribute is wall-clock/mtime-relative). Re-run \`docguard report --format json\` at the same commit to reproduce.`);
|
|
179
|
+
lines.push('');
|
|
180
|
+
return lines.join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function runReport(projectDir, config, flags) {
|
|
184
|
+
const report = buildReport(projectDir, config);
|
|
185
|
+
const isJson = flags.format === 'json';
|
|
186
|
+
const output = isJson ? JSON.stringify(report, null, 2) : toMarkdown(report);
|
|
187
|
+
|
|
188
|
+
if (flags.out) {
|
|
189
|
+
writeFileSync(resolvePath(projectDir, flags.out), output + '\n');
|
|
190
|
+
// Chrome goes to stderr-style short confirm only in non-JSON mode; in
|
|
191
|
+
// JSON mode stay silent so scripted callers see nothing unexpected.
|
|
192
|
+
if (!isJson) console.log(`${c.green}✅ Report written to ${flags.out}${c.reset}`);
|
|
193
|
+
return report;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Machine/markdown output IS the artifact: write + natural exit (never
|
|
197
|
+
// console.log + process.exit — >8 KB payloads truncate through a pipe).
|
|
198
|
+
process.stdout.write(output + '\n');
|
|
199
|
+
return report;
|
|
200
|
+
}
|
package/cli/commands/score.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { validateSecurity } from '../validators/security.mjs';
|
|
|
11
11
|
import { runGuardInternal } from './guard.mjs';
|
|
12
12
|
import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
|
|
13
13
|
import { assessAgentReadability } from '../scanners/agent-readability.mjs';
|
|
14
|
+
import { loadHistory, sparkline } from '../writers/history.mjs';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Detect whether the project configures a test runner (the "Check 3" of the
|
|
@@ -144,6 +145,10 @@ const WEIGHTS = {
|
|
|
144
145
|
};
|
|
145
146
|
|
|
146
147
|
export function runScore(projectDir, config, flags) {
|
|
148
|
+
// v0.33: `--trend` renders the local score history recorded by `docguard
|
|
149
|
+
// ci` (.docguard/history.jsonl) instead of recomputing a score.
|
|
150
|
+
if (flags.trend) return runTrend(projectDir, config, flags);
|
|
151
|
+
|
|
147
152
|
// v0.16-P1: suppress banner in JSON mode so stdout stays parseable.
|
|
148
153
|
// Was already fixed for guard/diagnose in v0.12; score/trace/diff missed
|
|
149
154
|
// the pattern. Reported on a Python project where `score --format json`
|
|
@@ -344,6 +349,52 @@ export function runScore(projectDir, config, flags) {
|
|
|
344
349
|
console.log(` ${c.dim}📎 Badge: ${c.reset}\n`);
|
|
345
350
|
}
|
|
346
351
|
|
|
352
|
+
/**
|
|
353
|
+
* `score --trend` — render the score trajectory from `.docguard/history.jsonl`
|
|
354
|
+
* (written by `docguard ci`). Read-only display; exits 0 whether or not
|
|
355
|
+
* history exists — trend is information, not a gate.
|
|
356
|
+
*/
|
|
357
|
+
function runTrend(projectDir, config, flags) {
|
|
358
|
+
const isJson = flags.format === 'json';
|
|
359
|
+
const entries = loadHistory(projectDir, 50);
|
|
360
|
+
|
|
361
|
+
if (isJson) {
|
|
362
|
+
const latest = entries[entries.length - 1] || null;
|
|
363
|
+
const first = entries[0] || null;
|
|
364
|
+
process.stdout.write(JSON.stringify({
|
|
365
|
+
project: config.projectName,
|
|
366
|
+
entries,
|
|
367
|
+
latest,
|
|
368
|
+
delta: latest && first ? latest.score - first.score : null,
|
|
369
|
+
}, null, 2) + '\n');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
console.log(`${c.bold}📈 DocGuard Score Trend — ${config.projectName}${c.reset}\n`);
|
|
374
|
+
if (entries.length === 0) {
|
|
375
|
+
console.log(` ${c.dim}No history yet. Run ${c.cyan}docguard ci${c.dim} to start recording`);
|
|
376
|
+
console.log(` score history to .docguard/history.jsonl (one line per run).${c.reset}\n`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const scores = entries.map(e => e.score);
|
|
381
|
+
const latest = entries[entries.length - 1];
|
|
382
|
+
const first = entries[0];
|
|
383
|
+
const delta = latest.score - first.score;
|
|
384
|
+
const deltaStr = delta > 0 ? `${c.green}+${delta}${c.reset}` : delta < 0 ? `${c.red}${delta}${c.reset}` : '±0';
|
|
385
|
+
|
|
386
|
+
console.log(` ${sparkline(scores)} ${first.score} → ${c.bold}${latest.score}${c.reset} (${deltaStr}) over ${entries.length} run(s)\n`);
|
|
387
|
+
|
|
388
|
+
const recent = entries.slice(-10);
|
|
389
|
+
for (const e of recent) {
|
|
390
|
+
const icon = e.status === 'PASS' ? '✅' : e.status === 'WARN' ? '⚠️ ' : '❌';
|
|
391
|
+
const when = (e.timestamp || '').slice(0, 10);
|
|
392
|
+
const commit = e.commit ? ` ${c.dim}@${e.commit.slice(0, 7)}${c.reset}` : '';
|
|
393
|
+
console.log(` ${icon} ${when} ${String(e.score).padStart(3)}/100 (${e.grade})${commit}`);
|
|
394
|
+
}
|
|
395
|
+
console.log('');
|
|
396
|
+
}
|
|
397
|
+
|
|
347
398
|
/**
|
|
348
399
|
* Internal scoring — returns data without printing.
|
|
349
400
|
* Used by badge, ci, and other commands that need the score.
|
|
@@ -363,8 +414,11 @@ export function runScoreInternal(projectDir, config) {
|
|
|
363
414
|
* + Complete, Consistent, Enduring, Available
|
|
364
415
|
*
|
|
365
416
|
* Reference: WHO Technical Report Series, No. 996, 2016, Annex 5
|
|
417
|
+
*
|
|
418
|
+
* Exported for `docguard report` — the evidence bundle embeds the same
|
|
419
|
+
* ALCOA+ table the score display renders, from one computation.
|
|
366
420
|
*/
|
|
367
|
-
function computeAlcoaCompliance(projectDir, config, scores) {
|
|
421
|
+
export function computeAlcoaCompliance(projectDir, config, scores) {
|
|
368
422
|
const attributes = [];
|
|
369
423
|
|
|
370
424
|
// 1. Attributable — Can we trace who wrote/reviewed docs?
|
package/cli/docguard.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import { runImpact } from './commands/impact.mjs';
|
|
|
45
45
|
import { runExplain } from './commands/explain.mjs';
|
|
46
46
|
import { runFeedback } from './commands/feedback.mjs';
|
|
47
47
|
import { runVerify } from './commands/verify.mjs';
|
|
48
|
+
import { runReport } from './commands/report.mjs';
|
|
48
49
|
import { runMemory } from './commands/memory.mjs';
|
|
49
50
|
import { runDemo } from './commands/demo.mjs';
|
|
50
51
|
import { runAgent } from './commands/agent.mjs';
|
|
@@ -80,7 +81,7 @@ ${c.bold}The Daily 5${c.reset} ${c.dim}— what you'll reach for 95% of the time
|
|
|
80
81
|
${c.green}guard${c.reset} Validate against canonical docs (all validators)
|
|
81
82
|
${c.green}diff${c.reset} Show gaps between docs and code (add ${c.cyan}--since <ref>${c.reset} for changed-file impact)
|
|
82
83
|
${c.green}sync${c.reset} Refresh code-truth doc sections — keeps memory always up to date
|
|
83
|
-
${c.green}score${c.reset} CDD maturity score (0-100; ${c.cyan}--diff${c.reset} for delta between refs)
|
|
84
|
+
${c.green}score${c.reset} CDD maturity score (0-100; ${c.cyan}--diff${c.reset} for delta between refs, ${c.cyan}--trend${c.reset} for history from \`ci\` runs)
|
|
84
85
|
|
|
85
86
|
${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
86
87
|
${c.green}demo${c.reset} Zero-install tour: see what DocGuard catches against a sample project in 30s
|
|
@@ -91,7 +92,9 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
91
92
|
${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
|
|
92
93
|
${c.green}verify${c.reset} Extract documented numbers/limits/enums for an agent to check vs code (${c.cyan}--semantic${c.reset})
|
|
93
94
|
${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
|
|
94
|
-
${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/diagnose as agent tools
|
|
95
|
+
${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/report/diagnose as agent tools
|
|
96
|
+
${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})
|
|
97
|
+
${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)
|
|
95
98
|
${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
|
|
96
99
|
${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)
|
|
97
100
|
${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
|
|
@@ -100,14 +103,14 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
100
103
|
${c.bold}init --with <name>${c.reset} ${c.dim}— optional scaffolders, picked at init time${c.reset}
|
|
101
104
|
${c.dim}agents${c.reset} AGENTS.md / CLAUDE.md / .cursor/rules / Copilot instructions
|
|
102
105
|
${c.dim}hooks${c.reset} Git pre-commit / pre-push hooks
|
|
103
|
-
${c.dim}ci${c.reset}
|
|
106
|
+
${c.dim}ci${c.reset} Run the CI gate (guard + score) once, right after init
|
|
104
107
|
${c.dim}badge${c.reset} Shields.io score badges for README
|
|
105
108
|
${c.dim}llms${c.reset} llms.txt generation
|
|
106
109
|
${c.dim}publish${c.reset} External doc-site scaffold (Mintlify) ${c.dim}— experimental${c.reset}
|
|
107
110
|
|
|
108
111
|
${c.bold}Deprecation aliases${c.reset} ${c.dim}— still work in v0.20.x with a yellow warning${c.reset}
|
|
109
112
|
${c.dim}setup${c.reset} → ${c.cyan}init --wizard${c.reset}
|
|
110
|
-
${c.dim}agents · hooks ·
|
|
113
|
+
${c.dim}agents · hooks · badge · llms · publish${c.reset} → ${c.cyan}init --with <name>${c.reset}
|
|
111
114
|
${c.dim}impact${c.reset} → ${c.cyan}diff --since <ref>${c.reset}
|
|
112
115
|
${c.dim}audit${c.reset} → ${c.green}guard${c.reset} ${c.dim}(permanent — no warning, no removal planned)${c.reset}
|
|
113
116
|
${c.dim}See docs-implementation/MIGRATION-v0.20.md for the full timeline.${c.reset}
|
|
@@ -211,13 +214,15 @@ const COMMAND_HELP = {
|
|
|
211
214
|
},
|
|
212
215
|
guard: {
|
|
213
216
|
summary: 'Validate code against canonical docs (all validators).',
|
|
214
|
-
usage: 'docguard guard [--format json] [--changed-only] [--fail-on-warning]',
|
|
217
|
+
usage: 'docguard guard [--format json|sarif|junit] [--changed-only] [--fail-on-warning]',
|
|
215
218
|
flags: [
|
|
216
|
-
['--format json', 'Machine-readable results for CI'],
|
|
219
|
+
['--format json', 'Machine-readable results for CI (also: sarif, junit)'],
|
|
217
220
|
['--changed-only', 'Only validate docs/code touched in the working tree'],
|
|
218
221
|
['--fail-on-warning', 'Exit non-zero on warnings (strict CI)'],
|
|
222
|
+
['--update-baseline', 'Freeze current findings to .docguard.baseline.json — adopt on a legacy repo without a red day one'],
|
|
223
|
+
['--no-baseline', 'Ignore the committed baseline for this run (show everything)'],
|
|
219
224
|
],
|
|
220
|
-
examples: ['docguard guard', 'docguard guard --format json'],
|
|
225
|
+
examples: ['docguard guard', 'docguard guard --format json', 'docguard guard --update-baseline'],
|
|
221
226
|
},
|
|
222
227
|
score: {
|
|
223
228
|
summary: 'CDD maturity score (0–100).',
|
|
@@ -428,6 +433,17 @@ async function main() {
|
|
|
428
433
|
// avoid collision with `docguard init --profile <name>`. `--show-timings`
|
|
429
434
|
// is the long form for users who prefer explicit verbs.
|
|
430
435
|
flags.timings = true;
|
|
436
|
+
} else if (args[i] === '--trend') {
|
|
437
|
+
flags.trend = true;
|
|
438
|
+
} else if (args[i] === '--no-history') {
|
|
439
|
+
flags.noHistory = true;
|
|
440
|
+
} else if (args[i] === '--update-baseline') {
|
|
441
|
+
flags.updateBaseline = true;
|
|
442
|
+
} else if (args[i] === '--no-baseline') {
|
|
443
|
+
flags.noBaseline = true;
|
|
444
|
+
} else if (args[i] === '--out' && args[i + 1]) {
|
|
445
|
+
flags.out = args[i + 1];
|
|
446
|
+
i++;
|
|
431
447
|
} else if (args[i] === '--quiet' || args[i] === '-q') {
|
|
432
448
|
// v0.16-P5: suppress the banner + ensureSkills decorative line.
|
|
433
449
|
// Useful inside git hooks (every commit prints the banner otherwise)
|
|
@@ -567,18 +583,26 @@ async function main() {
|
|
|
567
583
|
// `generate --plan` (and were already suppressed for `--plan --write`).
|
|
568
584
|
// v0.29: 'sarif' joins 'json' — any machine format where stdout IS the
|
|
569
585
|
// artifact belongs here, or the banner corrupts the payload.
|
|
570
|
-
|
|
586
|
+
// v0.33: 'junit' joins for the same reason (GitLab/Jenkins parse stdout XML).
|
|
587
|
+
const jsonMode = flags.format === 'json' || flags.format === 'sarif' || flags.format === 'junit';
|
|
571
588
|
// `agent` emits a machine task graph (JSON by default) — it must be banner-
|
|
572
589
|
// free and side-effect-free like the other read-only commands.
|
|
573
590
|
// `mcp`: stdout IS the JSON-RPC transport — any banner byte corrupts the stream.
|
|
574
591
|
// `nudge-hook`: stdout is the Claude Code hook feedback channel — any banner
|
|
575
592
|
// byte corrupts the JSON the hook runner parses.
|
|
576
|
-
|
|
593
|
+
// `report`: stdout IS the evidence artifact (markdown or JSON) — banner
|
|
594
|
+
// bytes would corrupt it for redirection/piping in both formats.
|
|
595
|
+
const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp' || command === 'nudge-hook' || command === 'report';
|
|
577
596
|
|
|
578
597
|
if (!headless) printBanner();
|
|
579
598
|
|
|
580
599
|
const config = loadConfig(projectDir);
|
|
581
600
|
|
|
601
|
+
// `--no-baseline` disables the committed adoption baseline for this run —
|
|
602
|
+
// threaded through config so guard, ci, report, and mcp all honor it the
|
|
603
|
+
// same way runGuardInternal sees everything else.
|
|
604
|
+
if (flags.noBaseline) config.baseline = false;
|
|
605
|
+
|
|
582
606
|
// Commands that only READ and REPORT — they must never mutate the working
|
|
583
607
|
// tree. Scaffolding (ensureSkills → .agent/.specify, spawning `specify`)
|
|
584
608
|
// belongs to setup/init/generate and the `init --with` family, where the
|
|
@@ -598,6 +622,14 @@ async function main() {
|
|
|
598
622
|
'feedback',
|
|
599
623
|
// verify only reads docs and emits a task list — pure report.
|
|
600
624
|
'verify',
|
|
625
|
+
// report gathers evidence (guard+score, read-only); --out writes only the
|
|
626
|
+
// user-named file — it must never scaffold or mutate the tree otherwise.
|
|
627
|
+
'report',
|
|
628
|
+
// ci is the pipeline gate — it must never scaffold into the workspace it
|
|
629
|
+
// gates (review finding H1: bare `docguard ci` in text mode ran
|
|
630
|
+
// ensureSkills and wrote ~9 files before gating). Its only write is its
|
|
631
|
+
// own .docguard/history.jsonl, same carve-out as feedback.
|
|
632
|
+
'ci',
|
|
601
633
|
// mcp serves read-only tools over stdio — scaffolding writes are off-limits.
|
|
602
634
|
'mcp',
|
|
603
635
|
// nudge-hook runs inside an agent's PostToolUse hook — it may write only
|
|
@@ -625,7 +657,8 @@ async function main() {
|
|
|
625
657
|
setup: { since: '0.20', replacement: 'docguard init --wizard' },
|
|
626
658
|
agents: { since: '0.20', replacement: 'docguard init --with agents' },
|
|
627
659
|
hooks: { since: '0.20', replacement: 'docguard init --with hooks' },
|
|
628
|
-
ci
|
|
660
|
+
// `ci` was deprecated here in v0.20 → un-deprecated in v0.33: it is the
|
|
661
|
+
// documented pipeline gate (guard + score + threshold), not a scaffolder.
|
|
629
662
|
badge: { since: '0.20', replacement: 'docguard init --with badge' },
|
|
630
663
|
llms: { since: '0.20', replacement: 'docguard init --with llms' },
|
|
631
664
|
publish: { since: '0.20', replacement: 'docguard init --with publish' },
|
|
@@ -720,7 +753,13 @@ async function main() {
|
|
|
720
753
|
await runInit(projectDir, config, { ...flags, with: ['badge'], skipPrompts: true });
|
|
721
754
|
break;
|
|
722
755
|
case 'ci':
|
|
723
|
-
|
|
756
|
+
// v0.33: restored as a first-class command. The v0.20 deprecation
|
|
757
|
+
// routed `ci` through runInit --with ci, which (a) scaffolded missing
|
|
758
|
+
// docs INTO the CI workspace — a validate command mutating the tree —
|
|
759
|
+
// and (b) printed init chrome into `--format json` stdout, corrupting
|
|
760
|
+
// it for parsers. A pipeline gate must be read-only and machine-clean,
|
|
761
|
+
// so it dispatches straight to runCI like guard/score.
|
|
762
|
+
runCI(projectDir, config, flags);
|
|
724
763
|
break;
|
|
725
764
|
case 'fix':
|
|
726
765
|
runFix(projectDir, config, flags);
|
|
@@ -766,6 +805,12 @@ async function main() {
|
|
|
766
805
|
// drift — the class regex/AST can't see). Read-only.
|
|
767
806
|
runVerify(projectDir, config, flags);
|
|
768
807
|
break;
|
|
808
|
+
case 'report':
|
|
809
|
+
// Compliance-evidence bundle (guard + score + ALCOA+ + fix history +
|
|
810
|
+
// integrity hash). Evidence, not a gate — always exits 0; guard/ci fail
|
|
811
|
+
// builds. Auditors need evidence collection that never self-censors.
|
|
812
|
+
runReport(projectDir, config, flags);
|
|
813
|
+
break;
|
|
769
814
|
case 'mcp':
|
|
770
815
|
// MCP stdio server — guard/score/explain/verify-claims/diagnose as tools
|
|
771
816
|
// for MCP clients. Long-lived; resolves when stdin closes.
|
package/cli/shared-git.mjs
CHANGED
|
@@ -241,6 +241,29 @@ export function lastCommitHash(dir, filePath) {
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Resolve HEAD identity for evidence reports: { commit, branch, dirty }.
|
|
246
|
+
* `branch` is null on a detached HEAD (common in CI checkouts); `dirty` is
|
|
247
|
+
* true when tracked files have uncommitted changes — evidence consumers need
|
|
248
|
+
* to know the report may not describe a reproducible tree. Returns null when
|
|
249
|
+
* the dir isn't a git repo or git is unavailable.
|
|
250
|
+
*/
|
|
251
|
+
export function getHeadInfo(dir) {
|
|
252
|
+
try {
|
|
253
|
+
const run = (args) => execFileSync('git', args, {
|
|
254
|
+
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'],
|
|
255
|
+
}).trim();
|
|
256
|
+
const commit = run(['rev-parse', 'HEAD']);
|
|
257
|
+
if (!commit) return null;
|
|
258
|
+
const branchRaw = run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
259
|
+
const branch = branchRaw === 'HEAD' ? null : branchRaw;
|
|
260
|
+
const dirty = run(['status', '--porcelain', '--untracked-files=no']) !== '';
|
|
261
|
+
return { commit, branch, dirty };
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
244
267
|
/**
|
|
245
268
|
* Resolve the absolute path to this repo's git hooks directory.
|
|
246
269
|
*
|
|
@@ -43,6 +43,11 @@ const COMMON_DOTFILES = new Set([
|
|
|
43
43
|
'.babelrc', '.browserslistrc', '.stylelintrc',
|
|
44
44
|
'.dockerignore', '.python-version', '.tool-versions', '.ruby-version',
|
|
45
45
|
'.gitkeep', '.keep',
|
|
46
|
+
// DocGuard's own files — self-explanatory (embedded _comment / schema),
|
|
47
|
+
// and flagging them creates a warning the moment a team adopts the tool
|
|
48
|
+
// (e.g. `guard --update-baseline` writing the baseline instantly produced
|
|
49
|
+
// a DCV001 about the baseline file itself).
|
|
50
|
+
'.docguard.json', '.docguardignore', '.docguard.baseline.json',
|
|
46
51
|
]);
|
|
47
52
|
|
|
48
53
|
// Generated tool artifacts (caches, coverage data, lock-data) that land at the
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adoption Baseline — `.docguard.baseline.json` (repo root, COMMITTED).
|
|
3
|
+
*
|
|
4
|
+
* The brownfield-adoption pattern (ESLint/semgrep-style): a legacy repo
|
|
5
|
+
* freezes its existing findings once (`guard --update-baseline`), commits the
|
|
6
|
+
* file, and from then on guard/ci gate only NEW drift. Suppressed findings
|
|
7
|
+
* are counted and displayed — never silently hidden — and the baseline is a
|
|
8
|
+
* reviewable diff in every PR that updates it.
|
|
9
|
+
*
|
|
10
|
+
* Root, not `.docguard/`: the state dir is gitignored, and a baseline only
|
|
11
|
+
* works if the whole team and CI share it.
|
|
12
|
+
*
|
|
13
|
+
* Fingerprints are content-addressed, not line-addressed: `code | location
|
|
14
|
+
* path (line numbers stripped) | message with digit-runs normalized to #`.
|
|
15
|
+
* Line numbers churn on every edit and messages embed volatile counts
|
|
16
|
+
* ("21 commits since…") — both would rot the baseline in a week.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
export const BASELINE_FILE = '.docguard.baseline.json';
|
|
24
|
+
|
|
25
|
+
/** Stable fingerprint for one finding. */
|
|
26
|
+
export function fingerprintFinding(f) {
|
|
27
|
+
const code = f.code || 'UNCODED';
|
|
28
|
+
const path = typeof f.location === 'string' ? f.location.replace(/:\d+$/, '') : '';
|
|
29
|
+
const msg = String(f.message || '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
|
|
30
|
+
return createHash('sha256').update(`${code}|${path}|${msg}`).digest('hex').slice(0, 16);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load the committed baseline as a Map of fingerprint → allowed occurrence
|
|
35
|
+
* count, or null when the project has none (the common case — zero overhead).
|
|
36
|
+
*
|
|
37
|
+
* Occurrence counts matter (review finding H2): two findings with the same
|
|
38
|
+
* code + file + message shape — e.g. two hardcoded passwords in one file —
|
|
39
|
+
* share a fingerprint. A count-less set would let one frozen instance
|
|
40
|
+
* suppress every FUTURE instance of that class in that file, a
|
|
41
|
+
* security-relevant false negative. With counts, freezing 1 suppresses 1;
|
|
42
|
+
* a second appearance surfaces and gates.
|
|
43
|
+
*/
|
|
44
|
+
export function loadBaseline(projectDir) {
|
|
45
|
+
const p = resolve(projectDir, BASELINE_FILE);
|
|
46
|
+
if (!existsSync(p)) return null;
|
|
47
|
+
try {
|
|
48
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'));
|
|
49
|
+
if (!data || typeof data.fingerprints !== 'object' || data.fingerprints === null) return null;
|
|
50
|
+
const map = new Map();
|
|
51
|
+
for (const [fp, n] of Object.entries(data.fingerprints)) {
|
|
52
|
+
const count = Number.isInteger(n) && n > 0 ? n : 0;
|
|
53
|
+
if (count > 0) map.set(fp, count);
|
|
54
|
+
}
|
|
55
|
+
return map.size > 0 ? map : null;
|
|
56
|
+
} catch {
|
|
57
|
+
// A malformed baseline must not silently un-gate CI: treat as absent so
|
|
58
|
+
// every finding surfaces (fail-open on visibility, fail-closed on hiding).
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Write the baseline from the current findings: fingerprint → occurrence
|
|
65
|
+
* count, keys sorted so the committed file diffs cleanly. Returns the number
|
|
66
|
+
* of distinct fingerprints.
|
|
67
|
+
*/
|
|
68
|
+
export function saveBaseline(projectDir, findings) {
|
|
69
|
+
const counts = {};
|
|
70
|
+
for (const f of findings) {
|
|
71
|
+
const fp = fingerprintFinding(f);
|
|
72
|
+
counts[fp] = (counts[fp] || 0) + 1;
|
|
73
|
+
}
|
|
74
|
+
const fingerprints = Object.fromEntries(Object.keys(counts).sort().map(k => [k, counts[k]]));
|
|
75
|
+
const doc = {
|
|
76
|
+
_comment: 'DocGuard adoption baseline — existing findings frozen at adoption time (fingerprint → occurrence count). Guard suppresses up to that many instances of each and gates everything new. Regenerate with: docguard guard --update-baseline',
|
|
77
|
+
version: 2,
|
|
78
|
+
generatedAt: new Date().toISOString(),
|
|
79
|
+
count: Object.keys(fingerprints).length,
|
|
80
|
+
fingerprints,
|
|
81
|
+
};
|
|
82
|
+
writeFileSync(resolve(projectDir, BASELINE_FILE), JSON.stringify(doc, null, 2) + '\n');
|
|
83
|
+
return Object.keys(fingerprints).length;
|
|
84
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Score History — local-first trend memory at `.docguard/history.jsonl`.
|
|
3
|
+
*
|
|
4
|
+
* `docguard ci` appends one line per run ({timestamp, commit, score, grade,
|
|
5
|
+
* errors, warnings, passed, total, status}); `docguard score --trend` reads
|
|
6
|
+
* it back and renders the trajectory. JSONL because append is the hot path:
|
|
7
|
+
* one O(1) write per CI run, and a truncated last line (crash mid-write)
|
|
8
|
+
* corrupts one entry, not the file. The rare trim rewrite goes through a
|
|
9
|
+
* temp-file + rename so a crash mid-trim can't truncate history; concurrent
|
|
10
|
+
* appends during a trim window can still lose an entry — acceptable for a
|
|
11
|
+
* trend log, not a ledger.
|
|
12
|
+
*
|
|
13
|
+
* Local-first by design: `.docguard/` is gitignored, so history accumulates
|
|
14
|
+
* per checkout. In ephemeral CI, persist it across runs with a cache/artifact
|
|
15
|
+
* step (see CI-RECIPES) — the file format is stable and merge-friendly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { resolve, dirname } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const HISTORY_PATH = '.docguard/history.jsonl';
|
|
22
|
+
|
|
23
|
+
// Trim trigger: beyond this many entries the file is rewritten keeping the
|
|
24
|
+
// most recent MAX_ENTRIES. Generous — 1000 CI runs of ~150 bytes ≈ 150 KB.
|
|
25
|
+
const MAX_ENTRIES = 1000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Append one run entry. Silent no-op on failure (read-only checkouts, odd
|
|
29
|
+
* CI filesystems) — recording history must never fail the pipeline it's
|
|
30
|
+
* recording.
|
|
31
|
+
*/
|
|
32
|
+
export function appendHistory(projectDir, entry) {
|
|
33
|
+
try {
|
|
34
|
+
const p = resolve(projectDir, HISTORY_PATH);
|
|
35
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
36
|
+
appendFileSync(p, JSON.stringify(entry) + '\n');
|
|
37
|
+
// Occasional trim, checked cheaply by size (~200 KB ≫ MAX_ENTRIES rows).
|
|
38
|
+
// Temp-file + rename: a crash mid-trim leaves the old file intact
|
|
39
|
+
// instead of a truncated one (L2).
|
|
40
|
+
if (statSync(p).size > 256 * 1024) {
|
|
41
|
+
const rows = loadHistory(projectDir, MAX_ENTRIES);
|
|
42
|
+
const tmp = p + '.tmp';
|
|
43
|
+
writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
44
|
+
renameSync(tmp, p);
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the last `limit` valid entries, oldest → newest. Malformed lines
|
|
54
|
+
* (partial writes, hand edits) are skipped, never thrown.
|
|
55
|
+
*/
|
|
56
|
+
export function loadHistory(projectDir, limit = 50) {
|
|
57
|
+
try {
|
|
58
|
+
const p = resolve(projectDir, HISTORY_PATH);
|
|
59
|
+
if (!existsSync(p)) return [];
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const line of readFileSync(p, 'utf-8').split('\n')) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
try {
|
|
64
|
+
const e = JSON.parse(line);
|
|
65
|
+
if (e && typeof e.score === 'number') out.push(e);
|
|
66
|
+
} catch { /* skip malformed line */ }
|
|
67
|
+
}
|
|
68
|
+
return out.slice(-limit);
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Unicode sparkline over the score series (0–100 → ▁–█). Pure display.
|
|
76
|
+
*/
|
|
77
|
+
export function sparkline(scores) {
|
|
78
|
+
const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
79
|
+
return scores
|
|
80
|
+
.map(s => BARS[Math.min(BARS.length - 1, Math.max(0, Math.floor((s / 100) * BARS.length)))])
|
|
81
|
+
.join('');
|
|
82
|
+
}
|