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.
@@ -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
+ }
@@ -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 warning text
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;
@@ -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
+ }