docguard-cli 0.27.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 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 (16)"]
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 **16 commands** (the "Daily 5" + 11 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).
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
 
@@ -274,6 +274,7 @@ DocGuard ships **16 commands** (the "Daily 5" + 11 situational tools, including
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
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 |
277
278
  | `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
278
279
  | `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
279
280
  | `memory --diff` | Drill into which specific claims don't match code |
@@ -111,13 +111,14 @@ const EXPLAINERS = {
111
111
  },
112
112
  freshness: {
113
113
  title: 'Freshness — docs updated alongside code',
114
- what: 'For each canonical doc, counts code commits since the doc\'s last commit. >10 commits = stale.',
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.',
115
115
  why: 'Docs drift silently. This validator surfaces the drift before it becomes invisible.',
116
116
  triggers: [
117
- ['code commits since last doc update', 'Run `docguard sync --write` to refresh code-truth sections, then review the prose for accuracy.'],
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.'],
118
119
  ['DRIFT-LOG.md may be stale', 'DRIFT comments in code outpaced log entries. Add the entries.'],
119
120
  ],
120
- 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)',
121
122
  standard: 'CDD principle: docs and code commit together',
122
123
  },
123
124
  traceability: {
@@ -134,12 +135,13 @@ const EXPLAINERS = {
134
135
  standard: 'ISO/IEC/IEEE 29148 (requirements traceability)',
135
136
  },
136
137
  apiSurface: {
137
- title: 'API-Surface — endpoints in code match API-REFERENCE.md',
138
- what: 'Compares routes scanned from code (Express, Next, FastAPI, Spring, etc.) against endpoints listed in API-REFERENCE.md and OpenAPI specs.',
139
- 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.',
140
141
  triggers: [
141
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.'],
142
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).'],
143
145
  ],
144
146
  example: 'GET /api/users in src/routes/users.ts AND in API-REFERENCE.md\'s Endpoints table',
145
147
  standard: 'OpenAPI 3.1',
@@ -462,12 +462,17 @@ export function runGuard(projectDir, config, flags) {
462
462
 
463
463
  // ── JSON output ──
464
464
  if (flags.format === 'json') {
465
- console.log(JSON.stringify(data, null, 2));
466
465
  // Use severity-aware effective counts for exit code; raw counts stay in the JSON
467
466
  // for display tools that want to show the full picture.
468
- if (data.effectiveErrors > 0) process.exit(1);
469
- if (data.effectiveWarnings > 0) process.exit(2);
470
- process.exit(0);
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;
471
476
  }
472
477
 
473
478
  // ── Text output ──
@@ -707,7 +712,8 @@ export function runGuard(projectDir, config, flags) {
707
712
  }
708
713
 
709
714
  // v0.5: severity-aware exit codes (see runGuardInternal for the rollup).
710
- if (data.effectiveErrors > 0) process.exit(1);
711
- if (data.effectiveWarnings > 0) process.exit(2);
712
- process.exit(0);
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;
713
719
  }
@@ -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
- const processed = content.replace(/YYYY-MM-DD/g, today);
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}`);
@@ -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
@@ -44,6 +44,7 @@ import { runUpgrade } from './commands/upgrade.mjs';
44
44
  import { runImpact } from './commands/impact.mjs';
45
45
  import { runExplain } from './commands/explain.mjs';
46
46
  import { runFeedback } from './commands/feedback.mjs';
47
+ import { runVerify } from './commands/verify.mjs';
47
48
  import { runMemory } from './commands/memory.mjs';
48
49
  import { runDemo } from './commands/demo.mjs';
49
50
  import { runAgent } from './commands/agent.mjs';
@@ -87,6 +88,7 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
87
88
  ${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
88
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})
89
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})
90
92
  ${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
91
93
  ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
92
94
  ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
@@ -232,13 +234,14 @@ const COMMAND_HELP = {
232
234
  examples: ['docguard diff', 'docguard diff --since HEAD~5'],
233
235
  },
234
236
  sync: {
235
- summary: 'Refresh code-truth doc sections (preview by default).',
236
- 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]',
237
239
  flags: [
238
240
  ['--write', 'Apply the refresh (default is a dry-run preview)'],
239
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.'],
240
243
  ],
241
- examples: ['docguard sync', 'docguard sync --write'],
244
+ examples: ['docguard sync', 'docguard sync --write', 'docguard sync --tests', 'docguard sync --tests --write'],
242
245
  },
243
246
  fix: {
244
247
  summary: 'Generate AI fix instructions for docs (or apply deterministic fixes).',
@@ -287,6 +290,15 @@ const COMMAND_HELP = {
287
290
  flags: [['--format json', 'Machine-readable list of reportable findings + URLs']],
288
291
  examples: ['docguard feedback'],
289
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
+ },
290
302
  };
291
303
 
292
304
  function printCommandHelp(command) {
@@ -355,6 +367,14 @@ async function main() {
355
367
  flags.auto = true;
356
368
  } else if (args[i] === '--write') {
357
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;
358
378
  } else if (args[i] === '--plan') {
359
379
  flags.plan = true;
360
380
  } else if (args[i] === '--since' && args[i + 1]) {
@@ -518,6 +538,8 @@ async function main() {
518
538
  // feedback only writes its own .docguard/feedback/ — it must NOT scaffold
519
539
  // skills or touch source, so it's gated out of ensureSkills like the rest.
520
540
  'feedback',
541
+ // verify only reads docs and emits a task list — pure report.
542
+ 'verify',
521
543
  ]);
522
544
 
523
545
  // Silent auto-check: install skills/commands if missing. Skip entirely in
@@ -663,6 +685,12 @@ async function main() {
663
685
  // redacted, capped GitHub issue URL. Opt-in; nothing filed automatically.
664
686
  runFeedback(projectDir, config, flags);
665
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;
666
694
  case 'memory':
667
695
  runMemory(projectDir, config, flags);
668
696
  break;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Semantic claim extractor (LLM field report #5).
3
+ *
4
+ * The highest-value class of doc bug is SEMANTIC: a documented number/enum/limit
5
+ * that no longer matches the code — DLP retention "30 days" vs code 730, a status
6
+ * enum "PENDING/IDLE" vs "WAITING", "100/min" vs "500 req/s", "29+ roles" vs 44,
7
+ * "4 GSIs" vs 6. Regex/AST can't judge these (the doc value and the code value
8
+ * are both just numbers), so they slip through every deterministic validator.
9
+ *
10
+ * DocGuard is zero-dependency and does NOT call an LLM itself. So this is an
11
+ * EXTRACTOR: it surfaces the verifiable claims — value, unit, doc:line, section,
12
+ * and the nearest cited code path — as a structured task list. The agent running
13
+ * `docguard verify --semantic` does the actual comparison against the code. This
14
+ * mirrors the `docguard agent` task-graph: deterministic discovery, LLM judgment.
15
+ *
16
+ * Precision over recall: a number is only a claim when it carries a recognized
17
+ * unit (days/ms/req-s/GSIs/roles/…); an enum only when it's a list of 2+
18
+ * UPPER_SNAKE tokens in a status/state/enum context. Bare version strings, dates,
19
+ * and prose numbers are ignored.
20
+ *
21
+ * Zero npm dependencies — pure Node.js built-ins.
22
+ */
23
+
24
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
+ import { resolve, join } from 'node:path';
26
+
27
+ // Numbers are only claims when adjacent to a recognized unit.
28
+ const NUMBER_PATTERNS = [
29
+ { kind: 'duration', re: /\b(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?)\b/gi },
30
+ { kind: 'rate', re: /\b(\d+)\s*(?:\/|\bper\b|\breq(?:uests?)?\s*\/?)\s*(s|sec|seconds?|min|minutes?|hours?|h)\b/gi },
31
+ { kind: 'count', re: /\b(\d+)\s*\+?\s*(GSIs?|LSIs?|indexes|indices|roles?|permissions?|scopes?|tables?|queues?|topics?|buckets?|endpoints?|routes?|validators?|columns?|fields?|shards?|partitions?|replicas?|retries|workers?|threads?|connections?)\b/gi },
32
+ ];
33
+
34
+ // A list of 2+ UPPER_SNAKE tokens separated by / , | or "or" — an enum claim,
35
+ // but only when the line or its heading reads like a status/state/enum context.
36
+ const ENUM_LIST_RE = /\b[A-Z][A-Z0-9_]{2,}(?:\s*(?:\/|,|\||\bor\b)\s*[A-Z][A-Z0-9_]{2,}){1,}\b/g;
37
+ const ENUM_CONTEXT_RE = /\b(status|state|enum|values?|one of|phase|stage|transitions?)\b/i;
38
+
39
+ // A code path mentioned in or near the claim — the agent's starting point.
40
+ const CITED_CODE_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?(?::(\d+))?/;
41
+
42
+ const MAX_CLAIMS = 80;
43
+
44
+ /** Canonical docs + the root docs where limits/counts commonly live. */
45
+ function claimSourceDocs(projectDir) {
46
+ const docs = [];
47
+ const canonical = resolve(projectDir, 'docs-canonical');
48
+ if (existsSync(canonical)) {
49
+ try {
50
+ for (const f of readdirSync(canonical)) {
51
+ if (f.toLowerCase().endsWith('.md')) docs.push(`docs-canonical/${f}`);
52
+ }
53
+ } catch { /* ignore */ }
54
+ }
55
+ for (const root of ['README.md', 'AGENTS.md']) {
56
+ if (existsSync(resolve(projectDir, root))) docs.push(root);
57
+ }
58
+ return docs;
59
+ }
60
+
61
+ /** True if a line is inside a fenced code block (toggled by the caller). */
62
+ function findCitedCode(lines, idx) {
63
+ // Search the claim line first, then the immediately adjacent lines. A tight
64
+ // window avoids cross-attributing a path from an unrelated nearby claim (e.g.
65
+ // a rate limit grabbing the retention doc's cited file three lines up).
66
+ for (let d = 0; d <= 1; d++) {
67
+ for (const j of d === 0 ? [idx] : [idx - d, idx + d]) {
68
+ if (j < 0 || j >= lines.length) continue;
69
+ const m = CITED_CODE_RE.exec(lines[j]);
70
+ if (m) return m[2] ? `${m[1]}:${m[2]}` : m[1];
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Extract semantic claims from a project's canonical docs.
78
+ * @returns {Array<{ doc, line, section, kind, subkind, value, unit, text, citedCode }>}
79
+ */
80
+ export function extractSemanticClaims(projectDir, config = {}) {
81
+ const claims = [];
82
+ const seen = new Set();
83
+
84
+ for (const doc of claimSourceDocs(projectDir)) {
85
+ let content;
86
+ try { content = readFileSync(resolve(projectDir, doc), 'utf-8'); } catch { continue; }
87
+ const lines = content.split('\n');
88
+ let section = '';
89
+ let inFence = false;
90
+
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const line = lines[i];
93
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
94
+ if (inFence) continue; // numbers in code samples are examples, not claims
95
+ const h = line.match(/^#{1,6}\s+(.*)$/);
96
+ if (h) { section = h[1].trim(); continue; }
97
+
98
+ const lineNo = i + 1;
99
+ const push = (claim) => {
100
+ const key = `${doc}:${lineNo}:${claim.kind}:${claim.value}:${claim.unit || ''}`;
101
+ if (seen.has(key)) return;
102
+ seen.add(key);
103
+ claims.push({ doc, line: lineNo, section, citedCode: findCitedCode(lines, i), text: line.trim().slice(0, 200), ...claim });
104
+ };
105
+
106
+ for (const { kind, re } of NUMBER_PATTERNS) {
107
+ re.lastIndex = 0;
108
+ let m;
109
+ while ((m = re.exec(line)) !== null) {
110
+ push({ kind: 'number', subkind: kind, value: m[1], unit: m[2].toLowerCase() });
111
+ }
112
+ }
113
+
114
+ if (ENUM_CONTEXT_RE.test(line) || ENUM_CONTEXT_RE.test(section)) {
115
+ ENUM_LIST_RE.lastIndex = 0;
116
+ let m;
117
+ while ((m = ENUM_LIST_RE.exec(line)) !== null) {
118
+ // Skip all-caps acronym runs joined by slash that are really one token.
119
+ const values = m[0].split(/\s*(?:\/|,|\||\bor\b)\s*/).filter(Boolean);
120
+ if (values.length >= 2) push({ kind: 'enum', subkind: 'enum-list', value: values.join('/'), unit: null });
121
+ }
122
+ }
123
+
124
+ if (claims.length >= MAX_CLAIMS) return claims;
125
+ }
126
+ }
127
+ return claims;
128
+ }
129
+
130
+ /**
131
+ * Turn extracted claims into agent-executable verification tasks (one per claim).
132
+ * Pure — reused by the command and any task-graph consumer.
133
+ */
134
+ export function buildSemanticVerifyTasks(claims) {
135
+ return claims.map((c, i) => {
136
+ const where = c.citedCode ? ` Start at the cited code: ${c.citedCode}.` : ' No code path is cited nearby — grep the codebase for the relevant constant/config.';
137
+ const what = c.kind === 'enum'
138
+ ? `the enum/status set "${c.value}"`
139
+ : `the ${c.subkind} value ${c.value}${c.unit ? ` ${c.unit}` : ''}`;
140
+ return {
141
+ id: `verify.semantic.${i + 1}`,
142
+ doc: c.doc,
143
+ line: c.line,
144
+ section: c.section,
145
+ kind: c.kind,
146
+ value: c.value,
147
+ unit: c.unit,
148
+ citedCode: c.citedCode,
149
+ claim: c.text,
150
+ instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values.`,
151
+ confidence: 'requires-human',
152
+ };
153
+ });
154
+ }
@@ -208,10 +208,56 @@ export function computeApiSurfaceDrift(projectDir, config) {
208
208
  };
209
209
  }
210
210
 
211
+ /**
212
+ * v0.28 (field report #4): diff the OpenAPI spec against the routes actually
213
+ * REGISTERED in code. When a spec exists, resolveApiSurface treats it as ground
214
+ * truth and the API-REFERENCE doc reconciles against it — so a spec that declares
215
+ * a phantom endpoint (no Express/Fastify route registers it) passes doc-vs-spec
216
+ * clean while the spec itself is wrong. This catches that.
217
+ *
218
+ * Conservative on purpose: only runs when code routes are actually scannable.
219
+ * If the scanner finds zero routes (unsupported framework, dynamically-registered
220
+ * routes), we can't tell "no route" from "scanner blind", so we skip rather than
221
+ * flag every spec endpoint as phantom. Reuses compareEndpoints so path-param /
222
+ * mount-prefix normalization matches the rest of the validator.
223
+ *
224
+ * @returns {{ applicable:boolean, specPath:string|null, routeCount:number,
225
+ * matched:object[], specDeclaredNoRoute:object[], reason?:string }}
226
+ */
227
+ export function computeSpecVsRouteDrift(projectDir, config) {
228
+ const specs = findAllOpenApiSpecs(projectDir, config);
229
+ if (specs.length === 0) {
230
+ return { applicable: false, specPath: null, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no openapi spec' };
231
+ }
232
+ const spec = specs[0]; // authoritative (sourceRoot first, root last)
233
+ const framework = detectFramework(projectDir, config);
234
+ const routes = scanRoutesDeep(projectDir, { framework }, { openapi: { found: false } }, { config });
235
+ if (routes.length === 0) {
236
+ return { applicable: false, specPath: spec.relPath, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no routes scannable' };
237
+ }
238
+ // documentedButAbsent = in the SPEC (first arg) but absent from the ROUTES
239
+ // (second arg) = spec-declares-but-no-route.
240
+ const cmp = compareEndpoints(
241
+ spec.endpoints.map(e => ({ method: e.method, path: e.path })),
242
+ routes.map(r => ({ method: r.method, path: r.path }))
243
+ );
244
+ return {
245
+ applicable: true,
246
+ specPath: spec.relPath,
247
+ routeCount: routes.length,
248
+ matched: cmp.matched,
249
+ specDeclaredNoRoute: cmp.documentedButAbsent,
250
+ };
251
+ }
252
+
211
253
  export function validateApiSurface(projectDir, config) {
212
254
  const errors = [];
213
255
  const warnings = [];
214
256
  const fixes = [];
257
+ const trim = (arr) => {
258
+ const shown = arr.slice(0, MAX_REPORTED);
259
+ return { shown, extra: arr.length - shown.length };
260
+ };
215
261
 
216
262
  // v0.14-P2: when --changed-only scoping is active and NONE of the changed
217
263
  // files look like route/spec/controller files, this validator has nothing
@@ -256,19 +302,39 @@ export function validateApiSurface(projectDir, config) {
256
302
  );
257
303
  }
258
304
 
305
+ // ── #4: spec declares an endpoint with no registered route ──
306
+ // Independent of the API-REFERENCE doc — it checks the spec against code, so it
307
+ // runs even when no doc exists. Conservative (only when routes are scannable).
308
+ const specRoute = computeSpecVsRouteDrift(projectDir, config);
309
+ let specRouteTotal = 0;
310
+ let specRoutePassed = 0;
311
+ if (specRoute.applicable) {
312
+ specRouteTotal = specRoute.matched.length + specRoute.specDeclaredNoRoute.length;
313
+ specRoutePassed = specRoute.matched.length;
314
+ if (specRoute.specDeclaredNoRoute.length) {
315
+ const { shown, extra } = trim(specRoute.specDeclaredNoRoute);
316
+ for (const e of shown) {
317
+ warnings.push(
318
+ `OpenAPI spec (${specRoute.specPath}) declares ${e.method} ${e.path} but no route registers it in code — ` +
319
+ `the spec may be wrong, and the API-REFERENCE doc reconciles clean against it, hiding the gap.`
320
+ );
321
+ }
322
+ if (extra > 0) warnings.push(`…and ${extra} more spec-declared endpoint(s) with no registered route`);
323
+ }
324
+ }
325
+
259
326
  if (!drift.applicable) {
260
- // Nothing to validate against the API-REFERENCE doc.
261
- return { errors, warnings, passed: 0, total: 0, fixes, authoritativeSpec: drift.source };
327
+ // Nothing to validate against the API-REFERENCE doc — but the spec-vs-route
328
+ // check above may still have produced findings.
329
+ return {
330
+ errors, warnings, passed: specRoutePassed, total: specRouteTotal, fixes,
331
+ authoritativeSpec: drift.source || specRoute.specPath,
332
+ };
262
333
  }
263
334
 
264
335
  const { documentedButAbsent, presentButUndocumented, matched, confidence, source } = drift;
265
- const total = matched.length + documentedButAbsent.length + presentButUndocumented.length;
266
- const passed = matched.length;
267
-
268
- const trim = (arr) => {
269
- const shown = arr.slice(0, MAX_REPORTED);
270
- return { shown, extra: arr.length - shown.length };
271
- };
336
+ const total = matched.length + documentedButAbsent.length + presentButUndocumented.length + specRouteTotal;
337
+ const passed = matched.length + specRoutePassed;
272
338
 
273
339
  // documented-but-absent → deterministic remove-endpoint fixes
274
340
  if (documentedButAbsent.length) {
@@ -97,11 +97,11 @@ function validateConfigLayers(projectDir, config, layers, results) {
97
97
  const relPath = relative(projectDir, file);
98
98
  const imports = extractImports(content);
99
99
 
100
- for (const imp of imports) {
101
- if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
100
+ for (const { spec } of imports) {
101
+ if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
102
102
 
103
103
  for (const forbiddenDir of layer.forbidden) {
104
- if (imp.includes(forbiddenDir) || imp.includes(`/${forbiddenDir}/`)) {
104
+ if (spec.includes(forbiddenDir) || spec.includes(`/${forbiddenDir}/`)) {
105
105
  results.total++;
106
106
  results.errors.push(
107
107
  `${relPath}: ${layer.name} layer imports from forbidden layer (${forbiddenDir})`
@@ -135,14 +135,19 @@ function buildImportGraph(projectDir, config) {
135
135
 
136
136
  const resolvedImports = [];
137
137
  for (const imp of imports) {
138
- if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
138
+ if (!imp.spec.startsWith('.') && !imp.spec.startsWith('/')) continue;
139
139
 
140
140
  // Resolve relative imports
141
141
  const fromDir = dirname(file);
142
- const resolved = resolveImport(fromDir, imp, projectDir);
142
+ const resolved = resolveImport(fromDir, imp.spec, projectDir);
143
143
  if (resolved) {
144
- resolvedImports.push(resolved);
145
- graph.edges.push({ from: relPath, to: resolved });
144
+ graph.edges.push({ from: relPath, to: resolved, dynamic: imp.dynamic });
145
+ // v0.28 (field report #2): a dynamic `await import()` does NOT create a
146
+ // load-time edge — it's the canonical way to BREAK an import cycle. So
147
+ // it's excluded from the cycle-detection adjacency (fileMap) while still
148
+ // recorded in graph.edges for layer-boundary checks (an import is still
149
+ // an import for layering).
150
+ if (!imp.dynamic) resolvedImports.push(resolved);
146
151
  }
147
152
  }
148
153
 
@@ -153,26 +158,33 @@ function buildImportGraph(projectDir, config) {
153
158
  return graph;
154
159
  }
155
160
 
161
+ /**
162
+ * Extract a file's imports as `{ spec, dynamic }`. `dynamic:true` marks a
163
+ * runtime `import('…')` — which does NOT create a load-time dependency edge and
164
+ * is the canonical way to break an import cycle (field report #2). ES `import …
165
+ * from` and CommonJS `require()` are load-time (static).
166
+ */
156
167
  function extractImports(content) {
157
168
  const imports = [];
158
169
 
159
- // ES module imports
170
+ // ES module imports (static, load-time). `import\s+` requires whitespace after
171
+ // `import`, so it never matches a dynamic `import(` call.
160
172
  const esImportRegex = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
161
173
  let match;
162
174
  while ((match = esImportRegex.exec(content)) !== null) {
163
- imports.push(match[1]);
175
+ imports.push({ spec: match[1], dynamic: false });
164
176
  }
165
177
 
166
- // Dynamic imports
178
+ // Dynamic imports (runtime — NOT a load-time cycle edge)
167
179
  const dynamicRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
168
180
  while ((match = dynamicRegex.exec(content)) !== null) {
169
- imports.push(match[1]);
181
+ imports.push({ spec: match[1], dynamic: true });
170
182
  }
171
183
 
172
- // CommonJS require
184
+ // CommonJS require (static, load-time)
173
185
  const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
174
186
  while ((match = requireRegex.exec(content)) !== null) {
175
- imports.push(match[1]);
187
+ imports.push({ spec: match[1], dynamic: false });
176
188
  }
177
189
 
178
190
  return imports;
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.27.0"
6
+ version: "0.28.0"
7
7
  description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
8
8
  author: "Ricardo Accioly"
9
9
  repository: "https://github.com/raccioly/docguard"
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.27.0
9
+ version: 0.28.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.27.0 -->
12
+ <!-- docguard:version: 0.28.0 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15
 
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
7
7
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
8
8
  metadata:
9
9
  author: docguard
10
- version: 0.27.0
10
+ version: 0.28.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.27.0 -->
13
+ <!-- docguard:version: 0.28.0 -->
14
14
 
15
15
  # DocGuard Guard Skill
16
16
 
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.27.0
9
+ version: 0.28.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.27.0 -->
12
+ <!-- docguard:version: 0.28.0 -->
13
13
 
14
14
  # DocGuard Review Skill
15
15
 
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.27.0
9
+ version: 0.28.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.27.0 -->
12
+ <!-- docguard:version: 0.28.0 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
4
4
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
5
5
  metadata:
6
6
  author: docguard
7
- version: 0.27.0
7
+ version: 0.28.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.27.0 -->
10
+ <!-- docguard:version: 0.28.0 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,10 @@
1
1
  # Environment & Configuration
2
2
 
3
+ <!-- docguard:version 0.1.0 -->
4
+ <!-- docguard:status draft -->
5
+ <!-- docguard:last-reviewed YYYY-MM-DD -->
6
+ <!-- docguard:owner @your-github-username -->
7
+
3
8
  > **Canonical document** — Design intent. This file documents everything needed to run this project.
4
9
  > Last updated: YYYY-MM-DD
5
10
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  <!-- docguard:version 0.1.0 -->
4
4
  <!-- docguard:status draft -->
5
+ <!-- docguard:last-reviewed YYYY-MM-DD -->
6
+ <!-- docguard:owner @your-github-username -->
5
7
 
6
8
  > Tracks functional requirements, non-functional requirements, and success criteria.
7
9
  > Use requirement IDs (FR-001, NFR-001, SC-001) for traceability back to code and tests.
@@ -1,6 +1,11 @@
1
1
  # Security
2
2
 
3
- > **Canonical document** — Design intent. This file defines the security model.
3
+ <!-- docguard:version 0.1.0 -->
4
+ <!-- docguard:status draft -->
5
+ <!-- docguard:last-reviewed YYYY-MM-DD -->
6
+ <!-- docguard:owner @your-github-username -->
7
+
8
+ > **Canonical document** — Design intent. This file defines the security model.
4
9
  > Last updated: YYYY-MM-DD
5
10
 
6
11
  ---
@@ -1,5 +1,10 @@
1
1
  # Test Specification
2
2
 
3
+ <!-- docguard:version 0.1.0 -->
4
+ <!-- docguard:status draft -->
5
+ <!-- docguard:last-reviewed YYYY-MM-DD -->
6
+ <!-- docguard:owner @your-github-username -->
7
+
3
8
  > **Canonical document** — Design intent. This file declares what tests MUST exist.
4
9
  > Last updated: YYYY-MM-DD
5
10