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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `docguard sync --tests` — reconcile the TEST-SPEC Source-to-Test Map from disk.
|
|
3
|
+
*
|
|
4
|
+
* Background (LLM field report #10): the source→test table in TEST-SPEC.md is
|
|
5
|
+
* hand-maintained, so plain `docguard sync` (which only refreshes
|
|
6
|
+
* docguard:generated code-truth SECTIONS) reports "nothing drifted" even when the
|
|
7
|
+
* table has a ghost service (source deleted), a ghost test (test file deleted),
|
|
8
|
+
* and N services that gained tests. The Test-Spec validator already detects the
|
|
9
|
+
* ghosts; this writes the reconciliation back.
|
|
10
|
+
*
|
|
11
|
+
* SAFETY — this edits a human-curated table, so it does ONLY the two unambiguous
|
|
12
|
+
* operations and previews by default (`--write` applies):
|
|
13
|
+
* - REMOVE a row whose SOURCE file no longer exists on disk (ghost service).
|
|
14
|
+
* - APPEND a row for a co-located source↔test pair found on disk but absent
|
|
15
|
+
* from the table (newly-covered service).
|
|
16
|
+
* Ghost TEST references (source still exists, test file gone) are REPORTED but
|
|
17
|
+
* never auto-edited — blanking a hand-maintained status/notes cell is too
|
|
18
|
+
* destructive, and the Test-Spec validator already warns on them.
|
|
19
|
+
*
|
|
20
|
+
* Zero npm dependencies — pure Node.js built-ins.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import { c } from '../shared.mjs';
|
|
26
|
+
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
27
|
+
|
|
28
|
+
const TEST_SPEC_DOC = 'docs-canonical/TEST-SPEC.md';
|
|
29
|
+
const CODE_EXT = /\.[cm]?[jt]sx?$/;
|
|
30
|
+
const TEST_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
31
|
+
const WALK_SKIP = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.next', '__pycache__', '.venv', 'vendor']);
|
|
32
|
+
|
|
33
|
+
// ── On-disk discovery ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function walkCodeFiles(projectDir, config) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const visit = (absDir, relDir) => {
|
|
38
|
+
let entries;
|
|
39
|
+
try { entries = readdirSync(absDir, { withFileTypes: true }); } catch { return; }
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (e.name.startsWith('.')) continue;
|
|
42
|
+
if (WALK_SKIP.has(e.name)) continue;
|
|
43
|
+
const relPath = relDir ? `${relDir}/${e.name}` : e.name;
|
|
44
|
+
if (e.isDirectory()) { visit(resolve(absDir, e.name), relPath); continue; }
|
|
45
|
+
if (!CODE_EXT.test(e.name)) continue;
|
|
46
|
+
if (shouldIgnore(relPath, config)) continue;
|
|
47
|
+
out.push(relPath);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
visit(resolve(projectDir), '');
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
55
|
+
const baseOf = (p) => (p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p);
|
|
56
|
+
const stemOf = (b) => b.replace(CODE_EXT, '').replace(/\.(test|spec)$/, '');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Discover co-located source↔test pairs on disk. A test counts as covering a
|
|
60
|
+
* source when their stems match AND the test sits in the same directory or a
|
|
61
|
+
* sibling `__tests__/`. Conservative by design — cross-directory basename
|
|
62
|
+
* collisions (two `index.ts`) would pollute the table, so they're excluded.
|
|
63
|
+
*
|
|
64
|
+
* @returns {Array<{ source: string, test: string }>}
|
|
65
|
+
*/
|
|
66
|
+
export function discoverTestPairs(projectDir, config = {}) {
|
|
67
|
+
const files = walkCodeFiles(projectDir, config);
|
|
68
|
+
const tests = files.filter((f) => TEST_RE.test(f));
|
|
69
|
+
const sources = files.filter((f) => !TEST_RE.test(f));
|
|
70
|
+
const pairs = [];
|
|
71
|
+
for (const src of sources) {
|
|
72
|
+
const sDir = dirOf(src);
|
|
73
|
+
const sStem = stemOf(baseOf(src));
|
|
74
|
+
const match = tests.find((t) => {
|
|
75
|
+
if (stemOf(baseOf(t)) !== sStem) return false;
|
|
76
|
+
const tDir = dirOf(t);
|
|
77
|
+
return tDir === sDir || tDir === `${sDir}/__tests__` || (sDir === '' && tDir === '__tests__');
|
|
78
|
+
});
|
|
79
|
+
if (match) pairs.push({ source: src, test: match });
|
|
80
|
+
}
|
|
81
|
+
return pairs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Table parsing (mirrors test-spec.mjs column detection) ─────────────────
|
|
85
|
+
|
|
86
|
+
const isPathLike = (v) => !!v && !/\s/.test(v) && (/[\\/]/.test(v) || /\.[A-Za-z0-9]{1,6}$/.test(v));
|
|
87
|
+
const splitRow = (line) => {
|
|
88
|
+
const parts = line.split('|');
|
|
89
|
+
parts.shift();
|
|
90
|
+
parts.pop();
|
|
91
|
+
return parts.map((s) => s.trim());
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Locate the Source-to-Test Map table and classify its rows against disk.
|
|
96
|
+
* @returns {null | { headerLine, sepLine, sourceIdx, testIdxs, ncols, keep:string[],
|
|
97
|
+
* removed:object[], ghostTests:object[], blockStart, blockEnd }}
|
|
98
|
+
*/
|
|
99
|
+
function parseMapTable(content, projectDir) {
|
|
100
|
+
const sectionRe = /## (?:Service-to-Test Map|Source-to-Test Map)[\s\S]*?(?=\n## |$)/;
|
|
101
|
+
const m = sectionRe.exec(content);
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
const sectionStart = m.index;
|
|
104
|
+
const sectionText = m[0];
|
|
105
|
+
const sectionLines = sectionText.split('\n');
|
|
106
|
+
|
|
107
|
+
// Find the FIRST pipe table inside the section (header + separator + rows).
|
|
108
|
+
let headerLineIdx = -1;
|
|
109
|
+
for (let i = 0; i < sectionLines.length - 1; i++) {
|
|
110
|
+
if (sectionLines[i].trim().startsWith('|') && /^\s*\|[\s|:-]+\|\s*$/.test(sectionLines[i + 1])) {
|
|
111
|
+
headerLineIdx = i;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (headerLineIdx === -1) return null;
|
|
116
|
+
|
|
117
|
+
const header = splitRow(sectionLines[headerLineIdx]).map((h) => h.toLowerCase());
|
|
118
|
+
const ncols = header.length;
|
|
119
|
+
let sourceIdx = header.findIndex((h) => /\bsource\b/.test(h));
|
|
120
|
+
if (sourceIdx < 0) sourceIdx = 0;
|
|
121
|
+
let statusIdx = header.findIndex((h) => /\bstatus\b/.test(h));
|
|
122
|
+
if (statusIdx < 0) statusIdx = ncols - 1;
|
|
123
|
+
let testIdxs = header.map((h, i) => (/\btest\b|\be2e\b/.test(h) ? i : -1)).filter((i) => i >= 0 && i !== sourceIdx && i !== statusIdx);
|
|
124
|
+
if (testIdxs.length === 0) { const fb = sourceIdx === 1 ? 0 : 1; if (fb !== statusIdx && fb < ncols) testIdxs = [fb]; }
|
|
125
|
+
|
|
126
|
+
// Walk data rows after the separator until the table ends (a non-pipe line).
|
|
127
|
+
const keep = []; // raw row lines to retain
|
|
128
|
+
const removed = []; // { source } ghost-source rows dropped
|
|
129
|
+
const ghostTests = []; // { source, test } source exists but a test ref is gone
|
|
130
|
+
const documentedSources = new Set();
|
|
131
|
+
let dataEndIdx = headerLineIdx + 2;
|
|
132
|
+
for (let i = headerLineIdx + 2; i < sectionLines.length; i++) {
|
|
133
|
+
const line = sectionLines[i];
|
|
134
|
+
if (!line.trim().startsWith('|')) break;
|
|
135
|
+
dataEndIdx = i + 1;
|
|
136
|
+
const cells = splitRow(line);
|
|
137
|
+
const rawSource = (cells[sourceIdx] || '').replace(/`/g, '').trim();
|
|
138
|
+
// Template/example/placeholder rows are left untouched.
|
|
139
|
+
if (!rawSource || rawSource.startsWith('<!--') || rawSource.startsWith('*') || !isPathLike(rawSource)) {
|
|
140
|
+
keep.push(line);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!existsSync(resolve(projectDir, rawSource))) {
|
|
144
|
+
removed.push({ source: rawSource });
|
|
145
|
+
continue; // drop ghost-source row
|
|
146
|
+
}
|
|
147
|
+
documentedSources.add(rawSource);
|
|
148
|
+
// Source exists — report (don't edit) any dead test reference.
|
|
149
|
+
for (const ti of testIdxs) {
|
|
150
|
+
const t = (cells[ti] || '').replace(/`/g, '').trim();
|
|
151
|
+
if (isPathLike(t) && !existsSync(resolve(projectDir, t))) ghostTests.push({ source: rawSource, test: t });
|
|
152
|
+
}
|
|
153
|
+
keep.push(line);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
sectionStart,
|
|
158
|
+
headerAbsLine: headerLineIdx,
|
|
159
|
+
sepLine: sectionLines[headerLineIdx + 1],
|
|
160
|
+
headerLine: sectionLines[headerLineIdx],
|
|
161
|
+
sourceIdx, testIdxs, statusIdx, ncols,
|
|
162
|
+
keep, removed, ghostTests, documentedSources,
|
|
163
|
+
// absolute char offsets of the table block within `content`
|
|
164
|
+
blockStartLine: headerLineIdx,
|
|
165
|
+
blockEndLine: dataEndIdx,
|
|
166
|
+
sectionLines,
|
|
167
|
+
sectionTextStart: sectionStart,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Compute the reconciliation. Pure: returns the diff + the rewritten content.
|
|
173
|
+
* @returns {{ applicable:boolean, removed:object[], added:object[], ghostTests:object[], newContent:string|null, reason?:string }}
|
|
174
|
+
*/
|
|
175
|
+
export function reconcileTestMap(content, projectDir, config) {
|
|
176
|
+
const parsed = parseMapTable(content, projectDir);
|
|
177
|
+
if (!parsed) {
|
|
178
|
+
return { applicable: false, removed: [], added: [], ghostTests: [], newContent: null, reason: 'no Source-to-Test Map table found' };
|
|
179
|
+
}
|
|
180
|
+
const pairs = discoverTestPairs(projectDir, config);
|
|
181
|
+
const added = pairs.filter((p) => !parsed.documentedSources.has(p.source));
|
|
182
|
+
|
|
183
|
+
// Build the new table block: header, separator, kept rows, appended rows.
|
|
184
|
+
const newRowFor = ({ source, test }) => {
|
|
185
|
+
const cells = new Array(parsed.ncols).fill('—');
|
|
186
|
+
cells[parsed.sourceIdx] = `\`${source}\``;
|
|
187
|
+
if (parsed.testIdxs.length) cells[parsed.testIdxs[0]] = `\`${test}\``;
|
|
188
|
+
cells[parsed.statusIdx] = '⚠️ auto-added — verify';
|
|
189
|
+
return `| ${cells.join(' | ')} |`;
|
|
190
|
+
};
|
|
191
|
+
const addedRows = added.map(newRowFor);
|
|
192
|
+
const newBlock = [parsed.headerLine, parsed.sepLine, ...parsed.keep, ...addedRows].join('\n');
|
|
193
|
+
|
|
194
|
+
// Splice the new block back into the original section text, then back into content.
|
|
195
|
+
const sectionLines = parsed.sectionLines.slice();
|
|
196
|
+
const before = sectionLines.slice(0, parsed.blockStartLine);
|
|
197
|
+
const after = sectionLines.slice(parsed.blockEndLine);
|
|
198
|
+
const newSection = [...before, newBlock, ...after].join('\n');
|
|
199
|
+
const oldSection = parsed.sectionLines.join('\n');
|
|
200
|
+
const newContent = content.slice(0, parsed.sectionTextStart) + newSection + content.slice(parsed.sectionTextStart + oldSection.length);
|
|
201
|
+
|
|
202
|
+
const changed = parsed.removed.length > 0 || added.length > 0;
|
|
203
|
+
return {
|
|
204
|
+
applicable: true,
|
|
205
|
+
removed: parsed.removed,
|
|
206
|
+
added,
|
|
207
|
+
ghostTests: parsed.ghostTests,
|
|
208
|
+
newContent: changed ? newContent : null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export function runSyncTests(projectDir, config, flags) {
|
|
215
|
+
const apply = !!flags.write;
|
|
216
|
+
const isJson = flags.format === 'json';
|
|
217
|
+
const docPath = resolve(projectDir, TEST_SPEC_DOC);
|
|
218
|
+
|
|
219
|
+
if (!existsSync(docPath)) {
|
|
220
|
+
if (isJson) { console.log(JSON.stringify({ applicable: false, reason: 'TEST-SPEC.md not present' }, null, 2)); return; }
|
|
221
|
+
console.log(`${c.yellow}TEST-SPEC.md not found — run ${c.cyan}docguard init${c.yellow} first.${c.reset}\n`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const content = readFileSync(docPath, 'utf-8');
|
|
226
|
+
const r = reconcileTestMap(content, projectDir, config);
|
|
227
|
+
|
|
228
|
+
if (isJson) {
|
|
229
|
+
console.log(JSON.stringify({
|
|
230
|
+
applicable: r.applicable, applied: apply && !!r.newContent,
|
|
231
|
+
removed: r.removed, added: r.added, ghostTests: r.ghostTests, reason: r.reason || null,
|
|
232
|
+
}, null, 2));
|
|
233
|
+
if (apply && r.newContent) writeFileSync(docPath, r.newContent, 'utf-8');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(`${c.bold}🔄 DocGuard Sync --tests — ${config.projectName}${c.reset}`);
|
|
238
|
+
console.log(`${c.dim} ${TEST_SPEC_DOC} · ${apply ? 'Applying' : 'Dry run (use --write to apply)'}${c.reset}\n`);
|
|
239
|
+
|
|
240
|
+
if (!r.applicable) {
|
|
241
|
+
console.log(` ${c.yellow}No Source-to-Test Map table found in TEST-SPEC.md.${c.reset}`);
|
|
242
|
+
console.log(` ${c.dim}Add a "## Source-to-Test Map" table (col 1 = source, last col = status), then re-run.${c.reset}\n`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (r.removed.length === 0 && r.added.length === 0 && r.ghostTests.length === 0) {
|
|
247
|
+
console.log(` ${c.green}✅ Source-to-Test Map matches disk — nothing to reconcile.${c.reset}\n`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (r.removed.length) {
|
|
252
|
+
console.log(` ${apply ? c.green : c.yellow}${apply ? '✅ Removed' : '• Remove'} ${r.removed.length} ghost-source row(s) (source file deleted):${c.reset}`);
|
|
253
|
+
for (const x of r.removed) console.log(` ${c.dim}- ${x.source}${c.reset}`);
|
|
254
|
+
}
|
|
255
|
+
if (r.added.length) {
|
|
256
|
+
console.log(` ${apply ? c.green : c.yellow}${apply ? '✅ Added' : '• Add'} ${r.added.length} newly-covered source(s):${c.reset}`);
|
|
257
|
+
for (const x of r.added) console.log(` ${c.dim}+ ${x.source} → ${x.test}${c.reset}`);
|
|
258
|
+
}
|
|
259
|
+
if (r.ghostTests.length) {
|
|
260
|
+
console.log(` ${c.yellow}⚠ ${r.ghostTests.length} ghost test reference(s) (source exists, test file gone) — fix by hand:${c.reset}`);
|
|
261
|
+
for (const x of r.ghostTests) console.log(` ${c.dim}~ ${x.source} → ${x.test} (missing)${c.reset}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (apply && r.newContent) {
|
|
265
|
+
writeFileSync(docPath, r.newContent, 'utf-8');
|
|
266
|
+
console.log(`\n ${c.green}↻ ${TEST_SPEC_DOC} updated. Review the ⚠️ auto-added rows, then ${c.cyan}docguard guard${c.green}.${c.reset}\n`);
|
|
267
|
+
} else if (!apply) {
|
|
268
|
+
console.log(`\n ${c.dim}Apply: ${c.cyan}docguard sync --tests --write${c.reset}\n`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log('');
|
|
271
|
+
}
|
|
272
|
+
}
|
package/cli/commands/sync.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { c } from '../shared.mjs';
|
|
|
20
20
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
21
21
|
import { getSection, replaceSection } from '../writers/sections.mjs';
|
|
22
22
|
import { hasGeneratedMarker } from '../writers/api-reference.mjs';
|
|
23
|
+
import { runSyncTests } from './sync-tests.mjs';
|
|
23
24
|
|
|
24
25
|
function gitChangedFiles(projectDir, since) {
|
|
25
26
|
const run = (args) => {
|
|
@@ -77,6 +78,11 @@ function sectionTouchedByChanges(sectionId, changedFiles) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export function runSync(projectDir, config, flags) {
|
|
81
|
+
// v0.28 (field report #10): `--tests` reconciles the hand-maintained TEST-SPEC
|
|
82
|
+
// Source-to-Test Map from disk (ghost-source removal + new co-located pairs) —
|
|
83
|
+
// a distinct path from the generated code-truth section refresh below.
|
|
84
|
+
if (flags.tests) return runSyncTests(projectDir, config, flags);
|
|
85
|
+
|
|
80
86
|
const plan = buildMemoryPlan(projectDir, config);
|
|
81
87
|
const apply = !!flags.write;
|
|
82
88
|
const isJson = flags.format === 'json';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify Command — `docguard verify --semantic` (LLM field report #5).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the semantic claims in the canonical docs (documented numbers, limits,
|
|
5
|
+
* and enums) as a structured verification task list for the agent to check
|
|
6
|
+
* against the code. DocGuard does the deterministic discovery; the LLM does the
|
|
7
|
+
* judgment — the same division of labour as `docguard agent`.
|
|
8
|
+
*
|
|
9
|
+
* Read-only. JSON is the machine artifact (the agent-executable task list);
|
|
10
|
+
* text is the human summary.
|
|
11
|
+
*
|
|
12
|
+
* docguard verify [--semantic] [--format json]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { c } from '../shared.mjs';
|
|
16
|
+
import { detectAgentMode } from '../ensure-skills.mjs';
|
|
17
|
+
import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
|
|
18
|
+
|
|
19
|
+
export function runVerify(projectDir, config, flags) {
|
|
20
|
+
const isJson = flags.format === 'json';
|
|
21
|
+
const claims = extractSemanticClaims(projectDir, config);
|
|
22
|
+
const tasks = buildSemanticVerifyTasks(claims);
|
|
23
|
+
|
|
24
|
+
if (isJson) {
|
|
25
|
+
console.log(JSON.stringify({
|
|
26
|
+
command: 'verify --semantic',
|
|
27
|
+
project: config.projectName,
|
|
28
|
+
claimCount: tasks.length,
|
|
29
|
+
// How to act on this: each task is a claim to confirm against the code.
|
|
30
|
+
howToVerify: 'For each task, read the cited code (or grep for the constant/config), compare it to the documented value, and report any mismatch with both values. DocGuard cannot judge these — they require reading the code.',
|
|
31
|
+
tasks,
|
|
32
|
+
}, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`${c.bold}🔬 DocGuard Verify — semantic claims${c.reset}`);
|
|
37
|
+
console.log(`${c.dim} ${config.projectName} · documented numbers / limits / enums to check against code${c.reset}\n`);
|
|
38
|
+
|
|
39
|
+
if (tasks.length === 0) {
|
|
40
|
+
console.log(` ${c.green}✅ No semantic claims found in the canonical docs.${c.reset}`);
|
|
41
|
+
console.log(` ${c.dim}(Looks for numbers with units — days/ms/req-s/GSIs/roles/… — and status/enum lists.)${c.reset}\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Group by doc for a readable summary.
|
|
46
|
+
const byDoc = new Map();
|
|
47
|
+
for (const t of tasks) {
|
|
48
|
+
if (!byDoc.has(t.doc)) byDoc.set(t.doc, []);
|
|
49
|
+
byDoc.get(t.doc).push(t);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(` ${c.yellow}${tasks.length} claim(s) to verify against the code:${c.reset}\n`);
|
|
53
|
+
for (const [doc, ts] of byDoc) {
|
|
54
|
+
console.log(` ${c.bold}${doc}${c.reset}`);
|
|
55
|
+
for (const t of ts) {
|
|
56
|
+
const val = t.kind === 'enum' ? `enum ${t.value}` : `${t.value}${t.unit ? ` ${t.unit}` : ''}`;
|
|
57
|
+
const cited = t.citedCode ? `${c.cyan}${t.citedCode}${c.reset}` : `${c.dim}(no cited code — grep for it)${c.reset}`;
|
|
58
|
+
console.log(` ${c.yellow}•${c.reset} L${t.line} ${c.dim}${t.section ? `[${t.section}] ` : ''}${c.reset}${c.bold}${val}${c.reset} → check ${cited}`);
|
|
59
|
+
}
|
|
60
|
+
console.log('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mode = detectAgentMode(projectDir);
|
|
64
|
+
const cmd = mode === 'llm' ? '/docguard.verify' : 'docguard verify --semantic --format json';
|
|
65
|
+
console.log(` ${c.dim}This is the highest-value bug class and DocGuard can't judge it — an agent must.${c.reset}`);
|
|
66
|
+
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`);
|
|
67
|
+
}
|
package/cli/docguard.mjs
CHANGED
|
@@ -43,6 +43,8 @@ import { runSetup } from './commands/setup.mjs';
|
|
|
43
43
|
import { runUpgrade } from './commands/upgrade.mjs';
|
|
44
44
|
import { runImpact } from './commands/impact.mjs';
|
|
45
45
|
import { runExplain } from './commands/explain.mjs';
|
|
46
|
+
import { runFeedback } from './commands/feedback.mjs';
|
|
47
|
+
import { runVerify } from './commands/verify.mjs';
|
|
46
48
|
import { runMemory } from './commands/memory.mjs';
|
|
47
49
|
import { runDemo } from './commands/demo.mjs';
|
|
48
50
|
import { runAgent } from './commands/agent.mjs';
|
|
@@ -85,7 +87,9 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
85
87
|
${c.green}fix${c.reset} Generate AI fix instructions for specific docs
|
|
86
88
|
${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
|
|
87
89
|
${c.green}agent${c.reset} One-shot agent task graph — ordered tasks, pre-filled code-truth, per-task verify (${c.cyan}--format json${c.reset})
|
|
88
|
-
${c.green}explain${c.reset} Explain a validator key or
|
|
90
|
+
${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
|
|
91
|
+
${c.green}verify${c.reset} Extract documented numbers/limits/enums for an agent to check vs code (${c.cyan}--semantic${c.reset})
|
|
92
|
+
${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
|
|
89
93
|
${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
|
|
90
94
|
${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
|
|
91
95
|
${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
|
|
@@ -230,13 +234,14 @@ const COMMAND_HELP = {
|
|
|
230
234
|
examples: ['docguard diff', 'docguard diff --since HEAD~5'],
|
|
231
235
|
},
|
|
232
236
|
sync: {
|
|
233
|
-
summary: 'Refresh code-truth doc sections (preview by default).',
|
|
234
|
-
usage: 'docguard sync [--write] [--since <ref>]',
|
|
237
|
+
summary: 'Refresh code-truth doc sections (preview by default). `--tests` reconciles the TEST-SPEC Source-to-Test Map from disk.',
|
|
238
|
+
usage: 'docguard sync [--write] [--since <ref>] [--tests]',
|
|
235
239
|
flags: [
|
|
236
240
|
['--write', 'Apply the refresh (default is a dry-run preview)'],
|
|
237
241
|
['--since <ref>', 'Only sync sections whose source files changed since <ref>'],
|
|
242
|
+
['--tests', 'Reconcile the TEST-SPEC Source-to-Test Map: drop ghost-source rows, append newly-covered source↔test pairs (report ghost tests). Pair with --write to apply.'],
|
|
238
243
|
],
|
|
239
|
-
examples: ['docguard sync', 'docguard sync --write'],
|
|
244
|
+
examples: ['docguard sync', 'docguard sync --write', 'docguard sync --tests', 'docguard sync --tests --write'],
|
|
240
245
|
},
|
|
241
246
|
fix: {
|
|
242
247
|
summary: 'Generate AI fix instructions for docs (or apply deterministic fixes).',
|
|
@@ -279,6 +284,21 @@ const COMMAND_HELP = {
|
|
|
279
284
|
flags: [['--diff', 'Drill into drift between memory and code']],
|
|
280
285
|
examples: ['docguard memory', 'docguard memory --diff'],
|
|
281
286
|
},
|
|
287
|
+
feedback: {
|
|
288
|
+
summary: 'Report likely false positives back to DocGuard. Collects the low-confidence findings of a guard run, saves a full local record under .docguard/feedback/, and prints a one-click, prefilled, redacted GitHub issue URL (zero typing, no source code or secret values).',
|
|
289
|
+
usage: 'docguard feedback [--format json]',
|
|
290
|
+
flags: [['--format json', 'Machine-readable list of reportable findings + URLs']],
|
|
291
|
+
examples: ['docguard feedback'],
|
|
292
|
+
},
|
|
293
|
+
verify: {
|
|
294
|
+
summary: 'Extract the semantic claims in your canonical docs — documented numbers, limits, and enums (retention days, rate limits, GSI/role counts, status enums) — as a verification task list the agent checks against the code. This is the highest-value bug class (a doc value that drifted from code) and the one regex/AST cannot judge. DocGuard finds the claims; the LLM confirms them.',
|
|
295
|
+
usage: 'docguard verify [--semantic] [--format json]',
|
|
296
|
+
flags: [
|
|
297
|
+
['--semantic', 'Extract documented numbers/limits/enums to verify against code (the current — and default — mode)'],
|
|
298
|
+
['--format json', 'Machine-readable task list (the agent-executable artifact)'],
|
|
299
|
+
],
|
|
300
|
+
examples: ['docguard verify --semantic', 'docguard verify --semantic --format json'],
|
|
301
|
+
},
|
|
282
302
|
};
|
|
283
303
|
|
|
284
304
|
function printCommandHelp(command) {
|
|
@@ -347,6 +367,14 @@ async function main() {
|
|
|
347
367
|
flags.auto = true;
|
|
348
368
|
} else if (args[i] === '--write') {
|
|
349
369
|
flags.write = true;
|
|
370
|
+
} else if (args[i] === '--tests') {
|
|
371
|
+
// v0.28 (field report #10): `docguard sync --tests` reconciles the
|
|
372
|
+
// TEST-SPEC Source-to-Test Map from disk.
|
|
373
|
+
flags.tests = true;
|
|
374
|
+
} else if (args[i] === '--semantic') {
|
|
375
|
+
// v0.28 (field report #5): `docguard verify --semantic` extracts
|
|
376
|
+
// documented numbers/enums/limits for the agent to check against code.
|
|
377
|
+
flags.semantic = true;
|
|
350
378
|
} else if (args[i] === '--plan') {
|
|
351
379
|
flags.plan = true;
|
|
352
380
|
} else if (args[i] === '--since' && args[i + 1]) {
|
|
@@ -507,6 +535,11 @@ async function main() {
|
|
|
507
535
|
const READ_ONLY_COMMANDS = new Set([
|
|
508
536
|
'guard', 'audit', 'score', 'diff', 'impact',
|
|
509
537
|
'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent',
|
|
538
|
+
// feedback only writes its own .docguard/feedback/ — it must NOT scaffold
|
|
539
|
+
// skills or touch source, so it's gated out of ensureSkills like the rest.
|
|
540
|
+
'feedback',
|
|
541
|
+
// verify only reads docs and emits a task list — pure report.
|
|
542
|
+
'verify',
|
|
510
543
|
]);
|
|
511
544
|
|
|
512
545
|
// Silent auto-check: install skills/commands if missing. Skip entirely in
|
|
@@ -646,6 +679,18 @@ async function main() {
|
|
|
646
679
|
case 'explain':
|
|
647
680
|
runExplain(projectDir, config, flags);
|
|
648
681
|
break;
|
|
682
|
+
case 'feedback':
|
|
683
|
+
// v0.27 (field report #3 / LLM feedback loop): collect low-confidence
|
|
684
|
+
// findings (likely false positives) → local record + 1-click prefilled,
|
|
685
|
+
// redacted, capped GitHub issue URL. Opt-in; nothing filed automatically.
|
|
686
|
+
runFeedback(projectDir, config, flags);
|
|
687
|
+
break;
|
|
688
|
+
case 'verify':
|
|
689
|
+
// v0.28 (field report #5): extract documented numbers/limits/enums as a
|
|
690
|
+
// verification task list for the agent to check against code (semantic
|
|
691
|
+
// drift — the class regex/AST can't see). Read-only.
|
|
692
|
+
runVerify(projectDir, config, flags);
|
|
693
|
+
break;
|
|
649
694
|
case 'memory':
|
|
650
695
|
runMemory(projectDir, config, flags);
|
|
651
696
|
break;
|
package/cli/findings.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findings — the structured, LLM-addressable result unit (v0.27).
|
|
3
|
+
*
|
|
4
|
+
* Background (LLM field report #3): DocGuard's whole job is to tell an agent
|
|
5
|
+
* what to do NEXT. A free-text `errors`/`warnings` string can't carry a stable
|
|
6
|
+
* code (for `explain <CODE>` + inline suppression), a confidence (the signal
|
|
7
|
+
* the false-positive feedback loop runs on), or a machine-readable suggested
|
|
8
|
+
* action. A Finding carries all three.
|
|
9
|
+
*
|
|
10
|
+
* The migration is INCREMENTAL and BACKWARD-COMPATIBLE. A validator that opts in
|
|
11
|
+
* builds `Finding[]` and returns `resultFromFindings(...)`, which still emits the
|
|
12
|
+
* exact `{ errors, warnings, passed, total }` shape every existing consumer
|
|
13
|
+
* (guard counts + exit code, diagnose, score, ci, `--format json`) already reads
|
|
14
|
+
* — PLUS a `findings` array that guard renders richly (each issue gets its
|
|
15
|
+
* `→ suggestion`). Validators that haven't migrated keep returning their
|
|
16
|
+
* hand-built results and render exactly as before. Nothing regresses.
|
|
17
|
+
*
|
|
18
|
+
* Zero npm dependencies — pure Node.js built-ins.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} Suggestion
|
|
21
|
+
* @property {'fix'|'suppress'|'review'|'report'} kind
|
|
22
|
+
* @property {string} text One concise line: what to do next.
|
|
23
|
+
* @property {string} [command] Optional CLI/skill command to run.
|
|
24
|
+
* @property {string} [pragma] Optional inline suppression snippet.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {Object} Finding
|
|
27
|
+
* @property {string} code Stable code, e.g. 'SEC001' (see CODES).
|
|
28
|
+
* @property {string} validator Owning validator key.
|
|
29
|
+
* @property {'error'|'warn'} severity
|
|
30
|
+
* @property {'high'|'low'} confidence 'low' = candidate false positive.
|
|
31
|
+
* @property {string} message Concise, NO ansi colour.
|
|
32
|
+
* @property {string|null} location 'path:line' or 'path'.
|
|
33
|
+
* @property {Suggestion|null} suggestion
|
|
34
|
+
* @property {boolean} reportable Surface in `docguard feedback`.
|
|
35
|
+
* @property {string|null} redactedContext Safe-to-share context for a report.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stable finding-code registry. `docguard explain <CODE>` reads this, and
|
|
40
|
+
* inline `// docguard:ignore <CODE>` keys off it. Keep codes append-only — a
|
|
41
|
+
* published code is a public surface we don't renumber.
|
|
42
|
+
*/
|
|
43
|
+
export const CODES = {
|
|
44
|
+
SEC001: {
|
|
45
|
+
validator: 'security',
|
|
46
|
+
title: 'Hardcoded password',
|
|
47
|
+
help: 'A `password`/`passwd`/`pwd` assignment with a quoted literal value (8+ chars). If the value is natural-language UI copy or a validation message — not a credential — this is a false positive: DocGuard now flags those low-confidence, but you can suppress inline.',
|
|
48
|
+
suppress: '// docguard:ignore SEC001 — UI copy, not a credential',
|
|
49
|
+
},
|
|
50
|
+
SEC002: {
|
|
51
|
+
validator: 'security',
|
|
52
|
+
title: 'Hardcoded API key',
|
|
53
|
+
help: 'An `api_key`/`apikey` assignment with a quoted literal value (16+ chars). Move it to an environment variable and read it via `process.env`.',
|
|
54
|
+
suppress: '// docguard:ignore SEC002 — sample value in fixture',
|
|
55
|
+
},
|
|
56
|
+
SEC003: {
|
|
57
|
+
validator: 'security',
|
|
58
|
+
title: 'Hardcoded secret key',
|
|
59
|
+
help: 'A `secret_key`/`secretkey` assignment with a quoted literal value (16+ chars). Move it to an environment variable.',
|
|
60
|
+
suppress: '// docguard:ignore SEC003 — reason',
|
|
61
|
+
},
|
|
62
|
+
SEC004: {
|
|
63
|
+
validator: 'security',
|
|
64
|
+
title: 'Hardcoded access token',
|
|
65
|
+
help: 'An `access_token`/`accesstoken` assignment with a quoted literal value (16+ chars). Move it to an environment variable.',
|
|
66
|
+
suppress: '// docguard:ignore SEC004 — reason',
|
|
67
|
+
},
|
|
68
|
+
SEC005: {
|
|
69
|
+
validator: 'security',
|
|
70
|
+
title: 'AWS Access Key ID',
|
|
71
|
+
help: 'A string matching the AWS Access Key ID format (AKIA…). Rotate it immediately if real, and move credentials to the AWS credential chain / environment.',
|
|
72
|
+
suppress: '// docguard:ignore SEC005 — documented example key',
|
|
73
|
+
},
|
|
74
|
+
SEC006: {
|
|
75
|
+
validator: 'security',
|
|
76
|
+
title: 'API secret key (Stripe/OpenAI pattern)',
|
|
77
|
+
help: 'A string matching a live/test secret-key format (sk-…, sk_live_…). Rotate it if real and move it to an environment variable.',
|
|
78
|
+
suppress: '// docguard:ignore SEC006 — reason',
|
|
79
|
+
},
|
|
80
|
+
SEC010: {
|
|
81
|
+
validator: 'security',
|
|
82
|
+
title: '.env not in .gitignore',
|
|
83
|
+
help: 'No `.env` entry was found in .gitignore, so a local `.env` could be committed. Add `.env` (and `.env.local`) to .gitignore.',
|
|
84
|
+
suppress: null,
|
|
85
|
+
},
|
|
86
|
+
SEC011: {
|
|
87
|
+
validator: 'security',
|
|
88
|
+
title: 'No source files scanned for secrets',
|
|
89
|
+
help: 'The secret scan matched zero source files — usually a too-broad ignore config or a wrong sourceRoot. A scan that checks nothing is a dangerous false ✅.',
|
|
90
|
+
suppress: null,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a Finding with sane defaults. `reportable` defaults to true for
|
|
96
|
+
* low-confidence findings — low confidence IS the feedback signal.
|
|
97
|
+
*
|
|
98
|
+
* @param {Partial<Finding>} f
|
|
99
|
+
* @returns {Finding}
|
|
100
|
+
*/
|
|
101
|
+
export function mkFinding(f) {
|
|
102
|
+
const severity = f.severity === 'error' ? 'error' : 'warn';
|
|
103
|
+
const confidence = f.confidence === 'low' ? 'low' : 'high';
|
|
104
|
+
return {
|
|
105
|
+
code: f.code || null,
|
|
106
|
+
validator: f.validator || null,
|
|
107
|
+
severity,
|
|
108
|
+
confidence,
|
|
109
|
+
message: f.message || '',
|
|
110
|
+
location: f.location || null,
|
|
111
|
+
suggestion: f.suggestion || null,
|
|
112
|
+
reportable: f.reportable === true || confidence === 'low',
|
|
113
|
+
redactedContext: f.redactedContext || null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Derive the legacy `{ errors, warnings, passed, total }` result from a list of
|
|
119
|
+
* findings, keeping `findings` attached for the rich renderer. ONE source of
|
|
120
|
+
* truth — the strings guard counts and the findings guard renders can never
|
|
121
|
+
* disagree because they're computed from the same array.
|
|
122
|
+
*
|
|
123
|
+
* @param {Finding[]} findings
|
|
124
|
+
* @param {{passed?:number, total?:number, applicable?:boolean}} [opts]
|
|
125
|
+
*/
|
|
126
|
+
export function resultFromFindings(findings, opts = {}) {
|
|
127
|
+
const errors = [];
|
|
128
|
+
const warnings = [];
|
|
129
|
+
for (const f of findings) {
|
|
130
|
+
if (f.severity === 'error') errors.push(f.message);
|
|
131
|
+
else warnings.push(f.message);
|
|
132
|
+
}
|
|
133
|
+
const res = {
|
|
134
|
+
errors,
|
|
135
|
+
warnings,
|
|
136
|
+
passed: opts.passed || 0,
|
|
137
|
+
total: opts.total != null ? opts.total : 0,
|
|
138
|
+
findings,
|
|
139
|
+
};
|
|
140
|
+
if (opts.applicable !== undefined) res.applicable = opts.applicable;
|
|
141
|
+
return res;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Does an inline `docguard:ignore` pragma in `text` suppress finding `code`?
|
|
146
|
+
*
|
|
147
|
+
* Accepted forms (mirrors the ergonomics of eslint-disable / ruff `# noqa`):
|
|
148
|
+
* docguard:ignore → suppresses ANY code on the line
|
|
149
|
+
* docguard:ignore SEC001 → suppresses exactly SEC001
|
|
150
|
+
* docguard:ignore SEC001,DQ002 → comma list
|
|
151
|
+
* docguard:ignore SEC* → prefix wildcard
|
|
152
|
+
* docguard:ignore all → suppresses any code
|
|
153
|
+
* docguard:ignore-secret → convenience alias for any SEC* code
|
|
154
|
+
*
|
|
155
|
+
* @param {string} text
|
|
156
|
+
* @param {string} code
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
export function suppressesCode(text, code) {
|
|
160
|
+
if (!text || !code) return false;
|
|
161
|
+
const m = text.match(/docguard:ignore(-secret)?\b[ \t]*([A-Za-z0-9_,*-]+)?/i);
|
|
162
|
+
if (!m) return false;
|
|
163
|
+
if (m[1]) return /^SEC/i.test(code); // ignore-secret alias
|
|
164
|
+
const arg = (m[2] || '').trim();
|
|
165
|
+
if (!arg) return true; // bare ignore → any code
|
|
166
|
+
return arg.split(',').map((s) => s.trim()).some((tok) => {
|
|
167
|
+
if (!tok) return false;
|
|
168
|
+
if (tok.toLowerCase() === 'all') return true;
|
|
169
|
+
if (tok.endsWith('*')) return code.toUpperCase().startsWith(tok.slice(0, -1).toUpperCase());
|
|
170
|
+
return tok.toUpperCase() === code.toUpperCase();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Source-line suppression: an ignore pragma counts if it's on the flagged line
|
|
176
|
+
* OR the line directly above it (so a comment can sit above the offending
|
|
177
|
+
* statement, the common style for non-trailing-comment languages).
|
|
178
|
+
*/
|
|
179
|
+
export function lineSuppresses(code, line, prevLine = '') {
|
|
180
|
+
return suppressesCode(line, code) || suppressesCode(prevLine, code);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Flatten a one-line, colour-free rendering of a suggestion — used by JSON
|
|
185
|
+
* consumers, diagnose, and the feedback body. Guard does its own coloured
|
|
186
|
+
* rendering and does not use this.
|
|
187
|
+
*/
|
|
188
|
+
export function suggestionLine(s) {
|
|
189
|
+
if (!s) return '';
|
|
190
|
+
let out = s.text || '';
|
|
191
|
+
if (s.command) out += ` → ${s.command}`;
|
|
192
|
+
else if (s.pragma) out += ` → ${s.pragma}`;
|
|
193
|
+
return out;
|
|
194
|
+
}
|