docguard-cli 0.26.0 → 0.28.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 +5 -3
- package/cli/commands/explain.mjs +31 -7
- package/cli/commands/feedback.mjs +163 -0
- package/cli/commands/guard.mjs +90 -22
- package/cli/commands/init.mjs +23 -1
- package/cli/commands/score.mjs +65 -32
- package/cli/commands/sync-tests.mjs +272 -0
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/verify.mjs +67 -0
- package/cli/docguard.mjs +49 -4
- package/cli/findings.mjs +194 -0
- package/cli/scanners/semantic-claims.mjs +154 -0
- package/cli/shared-source.mjs +24 -2
- package/cli/validators/api-surface.mjs +75 -9
- package/cli/validators/architecture.mjs +25 -13
- package/cli/validators/doc-quality.mjs +14 -3
- package/cli/validators/security.mjs +117 -31
- package/cli/validators/todo-tracking.mjs +4 -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 +1 -1
- package/templates/ENVIRONMENT.md.template +5 -0
- package/templates/REQUIREMENTS.md.template +2 -0
- package/templates/SECURITY.md.template +6 -1
- package/templates/TEST-SPEC.md.template +5 -0
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co
|
|
|
63
63
|
|
|
64
64
|
```mermaid
|
|
65
65
|
graph TD
|
|
66
|
-
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (
|
|
66
|
+
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (17)"]
|
|
67
67
|
Commands --> guard["guard"]
|
|
68
68
|
Commands --> generate["generate"]
|
|
69
69
|
Commands --> score["score"]
|
|
@@ -250,7 +250,7 @@ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/
|
|
|
250
250
|
|
|
251
251
|
## Usage
|
|
252
252
|
|
|
253
|
-
DocGuard ships **
|
|
253
|
+
DocGuard ships **17 commands** (the "Daily 5" + 12 situational tools, including the zero-install `demo`). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Eight v0.19 commands continue to work as deprecation aliases through v0.20.x — see [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
|
|
254
254
|
|
|
255
255
|
**The Daily 5** — what you'll reach for 95% of the time:
|
|
256
256
|
|
|
@@ -273,7 +273,9 @@ DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including t
|
|
|
273
273
|
| `fix --history` | Audit log of every mechanical fix applied (from `.docguard/fixed.json`) |
|
|
274
274
|
| `generate` | Reverse-engineer docs from existing codebase (`--plan` for AI scan) |
|
|
275
275
|
| `agent` | One-shot agent task graph — ordered, pre-filled code-truth, per-task verify (`--format json`) |
|
|
276
|
-
| `explain <warning>` | Paste any warning — get the validator's docstring
|
|
276
|
+
| `explain <warning\|CODE>` | Paste any warning — or a finding code like `SEC001` — to get the validator's docstring, fix path, and how to suppress |
|
|
277
|
+
| `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code — the semantic-drift class regex/AST can't see |
|
|
278
|
+
| `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
|
|
277
279
|
| `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
|
|
278
280
|
| `memory --diff` | Drill into which specific claims don't match code |
|
|
279
281
|
| `score --diff` | Drill into which checks pulled each category down |
|
package/cli/commands/explain.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { c } from '../shared.mjs';
|
|
20
|
+
import { CODES } from '../findings.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Validator-key → human-readable explainer. Keyed by the same key DocGuard
|
|
@@ -110,13 +111,14 @@ const EXPLAINERS = {
|
|
|
110
111
|
},
|
|
111
112
|
freshness: {
|
|
112
113
|
title: 'Freshness — docs updated alongside code',
|
|
113
|
-
what: 'For each canonical doc, counts code commits since
|
|
114
|
+
what: 'For each canonical doc it picks a "last updated" date by PRECEDENCE, then counts code commits since (>10 = stale): (1) an explicit `<!-- docguard:last-reviewed YYYY-MM-DD -->` marker — a human review signal git cannot see, so it WINS; (2) `<!-- docguard:status approved -->`; (3) the git commit date; (4) for an uncommitted file with no marker, it asks you to commit OR add a marker. CHANGELOG.md follows the SAME precedence — a marker satisfies it before any commit, which matters in a pre-commit edit/review loop. `docguard init` now stamps a `last-reviewed` marker into every canonical doc so freshness is marker-based and consistent from day one.',
|
|
114
115
|
why: 'Docs drift silently. This validator surfaces the drift before it becomes invisible.',
|
|
115
116
|
triggers: [
|
|
116
|
-
['code commits since last doc update', 'Run `docguard sync --write` to refresh code-truth sections, then review the prose
|
|
117
|
+
['code commits since last doc update', 'Run `docguard sync --write` to refresh code-truth sections, then review the prose and update (or add) the `<!-- docguard:last-reviewed YYYY-MM-DD -->` marker.'],
|
|
118
|
+
['not yet committed to git', 'A canonical doc has no git history and no marker. Commit it, or add `<!-- docguard:last-reviewed YYYY-MM-DD -->` (or `<!-- docguard:status approved -->`) so freshness is satisfiable before the commit.'],
|
|
117
119
|
['DRIFT-LOG.md may be stale', 'DRIFT comments in code outpaced log entries. Add the entries.'],
|
|
118
120
|
],
|
|
119
|
-
example: 'ARCHITECTURE.md last committed within 10 code commits',
|
|
121
|
+
example: 'ARCHITECTURE.md carries `<!-- docguard:last-reviewed 2026-06-19 -->` (or was committed within 10 code commits)',
|
|
120
122
|
standard: 'CDD principle: docs and code commit together',
|
|
121
123
|
},
|
|
122
124
|
traceability: {
|
|
@@ -133,12 +135,13 @@ const EXPLAINERS = {
|
|
|
133
135
|
standard: 'ISO/IEC/IEEE 29148 (requirements traceability)',
|
|
134
136
|
},
|
|
135
137
|
apiSurface: {
|
|
136
|
-
title: 'API-Surface — endpoints in code match API-REFERENCE.md',
|
|
137
|
-
what: 'Compares routes scanned from code (Express, Next, FastAPI, Spring, etc.) against endpoints listed in API-REFERENCE.md and OpenAPI specs.',
|
|
138
|
-
why: 'Documented but missing endpoints are dead links. Endpoints in code that aren\'t documented are invisible.',
|
|
138
|
+
title: 'API-Surface — endpoints in code match API-REFERENCE.md (and the spec matches the routes)',
|
|
139
|
+
what: 'Compares routes scanned from code (Express, Next, FastAPI, Spring, etc.) against endpoints listed in API-REFERENCE.md and OpenAPI specs. When an OpenAPI spec exists it is the authoritative surface — so it ALSO diffs the spec against the actually-registered code routes, catching a spec that declares a phantom endpoint (the doc reconciles clean against a wrong spec otherwise). That spec-vs-route check is conservative: it only runs when code routes are actually scannable.',
|
|
140
|
+
why: 'Documented but missing endpoints are dead links. Endpoints in code that aren\'t documented are invisible. And a spec nobody implements is a lie the doc check can\'t see.',
|
|
139
141
|
triggers: [
|
|
140
142
|
['documented but absent', 'API-REFERENCE.md lists an endpoint that scanRoutes() can\'t find. Remove or fix the doc; `fix --write` removes when marked.'],
|
|
141
143
|
['present but undocumented', 'A route exists in code but API-REFERENCE.md doesn\'t list it. Add it.'],
|
|
144
|
+
['declares', 'The OpenAPI spec declares an endpoint that no Express/Fastify/etc. route registers in code — i.e. "declares METHOD /path but no route registers it". Either implement the route or remove the phantom endpoint from the spec (the API-REFERENCE doc reconciles clean against the spec, so this is the only check that catches it).'],
|
|
142
145
|
],
|
|
143
146
|
example: 'GET /api/users in src/routes/users.ts AND in API-REFERENCE.md\'s Endpoints table',
|
|
144
147
|
standard: 'OpenAPI 3.1',
|
|
@@ -292,7 +295,7 @@ const EXPLAINERS = {
|
|
|
292
295
|
why: 'Vague, passive, negation-heavy docs are hard for both humans and AI agents to act on. Metrics inspired by IEEE 830 / ISO 29148.',
|
|
293
296
|
triggers: [
|
|
294
297
|
['High negation load', 'Rephrase in positive terms ("must not fail" → "must succeed"). If the negation is intentional (security/operational docs legitimately use "never"/"must not"), add the per-doc override: `<!-- docguard:quality negation-load off — your reason -->`, or set a custom bar with `<!-- docguard:quality negation-load 0.35 — reason -->`. Project-wide default: `docQuality.negationLoadThreshold` in .docguard.json.'],
|
|
295
|
-
['High passive voice ratio', 'Use active voice: "the config is read by the loader" → "the loader reads the config".'],
|
|
298
|
+
['High passive voice ratio', 'Use active voice: "the config is read by the loader" → "the loader reads the config". If the doc is legitimately passive (a sequence/flow doc), add the per-doc override: `<!-- docguard:quality passive-voice off — your reason -->`, or set a custom bar with `<!-- docguard:quality passive-voice 0.4 — reason -->`. Project-wide default: `docQuality.passiveVoiceThreshold` in .docguard.json.'],
|
|
296
299
|
['High ambiguous pronoun ratio', 'Replace "it/this/that/they" with the specific noun.'],
|
|
297
300
|
['Low atomicity', 'Split compound sentences so each states one verifiable fact (IEEE 830 §4.1).'],
|
|
298
301
|
['Reading level too high', 'Aim for grade 12–16 for technical docs — shorter sentences, simpler words.'],
|
|
@@ -419,6 +422,27 @@ export function runExplain(projectDir, _config, flags) {
|
|
|
419
422
|
return;
|
|
420
423
|
}
|
|
421
424
|
|
|
425
|
+
// v0.27: finding-code lookup — `docguard explain SEC001`. Codes are the stable,
|
|
426
|
+
// LLM-addressable handles that guard prints next to each finding and that
|
|
427
|
+
// inline `// docguard:ignore <CODE>` keys off.
|
|
428
|
+
const codeKey = query.toUpperCase();
|
|
429
|
+
if (CODES[codeKey]) {
|
|
430
|
+
const cd = CODES[codeKey];
|
|
431
|
+
if (isJson) {
|
|
432
|
+
console.log(JSON.stringify({ query, code: codeKey, ...cd }, null, 2));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
console.log(`${c.bold}🧭 ${codeKey} — ${cd.title}${c.reset}`);
|
|
436
|
+
console.log(`${c.dim} validator: ${cd.validator}${c.reset}\n`);
|
|
437
|
+
console.log(`${c.bold}What it means:${c.reset}\n ${cd.help}\n`);
|
|
438
|
+
if (cd.suppress) {
|
|
439
|
+
console.log(`${c.bold}Suppress inline${c.reset} ${c.dim}(only if it's a confirmed false positive):${c.reset}`);
|
|
440
|
+
console.log(` ${c.cyan}${cd.suppress}${c.reset}\n`);
|
|
441
|
+
}
|
|
442
|
+
console.log(`${c.bold}Got it wrong?${c.reset} ${c.dim}Send a redacted report so a future release stops flagging it: ${c.cyan}docguard feedback${c.reset}`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
422
446
|
const match = matchWarning(query);
|
|
423
447
|
if (!match) {
|
|
424
448
|
if (isJson) {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback Command — close the loop with the tool's maintainers (v0.27).
|
|
3
|
+
*
|
|
4
|
+
* DocGuard is a tool for LLMs: when it gets something wrong, the cheapest way to
|
|
5
|
+
* make the NEXT release better is to capture that signal. This command turns the
|
|
6
|
+
* low-confidence findings of a guard run (candidate false positives — and any
|
|
7
|
+
* other finding DocGuard itself flagged as uncertain) into:
|
|
8
|
+
*
|
|
9
|
+
* 1. a LOCAL-FIRST record under .docguard/feedback/<code>-<id>.json (full,
|
|
10
|
+
* reviewable, never sent anywhere automatically), and
|
|
11
|
+
* 2. a one-click, PREFILLED GitHub issue URL that needs zero typing.
|
|
12
|
+
*
|
|
13
|
+
* Hard constraints (learned the hard way — see commit 3b600fd, where an
|
|
14
|
+
* oversized prefilled URL overflowed GitHub's ~8 KB limit and silently failed):
|
|
15
|
+
* - The URL is CAPPED well under the limit; bulk lives in the local file.
|
|
16
|
+
* - It is REDACTED: no source code, no secret values — only a basename, a line
|
|
17
|
+
* number, and the safe `redactedContext` the validator built.
|
|
18
|
+
* - It is OPT-IN: nothing is filed automatically; the human clicks (or not).
|
|
19
|
+
*
|
|
20
|
+
* Not read-only in the strict sense — it writes its own .docguard/feedback/ —
|
|
21
|
+
* but it never scaffolds skills and never touches the user's source tree.
|
|
22
|
+
*
|
|
23
|
+
* Zero npm dependencies — pure Node.js built-ins.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
27
|
+
import { resolve, dirname, basename } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { c } from '../shared.mjs';
|
|
30
|
+
import { runGuardInternal } from './guard.mjs';
|
|
31
|
+
|
|
32
|
+
const _PKG = JSON.parse(
|
|
33
|
+
readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8')
|
|
34
|
+
);
|
|
35
|
+
const CLI_VERSION = _PKG.version;
|
|
36
|
+
const ISSUES_BASE = (_PKG.bugs && _PKG.bugs.url) || 'https://github.com/raccioly/docguard/issues';
|
|
37
|
+
|
|
38
|
+
// Keep the prefilled URL comfortably under GitHub's ~8 KB request-URL limit.
|
|
39
|
+
const URL_CAP = 1800;
|
|
40
|
+
|
|
41
|
+
/** Deterministic short id from a string — avoids Date.now()/Math.random(). */
|
|
42
|
+
function shortId(str) {
|
|
43
|
+
let h = 5381;
|
|
44
|
+
for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
|
|
45
|
+
return h.toString(36).slice(0, 6);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Redact a finding location for the SHARED url: basename + line only. */
|
|
49
|
+
function safeLocation(location) {
|
|
50
|
+
if (!location) return '(unknown)';
|
|
51
|
+
const [path, line] = String(location).split(/:(?=\d+$)/);
|
|
52
|
+
return line ? `${basename(path)}:${line}` : basename(path);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a prefilled, capped issue URL. Drops optional body lines (longest-value
|
|
57
|
+
* first) until under the cap; title + code + location always survive.
|
|
58
|
+
*/
|
|
59
|
+
function buildIssueUrl(finding) {
|
|
60
|
+
const code = finding.code || 'FINDING';
|
|
61
|
+
const validator = finding.validator || 'unknown';
|
|
62
|
+
const shortMsg = (finding.message || '').replace(/\s+/g, ' ').slice(0, 70);
|
|
63
|
+
const title = `[feedback] ${code} (${validator}): ${shortMsg}`;
|
|
64
|
+
|
|
65
|
+
// Optional lines are ordered most→least droppable.
|
|
66
|
+
const required = [
|
|
67
|
+
`DocGuard v${CLI_VERSION} flagged this and it may be a false positive (or other feedback).`,
|
|
68
|
+
'',
|
|
69
|
+
`- Code: ${code}`,
|
|
70
|
+
`- Validator: ${validator}`,
|
|
71
|
+
`- Location: ${safeLocation(finding.location)}`,
|
|
72
|
+
`- Confidence: ${finding.confidence}`,
|
|
73
|
+
];
|
|
74
|
+
const optional = [];
|
|
75
|
+
if (finding.redactedContext) optional.push(`- Context: ${finding.redactedContext}`);
|
|
76
|
+
if (finding.suggestion && finding.suggestion.text) optional.push(`- Suggestion shown: ${finding.suggestion.text}`);
|
|
77
|
+
const footer = ['', 'Generated by `docguard feedback` — no source code or secret values are included.'];
|
|
78
|
+
|
|
79
|
+
const compose = (opt) => `${ISSUES_BASE}/new?labels=${encodeURIComponent('docguard-feedback')}` +
|
|
80
|
+
`&title=${encodeURIComponent(title)}` +
|
|
81
|
+
`&body=${encodeURIComponent([...required, ...opt, ...footer].join('\n'))}`;
|
|
82
|
+
|
|
83
|
+
let opt = [...optional];
|
|
84
|
+
let url = compose(opt);
|
|
85
|
+
while (url.length > URL_CAP && opt.length > 0) {
|
|
86
|
+
opt = opt.slice(0, -1);
|
|
87
|
+
url = compose(opt);
|
|
88
|
+
}
|
|
89
|
+
if (url.length > URL_CAP) {
|
|
90
|
+
// Even the required body is too long (pathological) — collapse to a stub.
|
|
91
|
+
url = `${ISSUES_BASE}/new?labels=${encodeURIComponent('docguard-feedback')}` +
|
|
92
|
+
`&title=${encodeURIComponent(title)}` +
|
|
93
|
+
`&body=${encodeURIComponent(`DocGuard v${CLI_VERSION} — ${code} (${validator}). Full details saved locally; please attach.`)}`;
|
|
94
|
+
}
|
|
95
|
+
return { url, title };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function runFeedback(projectDir, config, flags) {
|
|
99
|
+
const data = runGuardInternal(projectDir, config);
|
|
100
|
+
const reportable = (data.findings || []).filter((f) => f.reportable);
|
|
101
|
+
const isJson = flags.format === 'json';
|
|
102
|
+
|
|
103
|
+
if (reportable.length === 0) {
|
|
104
|
+
if (isJson) {
|
|
105
|
+
console.log(JSON.stringify({ reportable: [], message: 'no uncertain findings' }, null, 2));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
console.log(`${c.bold}📮 DocGuard Feedback${c.reset}`);
|
|
109
|
+
console.log(`${c.green}✅ Nothing to report — DocGuard is confident about everything it flagged.${c.reset}`);
|
|
110
|
+
console.log(`${c.dim} (Feedback collects low-confidence findings, i.e. likely false positives.)${c.reset}\n`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Capture each reportable finding locally (full, reviewable) + build its URL.
|
|
115
|
+
const feedbackDir = resolve(projectDir, '.docguard', 'feedback');
|
|
116
|
+
const items = reportable.map((f) => {
|
|
117
|
+
const id = shortId(`${f.code}|${f.location || f.message}`);
|
|
118
|
+
const { url, title } = buildIssueUrl(f);
|
|
119
|
+
const fileName = `${(f.code || 'finding').toLowerCase()}-${id}.json`;
|
|
120
|
+
const filePath = resolve(feedbackDir, fileName);
|
|
121
|
+
return { finding: f, id, url, title, fileName, filePath };
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (isJson) {
|
|
125
|
+
console.log(JSON.stringify({
|
|
126
|
+
version: CLI_VERSION,
|
|
127
|
+
reportable: items.map((it) => ({ code: it.finding.code, location: it.finding.location, url: it.url, file: `.docguard/feedback/${it.fileName}` })),
|
|
128
|
+
}, null, 2));
|
|
129
|
+
// Still write the local records so the JSON path is not a dead end.
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let wrote = 0;
|
|
133
|
+
for (const it of items) {
|
|
134
|
+
try {
|
|
135
|
+
if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
|
|
136
|
+
writeFileSync(it.filePath, JSON.stringify({
|
|
137
|
+
capturedBy: `docguard feedback (v${CLI_VERSION})`,
|
|
138
|
+
finding: it.finding,
|
|
139
|
+
issueUrl: it.url,
|
|
140
|
+
}, null, 2) + '\n', 'utf-8');
|
|
141
|
+
wrote++;
|
|
142
|
+
} catch { /* best-effort local capture */ }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isJson) return;
|
|
146
|
+
|
|
147
|
+
console.log(`${c.bold}📮 DocGuard Feedback${c.reset}`);
|
|
148
|
+
console.log(`${c.dim} ${items.length} uncertain finding(s) — likely false positives. Saved locally to ${c.cyan}.docguard/feedback/${c.reset}\n`);
|
|
149
|
+
|
|
150
|
+
for (const it of items) {
|
|
151
|
+
const f = it.finding;
|
|
152
|
+
console.log(` ${c.yellow}[${f.code}]${c.reset} ${f.message}`);
|
|
153
|
+
if (f.suggestion && f.suggestion.pragma) {
|
|
154
|
+
console.log(` ${c.dim}Suppress locally instead: ${f.suggestion.pragma}${c.reset}`);
|
|
155
|
+
}
|
|
156
|
+
console.log(` ${c.dim}Report (1 click, prefilled, redacted):${c.reset}`);
|
|
157
|
+
console.log(` ${c.cyan}${it.url}${c.reset}`);
|
|
158
|
+
console.log(` ${c.dim}Local copy: .docguard/feedback/${it.fileName}${c.reset}\n`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(`${c.dim}These reports help DocGuard stop flagging the same false positive in a future release.${c.reset}`);
|
|
162
|
+
console.log(`${c.dim}Nothing is sent automatically — open a link only if you want to.${c.reset}\n`);
|
|
163
|
+
}
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -180,6 +180,27 @@ export function classifyResult(result) {
|
|
|
180
180
|
return { status, quality };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* v0.27: the list of issues to render for a validator. Prefers structured
|
|
185
|
+
* findings (code + confidence + suggestion) when present; otherwise maps the
|
|
186
|
+
* legacy error/warning strings into the same shape so the renderer is uniform.
|
|
187
|
+
*/
|
|
188
|
+
function renderableItems(v) {
|
|
189
|
+
if (Array.isArray(v.findings) && v.findings.length > 0) {
|
|
190
|
+
return v.findings.map((f) => ({
|
|
191
|
+
severity: f.severity,
|
|
192
|
+
message: f.message,
|
|
193
|
+
code: f.code,
|
|
194
|
+
confidence: f.confidence,
|
|
195
|
+
suggestion: f.suggestion,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
return [
|
|
199
|
+
...(v.errors || []).map((m) => ({ severity: 'error', message: m })),
|
|
200
|
+
...(v.warnings || []).map((m) => ({ severity: 'warn', message: m })),
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
183
204
|
export function runGuardInternal(projectDir, config) {
|
|
184
205
|
const validators = config.validators || {};
|
|
185
206
|
const results = [];
|
|
@@ -323,6 +344,15 @@ export function runGuardInternal(projectDir, config) {
|
|
|
323
344
|
// what the user reads is what CI does.
|
|
324
345
|
const overallStatus = effectiveErrors > 0 ? 'FAIL' : effectiveWarnings > 0 ? 'WARN' : 'PASS';
|
|
325
346
|
|
|
347
|
+
// v0.27: stable, LLM-addressable contract. `findings` is the flattened,
|
|
348
|
+
// structured view (those validators that emit it); `reportable` are the
|
|
349
|
+
// low-confidence ones the feedback loop offers to report; `nextStep` is the
|
|
350
|
+
// single machine hint so an agent in a hook never has to parse prose.
|
|
351
|
+
const allFindings = activeResults.flatMap((r) => (Array.isArray(r.findings) ? r.findings : []));
|
|
352
|
+
const reportable = allFindings.filter((f) => f.reportable);
|
|
353
|
+
const nextStep =
|
|
354
|
+
overallStatus === 'PASS' ? null : 'docguard diagnose';
|
|
355
|
+
|
|
326
356
|
return {
|
|
327
357
|
project: config.projectName,
|
|
328
358
|
profile: config.profile || 'standard',
|
|
@@ -331,6 +361,9 @@ export function runGuardInternal(projectDir, config) {
|
|
|
331
361
|
total: totalChecks,
|
|
332
362
|
errors: totalErrors,
|
|
333
363
|
warnings: totalWarnings,
|
|
364
|
+
findings: allFindings,
|
|
365
|
+
reportable,
|
|
366
|
+
nextStep,
|
|
334
367
|
// v0.5: severity-aware counts for exit-code logic. The display still uses
|
|
335
368
|
// the raw counts above so users see every warning, but CI only fails on
|
|
336
369
|
// things they've marked as high-severity.
|
|
@@ -429,12 +462,17 @@ export function runGuard(projectDir, config, flags) {
|
|
|
429
462
|
|
|
430
463
|
// ── JSON output ──
|
|
431
464
|
if (flags.format === 'json') {
|
|
432
|
-
console.log(JSON.stringify(data, null, 2));
|
|
433
465
|
// Use severity-aware effective counts for exit code; raw counts stay in the JSON
|
|
434
466
|
// for display tools that want to show the full picture.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
467
|
+
const code = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
|
|
468
|
+
// v0.28: set exitCode + return instead of process.exit(). A large JSON
|
|
469
|
+
// payload (>~8 KB) written to a PIPE flushes asynchronously; an immediate
|
|
470
|
+
// process.exit() truncates it mid-string, so a CI consumer parsing stdout
|
|
471
|
+
// gets "Unterminated string in JSON" on exactly the big reports that matter.
|
|
472
|
+
// Returning lets Node drain stdout and exit naturally with process.exitCode.
|
|
473
|
+
process.exitCode = code;
|
|
474
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
475
|
+
return;
|
|
438
476
|
}
|
|
439
477
|
|
|
440
478
|
// ── Text output ──
|
|
@@ -473,14 +511,26 @@ export function runGuard(projectDir, config, flags) {
|
|
|
473
511
|
// overall validator status — useful when a validator passes overall
|
|
474
512
|
// (passed < total) without surfacing the specific failing checks.
|
|
475
513
|
const show = flags.verbose || flags.showFailing;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
514
|
+
const showErr = show || v.status === 'fail';
|
|
515
|
+
const showWarn = show || v.status === 'warn';
|
|
516
|
+
// v0.27: render from structured findings when the validator emits them
|
|
517
|
+
// (each issue carries a code, confidence, and a `→ suggestion`); otherwise
|
|
518
|
+
// fall back to the legacy error/warning strings. Identical gating.
|
|
519
|
+
for (const item of renderableItems(v)) {
|
|
520
|
+
if (item.severity === 'error' && !showErr) continue;
|
|
521
|
+
if (item.severity === 'warn' && !showWarn) continue;
|
|
522
|
+
const mark = item.severity === 'error' ? `${c.red}✗` : `${c.yellow}⚠`;
|
|
523
|
+
const codeTag = item.code ? `${c.dim}[${item.code}]${c.reset} ` : '';
|
|
524
|
+
const conf = item.confidence === 'low'
|
|
525
|
+
? ` ${c.dim}(low confidence — possible false positive)${c.reset}` : '';
|
|
526
|
+
console.log(` ${mark} ${codeTag}${item.message}${c.reset}${conf}`);
|
|
527
|
+
if (item.suggestion) {
|
|
528
|
+
console.log(` ${c.cyan}→${c.reset} ${c.dim}${item.suggestion.text}${c.reset}`);
|
|
529
|
+
if (item.suggestion.command) {
|
|
530
|
+
console.log(` ${c.cyan}${item.suggestion.command}${c.reset}`);
|
|
531
|
+
} else if (item.suggestion.pragma) {
|
|
532
|
+
console.log(` ${c.dim}${item.suggestion.pragma}${c.reset}`);
|
|
533
|
+
}
|
|
484
534
|
}
|
|
485
535
|
}
|
|
486
536
|
// If a validator reports passed < total but has no errors/warnings, surface
|
|
@@ -514,14 +564,31 @@ export function runGuard(projectDir, config, flags) {
|
|
|
514
564
|
console.log(` ${c.red}${c.bold}❌ FAIL${c.reset} ${c.red}— ${data.passed}/${data.total} passed, ${data.effectiveErrors} blocking issue(s)${warnSuffix}${c.reset}`);
|
|
515
565
|
}
|
|
516
566
|
|
|
517
|
-
// Next
|
|
567
|
+
// ── Next steps — every run ends with a suggested action (v0.27) ──
|
|
568
|
+
// The field-report principle: whenever DocGuard calls out an issue it must
|
|
569
|
+
// suggest what to do next; on a clean run it points at the next workflow step
|
|
570
|
+
// rather than nagging. JSON consumers read this off the `nextStep`/`reportable`
|
|
571
|
+
// contract fields instead of this prose.
|
|
572
|
+
const agentMode = detectAgentMode(projectDir);
|
|
573
|
+
const skill = (name) => (agentMode === 'llm' ? `/docguard.${name}` : `docguard ${name}`);
|
|
574
|
+
|
|
518
575
|
if (data.status !== 'PASS') {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
576
|
+
console.log(` ${c.dim}Next: run ${c.cyan}${skill('diagnose')}${c.dim} to get AI fix prompts that resolve the issues above.${c.reset}`);
|
|
577
|
+
} else {
|
|
578
|
+
console.log(` ${c.dim}Next: ${c.cyan}${skill('score')}${c.dim} for your CDD maturity score, or commit with confidence.${c.reset}`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Low-confidence findings (possible false positives) → offer the local-first
|
|
582
|
+
// feedback path. Broader than secrets: anything DocGuard flagged uncertainly.
|
|
583
|
+
if (Array.isArray(data.reportable) && data.reportable.length > 0) {
|
|
584
|
+
const n = data.reportable.length;
|
|
585
|
+
console.log(` ${c.dim}↪ ${n} finding(s) look uncertain (possible false positives). Review or report: ${c.cyan}${skill('feedback')}${c.reset}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Read-only skills nudge (never writes — that's `init`'s job). If the agent
|
|
589
|
+
// has no /docguard.* commands installed yet, say how to get them.
|
|
590
|
+
if (agentMode === 'llm' && !existsSync(resolvePath(projectDir, '.agent', 'skills', 'docguard-guard'))) {
|
|
591
|
+
console.log(` ${c.dim}💡 Install ${c.cyan}/docguard.*${c.dim} commands for your agent: ${c.cyan}docguard init${c.reset}`);
|
|
525
592
|
}
|
|
526
593
|
|
|
527
594
|
// Badge snippet
|
|
@@ -645,7 +712,8 @@ export function runGuard(projectDir, config, flags) {
|
|
|
645
712
|
}
|
|
646
713
|
|
|
647
714
|
// v0.5: severity-aware exit codes (see runGuardInternal for the rollup).
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
715
|
+
// v0.28: exitCode + return (not process.exit) so the buffered text output
|
|
716
|
+
// flushes to a pipe before the process exits — same truncation fix as the
|
|
717
|
+
// JSON path above.
|
|
718
|
+
process.exitCode = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
|
|
651
719
|
}
|
package/cli/commands/init.mjs
CHANGED
|
@@ -78,6 +78,20 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
78
78
|
const __dirname = dirname(__filename);
|
|
79
79
|
const TEMPLATES_DIR = resolve(__dirname, '../../templates');
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* v0.28 (field report #11): inject a `<!-- docguard:last-reviewed DATE -->`
|
|
83
|
+
* marker right after the first H1, so a canonical doc has a freshness signal the
|
|
84
|
+
* Freshness validator reads directly (not git mtime). No-op if one is present.
|
|
85
|
+
*/
|
|
86
|
+
function stampLastReviewed(content, date) {
|
|
87
|
+
const marker = `<!-- docguard:last-reviewed ${date} -->`;
|
|
88
|
+
const lines = content.split('\n');
|
|
89
|
+
const h1 = lines.findIndex(l => /^#\s/.test(l));
|
|
90
|
+
if (h1 === -1) return `${marker}\n\n${content}`;
|
|
91
|
+
lines.splice(h1 + 1, 0, '', marker);
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
// ── Readline helper ──────────────────────────────────────────────────────
|
|
82
96
|
|
|
83
97
|
function askQuestion(prompt) {
|
|
@@ -263,7 +277,15 @@ export async function runInit(projectDir, config, flags) {
|
|
|
263
277
|
if (existsSync(templatePath)) {
|
|
264
278
|
const content = readFileSync(templatePath, 'utf-8');
|
|
265
279
|
const today = new Date().toISOString().split('T')[0];
|
|
266
|
-
|
|
280
|
+
let processed = content.replace(/YYYY-MM-DD/g, today);
|
|
281
|
+
// v0.28 (field report #11): every canonical doc must ship with a freshness
|
|
282
|
+
// marker so the Freshness validator is marker-based (consistent across docs,
|
|
283
|
+
// and satisfiable in a pre-commit review loop) rather than silently falling
|
|
284
|
+
// back to git mtime. Templates now all carry one; this is the belt-and-
|
|
285
|
+
// suspenders guarantee for any future template that forgets.
|
|
286
|
+
if (mapping.dest.startsWith('docs-canonical/') && !/docguard:last-reviewed/.test(processed)) {
|
|
287
|
+
processed = stampLastReviewed(processed, today);
|
|
288
|
+
}
|
|
267
289
|
writeFileSync(destPath, processed, 'utf-8');
|
|
268
290
|
created.push(mapping.dest);
|
|
269
291
|
console.log(` ${c.green}✅${c.reset} Created: ${c.cyan}${mapping.dest}${c.reset}`);
|
package/cli/commands/score.mjs
CHANGED
|
@@ -10,6 +10,70 @@ import { c, docHasSection } from '../shared.mjs';
|
|
|
10
10
|
import { validateSecurity } from '../validators/security.mjs';
|
|
11
11
|
import { runGuardInternal } from './guard.mjs';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Detect whether the project configures a test runner (the "Check 3" of the
|
|
15
|
+
* testing score). Extracted as an exported seam so it's unit-testable without
|
|
16
|
+
* the full score pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Recognises, in order: standalone config files; pytest config inside
|
|
19
|
+
* pyproject.toml / tox.ini; node:test via projectTypeConfig or scripts.test;
|
|
20
|
+
* a `scripts.test` that invokes a known runner; Vitest configured INSIDE
|
|
21
|
+
* vite.config.* (field report #3 — `vitest/config` import or a `test:` block);
|
|
22
|
+
* and runner configs in common workspace subdirs.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} dir
|
|
25
|
+
* @param {object} config
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function detectTestRunner(dir, config = {}) {
|
|
29
|
+
const testConfigFiles = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
|
|
30
|
+
if (testConfigFiles.some((f) => existsSync(resolve(dir, f)))) return true;
|
|
31
|
+
|
|
32
|
+
// Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
|
|
33
|
+
// or tox.ini ([pytest]) — not a standalone file.
|
|
34
|
+
for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
|
|
35
|
+
const p = resolve(dir, file);
|
|
36
|
+
if (!existsSync(p)) continue;
|
|
37
|
+
try { if (marker.test(readFileSync(p, 'utf-8'))) return true; } catch { /* skip */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// node:test has no config file — recognize it via projectTypeConfig or package.json.
|
|
41
|
+
const ptc = config.projectTypeConfig || {};
|
|
42
|
+
if (ptc.testFramework === 'node:test') return true;
|
|
43
|
+
const pkgPath = resolve(dir, 'package.json');
|
|
44
|
+
if (existsSync(pkgPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
47
|
+
const testScript = pkg.scripts?.test || '';
|
|
48
|
+
if (testScript.includes('node --test') || testScript.includes('node:test')) return true;
|
|
49
|
+
// v0.27 (field report #3): a `scripts.test` that runs a known runner IS a
|
|
50
|
+
// configured test runner, even without a standalone config file.
|
|
51
|
+
if (/\b(vitest|jest|mocha|ava|playwright|cypress|pytest)\b/.test(testScript)) return true;
|
|
52
|
+
} catch { /* skip */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// v0.27 (field report #3): Vitest configured INSIDE vite.config.* rather than a
|
|
56
|
+
// standalone vitest.config (`vitest/config` import + a `test:` block).
|
|
57
|
+
for (const f of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) {
|
|
58
|
+
const p = resolve(dir, f);
|
|
59
|
+
if (!existsSync(p)) continue;
|
|
60
|
+
try {
|
|
61
|
+
const src = readFileSync(p, 'utf-8');
|
|
62
|
+
if (/vitest\/config/.test(src) || /^\s*test\s*:/m.test(src)) return true;
|
|
63
|
+
} catch { /* skip */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Workspace subdirs: a runner config one level down still configures the project.
|
|
67
|
+
const subConfigs = ['vitest.config.ts', 'vitest.config.js', 'jest.config.ts', 'jest.config.js', 'vite.config.ts'];
|
|
68
|
+
for (const sub of ['backend', 'frontend', 'server', 'client', 'app', 'web', 'api']) {
|
|
69
|
+
for (const f of subConfigs) {
|
|
70
|
+
if (existsSync(resolve(dir, sub, f))) return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
13
77
|
/**
|
|
14
78
|
* v0.18-P3: map score categories to the validator keys that contribute.
|
|
15
79
|
* One category can roll up multiple validators (e.g. "environment" pulls
|
|
@@ -585,38 +649,7 @@ function calcTestingScore(dir, config) {
|
|
|
585
649
|
else failures.push({ issue: 'TEST-SPEC.md missing', fixCmd: 'docguard fix --doc test-spec' });
|
|
586
650
|
|
|
587
651
|
// ── Check 3: Test config or built-in runner (15 pts) ──
|
|
588
|
-
|
|
589
|
-
let hasTestRunner = testConfigFiles.some(f => existsSync(resolve(dir, f)));
|
|
590
|
-
|
|
591
|
-
// Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
|
|
592
|
-
// or tox.ini ([pytest]) — not a standalone file. Detect those too, so a uv/pytest
|
|
593
|
-
// project isn't told to "add a test runner" it already configured (field report, Issue B).
|
|
594
|
-
if (!hasTestRunner) {
|
|
595
|
-
for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
|
|
596
|
-
const p = resolve(dir, file);
|
|
597
|
-
if (!existsSync(p)) continue;
|
|
598
|
-
try { if (marker.test(readFileSync(p, 'utf-8'))) { hasTestRunner = true; break; } } catch { /* skip */ }
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// node:test has no config file — recognize it via projectTypeConfig or package.json.
|
|
603
|
-
if (!hasTestRunner) {
|
|
604
|
-
const ptc = config.projectTypeConfig || {};
|
|
605
|
-
if (ptc.testFramework === 'node:test') {
|
|
606
|
-
hasTestRunner = true;
|
|
607
|
-
} else {
|
|
608
|
-
const pkgPath = resolve(dir, 'package.json');
|
|
609
|
-
if (existsSync(pkgPath)) {
|
|
610
|
-
try {
|
|
611
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
612
|
-
const testScript = pkg.scripts?.test || '';
|
|
613
|
-
if (testScript.includes('node --test') || testScript.includes('node:test')) hasTestRunner = true;
|
|
614
|
-
} catch { /* skip */ }
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
if (hasTestRunner) score += 15;
|
|
652
|
+
if (detectTestRunner(dir, config)) score += 15;
|
|
620
653
|
else failures.push({ issue: 'no test runner config detected (jest/vitest/pytest/node:test)' });
|
|
621
654
|
|
|
622
655
|
// ── Check 4: CI test step (15 pts) ──
|