docguard-cli 0.21.1 → 0.22.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
@@ -3,7 +3,9 @@
3
3
  > **The enforcement layer for Spec-Driven Development.**
4
4
  > Validate. Score. Enforce. Ship documentation that AI agents can actually use.
5
5
 
6
+ [![CI](https://github.com/raccioly/docguard/actions/workflows/ci.yml/badge.svg)](https://github.com/raccioly/docguard/actions/workflows/ci.yml)
6
7
  [![npm](https://img.shields.io/npm/v/docguard-cli)](https://www.npmjs.com/package/docguard-cli)
8
+ [![npm downloads](https://img.shields.io/npm/dw/docguard-cli)](https://www.npmjs.com/package/docguard-cli)
7
9
  [![PyPI](https://img.shields.io/pypi/v/docguard-cli)](https://pypi.org/project/docguard-cli/)
8
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
11
  [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green)](https://nodejs.org)
@@ -18,6 +20,8 @@
18
20
  > ```
19
21
  > Runs against a baked-in sample project with intentional drift and shows you the findings + a clear path to fixing them.
20
22
 
23
+ ![DocGuard demo](assets/demo.gif)
24
+
21
25
  ---
22
26
 
23
27
  ## Table of Contents
@@ -67,7 +71,7 @@ graph TD
67
71
  Commands --> setup["setup wizard"]
68
72
  Commands --> other["diff · init · fix · trace · impact · sync<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
69
73
 
70
- guard --> Validators["Validators (23)"]
74
+ guard --> Validators["Validators (24)"]
71
75
  generate --> Scanners["Scanners (4)<br/>routes · schemas · doc-tools · speckit"]
72
76
  score --> Scoring["Weighted Scoring<br/>8 categories"]
73
77
  diagnose --> Validators
@@ -95,7 +99,7 @@ graph TD
95
99
  Recent highlights across the v0.16 → v0.19 line:
96
100
 
97
101
  - **`docguard explain <validator>`** — `docguard explain freshness` prints purpose, rules, common
98
- failures, and fix recipes for any of the 23 validators. No need to dig into source.
102
+ failures, and fix recipes for any of the 24 validators. No need to dig into source.
99
103
  - **`docguard memory --diff`** — surface what changed in your canonical docs between two refs
100
104
  (`HEAD~10..HEAD` by default). Great for code review and changelog drafting.
101
105
  - **`docguard score --diff`** — see exactly which validators moved the score up or down between
@@ -237,7 +241,7 @@ DocGuard is a [community extension](https://github.com/github/spec-kit/blob/main
237
241
  specify extension add docguard
238
242
  ```
239
243
 
240
- This installs DocGuard's slash commands (`/docguard.guard`, `/docguard.review`, `/docguard.fix`, `/docguard.score`) into your AI agent's command palette.
244
+ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/docguard.review`, `/docguard.fix`, `/docguard.update`) into your AI agent's command palette.
241
245
 
242
246
  ---
243
247
 
@@ -250,7 +254,7 @@ DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including t
250
254
  | Command | What It Does |
251
255
  |:--------|:-------------|
252
256
  | `init` | Bootstrap a project (`--wizard` for interactive · `--with <name>` for scaffolders) |
253
- | `guard` | Validate against canonical docs — 23 validators |
257
+ | `guard` | Validate against canonical docs — 24 validators |
254
258
  | `diff` | Show gaps between docs and code (`--since <ref>` for impact mode) |
255
259
  | `sync` | Refresh code-truth doc sections — keeps memory always up to date |
256
260
  | `score` | CDD maturity score (0-100; `--diff` for delta between refs) |
@@ -259,6 +263,7 @@ DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including t
259
263
 
260
264
  | Command | Purpose |
261
265
  |:--------|:--------|
266
+ | `demo` | Zero-install showcase — runs guard against a baked-in drifting fixture (`npx docguard-cli demo`) |
262
267
  | `diagnose` | AI orchestrator — guard → emit fix prompts in one command |
263
268
  | `fix` | Generate AI fix instructions for specific docs (`--doc <name> --format prompt`) |
264
269
  | `fix --write` | Apply deterministic fixes (no AI — version bumps, counts, anchors, sections) |
@@ -343,7 +348,7 @@ $ npx docguard-cli generate
343
348
 
344
349
  ## 🔍 Validators
345
350
 
346
- DocGuard runs **23 automated validators** on every `guard` check. Every one is **language-aware** as of v0.16 — patterns for Python (`test_*.py`), Rust (`tests/*.rs`), Go (`*_test.go`), Java (`*Test.java`), Ruby (`*_spec.rb`), PHP, and JS/TS all match.
351
+ DocGuard runs **24 automated validators** on every `guard` check. Every one is **language-aware** as of v0.16 — patterns for Python (`test_*.py`), Rust (`tests/*.rs`), Go (`*_test.go`), Java (`*Test.java`), Ruby (`*_spec.rb`), PHP, and JS/TS all match.
347
352
 
348
353
  | # | Validator | What It Checks | Default |
349
354
  |:--|:----------|:--------------|:--------|
@@ -370,6 +375,7 @@ DocGuard runs **23 automated validators** on every `guard` check. Every one is *
370
375
  | 21 | **Generated-Staleness** | `source=code` sections match scanner output; `status: draft` doc age | ✅ On |
371
376
  | 22 | **Canonical-Sync** | DocGuard's own README count claims match code-truth (DocGuard repo only — N/A elsewhere) | ✅ On |
372
377
  | 23 | **Metrics-Consistency** | Hardcoded numbers match actual counts | ✅ On |
378
+ | 24 | **Surface-Sync** | Item-level enumerable drift — names in doc tables/lists (commands, validators, etc.) match code-truth (opt-in via `surfaceSync.surfaces`; N/A unless configured) | ✅ On |
373
379
 
374
380
  **Per-validator controls** (in `.docguard.json`):
375
381
  ```json
@@ -437,10 +443,11 @@ DocGuard provides AI agent slash commands for integrated workflows. Installed au
437
443
 
438
444
  | Command | What It Does |
439
445
  |:--------|:-------------|
440
- | `/docguard.guard` | Run quality validation check all 23 validators |
446
+ | `/docguard.init` | Initialize Canonical-Driven Development in a new or existing project |
447
+ | `/docguard.guard` | Run quality validation — check all 24 validators |
441
448
  | `/docguard.review` | Analyze doc quality and suggest improvements |
442
449
  | `/docguard.fix` | Generate targeted fix prompts for specific issues |
443
- | `/docguard.score` | Show CDD maturity score with category breakdown |
450
+ | `/docguard.update` | Update canonical docs after code changes detect drift and sync documentation |
444
451
 
445
452
  These commands are installed into your AI agent's command directory:
446
453
 
@@ -657,6 +664,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#research--academic-credits) for full citat
657
664
 
658
665
  ---
659
666
 
667
+ ## ⭐ Star History
668
+
669
+ [![Star History Chart](https://api.star-history.com/svg?repos=raccioly/docguard&type=Date)](https://star-history.com/#raccioly/docguard&Date)
670
+
671
+ ---
672
+
660
673
  ## 📄 License
661
674
 
662
675
  [MIT](LICENSE) — Free to use, modify, and distribute.
@@ -150,6 +150,17 @@ const EXPLAINERS = {
150
150
  example: 'AGENTS.md says "22 validators" and `docguard guard` shows 22 active validators',
151
151
  standard: 'CDD principle: documented metrics match reality',
152
152
  },
153
+ surfaceSync: {
154
+ title: 'Surface-Sync — every code-derived list entry is documented',
155
+ what: 'Item-level check (complementing canonical-sync\'s count-level check). For each configured surface (e.g. commands → `cli/commands/*.mjs`), compares the discovered files against the names appearing in table rows / bullet lists in target docs (README.md, AGENTS.md). Warns when a code item is missing from the doc, or a doc item is missing from code.',
156
+ why: 'Counts can match while lists drift — README claimed "14 commands" while the table only listed 13 (demo was missing). Count validators celebrated; users hit "command not found" anyway. This check catches that case.',
157
+ triggers: [
158
+ ['Surface "X" drift: N in code but missing from README.md', 'Add the missing items to the relevant table / bullet list in the target doc. Or, if intentional (deprecation alias, scaffolder behind --with), add the names to the surface\'s `ignore` list in .docguard.json.'],
159
+ ['Surface "X" drift: N listed in README.md but not found in code', 'A documented item no longer exists in code. Either remove it from the doc (it was deleted) or restore the file/command (it was deleted by mistake).'],
160
+ ],
161
+ example: '`cli/commands/demo.mjs` exists and `| `demo` | Zero-install preview |` appears in README.md\'s commands table',
162
+ standard: 'CDD principle: documented surfaces match implemented surfaces',
163
+ },
153
164
  crossReference: {
154
165
  title: 'Cross-Reference — internal markdown links resolve',
155
166
  what: 'Scans canonical docs for `[text](./OTHER.md#anchor)` and `#anchor` links. Verifies the target file exists and the anchor matches a heading.',
@@ -141,6 +141,7 @@ import { validateTodoTracking } from '../validators/todo-tracking.mjs';
141
141
  import { validateSchemaSync } from '../validators/schema-sync.mjs';
142
142
  import { validateSpecKitIntegration } from '../validators/spec-kit.mjs';
143
143
  import { validateCanonicalSync } from '../validators/canonical-sync.mjs';
144
+ import { validateSurfaceSync } from '../validators/surface-sync.mjs';
144
145
 
145
146
  /**
146
147
  * Internal guard — returns structured data, no console output, no process.exit.
@@ -215,6 +216,7 @@ export function runGuardInternal(projectDir, config) {
215
216
  { key: 'specKit', name: 'Spec-Kit', fn: () => validateSpecKitIntegration(projectDir, config) },
216
217
  { key: 'crossReference', name: 'Cross-Reference', fn: () => validateCrossReferences(projectDir, config) },
217
218
  { key: 'generatedStaleness', name: 'Generated-Staleness', fn: () => validateGeneratedStaleness(projectDir, config) },
219
+ { key: 'surfaceSync', name: 'Surface-Sync', fn: () => validateSurfaceSync(projectDir, config) },
218
220
  // Metrics-Consistency runs post-loop (needs guard results)
219
221
  ];
220
222
 
@@ -84,8 +84,18 @@ export function runMemory(projectDir, config, flags) {
84
84
  // ── Text output ──
85
85
  console.log(`${c.bold}🧠 DocGuard Memory${c.reset} ${c.dim}— ${config.projectName}${c.reset}\n`);
86
86
 
87
+ // Label is `Claim match rate` (not `Accuracy`) to disambiguate from the
88
+ // score-level `Accuracy` metric in `docguard score`, which is a weighted
89
+ // average across the entire accuracy axis (apiSurface, environment, drift,
90
+ // metadata-sync, etc.) and uses a different denominator. Both numbers are
91
+ // legitimate, but the same word was pointing at two different calculations
92
+ // — users saw "Accuracy 0%" here and "Accuracy 93%" in `score` on the same
93
+ // project and reasonably read it as a contradiction. This metric is the
94
+ // strict per-claim ratio (matched claims / total checked claims, restricted
95
+ // to domains that actually have any claims).
87
96
  const accColor = overallAccuracy >= 90 ? c.green : overallAccuracy >= 70 ? c.yellow : c.red;
88
- console.log(` ${c.bold}Accuracy:${c.reset} ${accColor}${overallAccuracy}%${c.reset} ${c.dim}(${totalMatched}/${totalChecks} doc claims match code)${c.reset}\n`);
97
+ console.log(` ${c.bold}Claim match rate:${c.reset} ${accColor}${overallAccuracy}%${c.reset} ${c.dim}(${totalMatched}/${totalChecks} doc claims match code)${c.reset}`);
98
+ console.log(` ${c.dim} ↳ See ${c.cyan}docguard score${c.dim} for the weighted accuracy axis across all domains.${c.reset}\n`);
89
99
 
90
100
  if (totalChecks === 0) {
91
101
  console.log(` ${c.dim}No applicable domains found — add canonical docs (API-REFERENCE.md, DATA-MODEL.md, ENVIRONMENT.md) and rerun.${c.reset}`);
@@ -274,6 +274,18 @@ export async function runUpgrade(projectDir, _config, flags) {
274
274
  process.exit(1);
275
275
  }
276
276
  console.log(` ${c.green}✓ CLI upgraded.${c.reset}`);
277
+ // Validator-count drift: a CLI upgrade often adds or removes validators,
278
+ // which changes the "N validators" / "N checks" totals that Metrics-
279
+ // Consistency scans for in markdown. Without a clear nudge here, a
280
+ // previously-green project goes red on the very next `docguard guard`
281
+ // for a reason the user did not cause. The fix is mechanical (replace
282
+ // hardcoded counts via fix --write) but the user has to know to run it.
283
+ // We don't auto-run fix --write from this process — the just-installed
284
+ // binary has the new validator list, but THIS process is still the OLD
285
+ // binary, so running fix here would use stale counts.
286
+ console.log(` ${c.yellow}ℹ Validator/check totals may have shifted with this upgrade.${c.reset}`);
287
+ console.log(` Run ${c.cyan}docguard fix --write${c.reset} ${c.dim}to refresh hardcoded counts in your docs${c.reset}`);
288
+ console.log(` ${c.dim}(picks up the new totals from the just-installed CLI).${c.reset}`);
277
289
  }
278
290
 
279
291
  if (schemaBehind && projectSchema) {
package/cli/docguard.mjs CHANGED
@@ -230,7 +230,7 @@ const _KNOWN_VALIDATORS = [
230
230
  'security', 'architecture', 'freshness', 'traceability', 'docsDiff',
231
231
  'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
232
232
  'schemaSync', 'specKit', 'crossReference', 'generatedStaleness',
233
- 'metricsConsistency',
233
+ 'canonicalSync', 'surfaceSync', 'metricsConsistency',
234
234
  ];
235
235
 
236
236
  function _kebabToCamel(k) {
@@ -108,8 +108,14 @@ function scanNextJsRoutes(dir) {
108
108
  const content = readFileSafe(filePath);
109
109
  if (!content) return;
110
110
 
111
- // Path from directory structure
112
- const relDir = relative(resolve(dir, appDir.split('/')[0]), dirname(filePath));
111
+ // Path from directory structure. The HTTP base in Next.js App Router is
112
+ // `/api/...` Next strips everything up to and including the `app/`
113
+ // segment. Compute the relative path from the directory ABOVE `api/`
114
+ // so both `app/api` (no src layout) and `src/app/api` (src layout)
115
+ // produce `/api/<segments>`. Previously `appDir.split('/')[0]` stripped
116
+ // only `src/` for the src layout, leaking `app/` into the emitted path.
117
+ const apiBase = appDir.slice(0, appDir.lastIndexOf('/'));
118
+ const relDir = relative(resolve(dir, apiBase), dirname(filePath));
113
119
  const apiPath = '/' + relDir
114
120
  .replace(/\\/g, '/')
115
121
  .replace(/\[\.\.\.(\w+)\]/g, ':$1*') // Catch-all [...slug]
@@ -217,6 +217,15 @@ export function grepEnvUsage(projectDir, config = {}) {
217
217
  new RegExp(`process\\.env\\.${NAME}`, 'g'),
218
218
  new RegExp(`process\\.env\\[\\s*['"]${NAME}['"]\\s*\\]`, 'g'),
219
219
  new RegExp(`import\\.meta\\.env\\.${NAME}`, 'g'),
220
+ // Python: `os.environ["X"]`, `os.environ.get("X")`, `os.getenv("X")`. The
221
+ // `explain` command and ENVIRONMENT.md templates have always told users
222
+ // these forms are scanned, but the implementation only handled JS. On a
223
+ // Python project this caused every documented env var to be reported as
224
+ // "in docs, not in code" — a silent 0% accuracy. Patterns cover bracket
225
+ // access, .get(), and the standalone os.getenv() function.
226
+ new RegExp(`os\\.environ\\[\\s*['"]${NAME}['"]\\s*\\]`, 'g'),
227
+ new RegExp(`os\\.environ\\.get\\s*\\(\\s*['"]${NAME}['"]`, 'g'),
228
+ new RegExp(`os\\.getenv\\s*\\(\\s*['"]${NAME}['"]`, 'g'),
220
229
  ];
221
230
  // Vite injects these at build time; they are not user-set env vars.
222
231
  const VITE_INTRINSICS = new Set(['DEV', 'PROD', 'MODE', 'BASE_URL', 'SSR']);
@@ -42,6 +42,17 @@ export function validateDocsDiff(projectDir, config) {
42
42
  diffTests(projectDir, config),
43
43
  ];
44
44
 
45
+ // Limit how many offending names are inlined in a single warning — keeps
46
+ // the line readable on terminals while still naming the specific files so
47
+ // the warning is actually actionable. Without these names the user gets a
48
+ // bare count ("1 documented but not found in code") with no path to debug.
49
+ const MAX_INLINE = 5;
50
+ const fmtList = (arr) => {
51
+ const shown = arr.slice(0, MAX_INLINE).map(v => `\`${v}\``).join(', ');
52
+ const extra = arr.length - MAX_INLINE;
53
+ return extra > 0 ? `${shown} (+${extra} more)` : shown;
54
+ };
55
+
45
56
  for (const result of checks) {
46
57
  if (!result) continue;
47
58
 
@@ -53,9 +64,13 @@ export function validateDocsDiff(projectDir, config) {
53
64
  passed++;
54
65
  } else {
55
66
  const parts = [];
56
- if (undocumented > 0) parts.push(`${undocumented} in code but not documented`);
57
- if (stale > 0) parts.push(`${stale} documented but not found in code`);
58
- warnings.push(`${result.title} drift: ${parts.join(', ')}`);
67
+ if (undocumented > 0) {
68
+ parts.push(`${undocumented} in code but not documented: ${fmtList(result.onlyInCode)}`);
69
+ }
70
+ if (stale > 0) {
71
+ parts.push(`${stale} documented but not found in code: ${fmtList(result.onlyInDocs)}`);
72
+ }
73
+ warnings.push(`${result.title} drift: ${parts.join('; ')}`);
59
74
  }
60
75
  }
61
76
 
@@ -64,6 +64,19 @@ export function validateEnvironment(projectDir, config) {
64
64
  if (SYSTEM.has(m[1])) continue; // v0.16-P4: prose mentions of system vars are not docs
65
65
  documented.add(m[1]);
66
66
  }
67
+ // Also extract markdown table rows where the first column is a bare env
68
+ // var name (no backticks). Real-world ENVIRONMENT.md docs frequently use
69
+ // pipe tables with un-backticked names — the backtick-only regex above
70
+ // silently misses every suffixed variant (DYNAMODB_TABLE_JOBS,
71
+ // DYNAMODB_TABLE_SOURCES, …) and they get reported as undocumented.
72
+ // Match `| VAR_NAME |` anywhere on the line; require the row to also
73
+ // contain a second `|` (real table row), not a stray pipe in prose.
74
+ const tableRe = /^\s*\|\s*([A-Z][A-Z0-9_]*[A-Z0-9])\s*\|/gm;
75
+ while ((m = tableRe.exec(content)) !== null) {
76
+ if (m[1].length < 3) continue;
77
+ if (SYSTEM.has(m[1])) continue;
78
+ documented.add(m[1]);
79
+ }
67
80
  for (const envFile of ['.env.example', '.env.template']) {
68
81
  const p = resolve(projectDir, envFile);
69
82
  if (!existsSync(p)) continue;
@@ -6,7 +6,7 @@
6
6
  * but the code has already been implemented and committed.
7
7
  */
8
8
 
9
- import { existsSync, readdirSync, statSync } from 'node:fs';
9
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
10
10
  import { resolve, join, extname } from 'node:path';
11
11
  import { execSync, execFileSync } from 'node:child_process';
12
12
 
@@ -34,6 +34,26 @@ const IGNORE_DIRS = new Set([
34
34
  'templates', 'configs', 'Research',
35
35
  ]);
36
36
 
37
+ /**
38
+ * Read the `<!-- docguard:last-reviewed YYYY-MM-DD -->` header from a doc file.
39
+ * Returns the parsed Date when present, null otherwise (file missing, header
40
+ * absent, or date unparseable). The header is the authoritative review date
41
+ * — it represents an explicit human review action that `git log` cannot see
42
+ * (e.g., the reviewer read the file, confirmed it still matches reality, and
43
+ * stamped the header without touching content, so there is no commit to find).
44
+ */
45
+ function readLastReviewedDate(absPath) {
46
+ try {
47
+ const content = readFileSync(absPath, 'utf-8');
48
+ const m = content.match(/<!--\s*docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})\s*-->/);
49
+ if (!m) return null;
50
+ const d = new Date(m[1] + 'T00:00:00Z');
51
+ return isNaN(d.getTime()) ? null : d;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
37
57
  /**
38
58
  * Get the last git commit date for a file.
39
59
  * Returns null if the file isn't tracked or git isn't available.
@@ -170,7 +190,15 @@ export function validateFreshness(dir, config) {
170
190
  const docPath = resolve(dir, docFile);
171
191
  if (!existsSync(docPath)) continue;
172
192
 
173
- const docDate = getLastGitDate(docFile, dir);
193
+ // Prefer the explicit `<!-- docguard:last-reviewed YYYY-MM-DD -->` header
194
+ // over the git commit date. A reviewer who reads a doc and stamps the
195
+ // header without changing content has signaled "I confirmed this is still
196
+ // current" — git log cannot see that signal, so it would falsely flag the
197
+ // doc as stale despite the explicit review. Fall back to git log only when
198
+ // the header is absent. Symmetric with the ALCOA+ "review metadata present"
199
+ // check in score.mjs, which reads the same header.
200
+ const reviewedDate = readLastReviewedDate(docPath);
201
+ const docDate = reviewedDate || getLastGitDate(docFile, dir);
174
202
  if (!docDate) {
175
203
  // File exists but isn't tracked in git yet
176
204
  results.push({
@@ -212,7 +240,9 @@ export function validateFreshness(dir, config) {
212
240
  // ── 2. Check CHANGELOG.md was updated in the last 5 code commits ──
213
241
  const changelogPath = resolve(dir, config.requiredFiles?.changelog || 'CHANGELOG.md');
214
242
  if (existsSync(changelogPath)) {
215
- const changelogDate = getLastGitDate(config.requiredFiles?.changelog || 'CHANGELOG.md', dir);
243
+ const changelogDate =
244
+ readLastReviewedDate(changelogPath) ||
245
+ getLastGitDate(config.requiredFiles?.changelog || 'CHANGELOG.md', dir);
216
246
  if (changelogDate && latestCodeDate) {
217
247
  const daysDiff = Math.floor((latestCodeDate - changelogDate) / (1000 * 60 * 60 * 24));
218
248
  if (daysDiff > 7) {
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Surface-Sync Validator — item-level drift detection for enumerable surfaces.
3
+ *
4
+ * Complements canonical-sync (which checks NUMERIC count claims) by checking
5
+ * that each individual ITEM in a code-derived list is actually documented as
6
+ * a list entry in the target markdown file(s). Catches the "demo command
7
+ * exists in code, the count matches, but it's missing from the README's
8
+ * command table" class of drift — invisible to count-based checks.
9
+ *
10
+ * Why this exists: canonical-sync was passing 3/3 on a docguard repo whose
11
+ * README's command table omitted `demo` entirely. The count matched ("14
12
+ * commands" = 14 user-facing commands in --help) but the table only listed
13
+ * 13 of them. Without an item-level check, a doc can be silently wrong while
14
+ * every count-based validator celebrates. That's exactly the credibility hit
15
+ * the user reported.
16
+ *
17
+ * How it works: for each configured surface (commands, validators, slash
18
+ * commands, templates, anything enumerable), we:
19
+ * 1. Discover the code-truth set from a glob pattern + basename extractor
20
+ * 2. For each target doc, parse all markdown tables and bullet lists,
21
+ * collecting backticked tokens that look like identifiers from the
22
+ * FIRST column / item position only (not arbitrary prose mentions)
23
+ * 3. Diff the two sets and warn on items present in code but absent from
24
+ * the doc, OR present in the doc but missing from code (likely
25
+ * removed / renamed)
26
+ *
27
+ * Scoped to list contexts on purpose: scanning every backtick would produce
28
+ * false positives every time someone wrote `--verbose` or `process.env.X`
29
+ * in prose. A table row or bullet item is a deliberate inventory signal —
30
+ * docguard treats those as the doc's authoritative list, ignores the rest.
31
+ *
32
+ * Default: N/A. The validator is OFF unless the project's .docguard.json
33
+ * declares at least one surface under `validators.surfaceSync.surfaces`.
34
+ * This keeps the upgrade path safe for projects that don't opt in.
35
+ *
36
+ * Config shape (in .docguard.json):
37
+ * {
38
+ * "validators": { "surfaceSync": true },
39
+ * "surfaceSync": {
40
+ * "surfaces": [
41
+ * {
42
+ * "name": "commands",
43
+ * "glob": "cli/commands/*.mjs",
44
+ * "extract": "basename-no-ext", // "basename" or "basename-no-ext"
45
+ * "ignore": ["setup", "impact"], // known aliases / non-public items
46
+ * "docs": ["README.md"]
47
+ * }
48
+ * ]
49
+ * }
50
+ * }
51
+ *
52
+ * Severity: MEDIUM. Wrong-list drift is real but rarely a build-breaker —
53
+ * users can still read the doc and discover the missing surface via --help.
54
+ * Demoting to LOW makes drift invisible; promoting to HIGH would conflict
55
+ * with the existing reality that many projects ship slightly-stale READMEs.
56
+ *
57
+ * Zero NPM runtime dependencies — pure Node.js built-ins only.
58
+ */
59
+
60
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
61
+ import { resolve, join, basename, extname, relative, dirname } from 'node:path';
62
+
63
+ /**
64
+ * Expand a simple glob (only supports `*` at the leaf segment level —
65
+ * sufficient for `cli/commands/*.mjs`, `templates/*.md.template`, etc.).
66
+ * Recursive `**` is NOT supported by design — surface lists live in
67
+ * known shallow directories; deep recursion would be a configuration
68
+ * smell, not a feature.
69
+ */
70
+ function expandGlob(projectDir, glob) {
71
+ const slash = glob.lastIndexOf('/');
72
+ const dir = slash >= 0 ? glob.slice(0, slash) : '.';
73
+ const pattern = slash >= 0 ? glob.slice(slash + 1) : glob;
74
+ const absDir = resolve(projectDir, dir);
75
+ if (!existsSync(absDir)) return [];
76
+
77
+ // Convert glob to regex: escape regex specials, then `*` → `.*`
78
+ const rx = new RegExp('^' + pattern
79
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
80
+ .replace(/\*/g, '.*') + '$');
81
+
82
+ try {
83
+ return readdirSync(absDir)
84
+ .filter(name => rx.test(name))
85
+ .map(name => join(absDir, name))
86
+ .filter(full => {
87
+ try { return statSync(full).isFile(); } catch { return false; }
88
+ });
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Extract the surface name from a file path according to the configured rule.
96
+ * `basename` → keep the full filename: `guard.mjs`
97
+ * `basename-no-ext` → strip the single trailing extension: `guard`
98
+ *
99
+ * `basename-no-ext` strips ONLY the last extension on purpose. For files like
100
+ * `ARCHITECTURE.md.template`, `basename-no-ext` returns `ARCHITECTURE.md` —
101
+ * which is the canonical doc name the template generates. If the user wants
102
+ * the bare `ARCHITECTURE`, they can use a custom extractor (not yet supported).
103
+ */
104
+ function applyExtractor(filePath, extractor) {
105
+ const base = basename(filePath);
106
+ if (extractor === 'basename') return base;
107
+ if (extractor === 'basename-no-ext') {
108
+ const ext = extname(base);
109
+ return ext ? base.slice(0, -ext.length) : base;
110
+ }
111
+ // Unknown extractor → fall back to bare basename. Don't throw — a future
112
+ // CLI version might introduce more extractors, and we want old configs to
113
+ // degrade gracefully rather than crash a guard run.
114
+ return base;
115
+ }
116
+
117
+ /**
118
+ * Slice a markdown document down to a single section, identified by a heading
119
+ * substring match (case-insensitive). The slice runs from that heading to the
120
+ * next heading of equal or higher level (so a `##` section ends at the next
121
+ * `##` or `#`, but `###` subsections are included).
122
+ *
123
+ * Returns the original `content` if `heading` is falsy. Returns an empty string
124
+ * if the heading isn't found — callers treat that as "section absent, no
125
+ * documented tokens here," which surfaces as a "missing-from-doc" warning for
126
+ * every code item (correctly: the section the user said to scope to does not
127
+ * exist in this doc).
128
+ */
129
+ function sliceSection(content, heading) {
130
+ if (!heading) return content;
131
+ const lines = content.split('\n');
132
+ const needle = String(heading).toLowerCase().replace(/^#+\s*/, '').trim();
133
+ if (!needle) return content;
134
+
135
+ // Find the start: any heading line whose text (after stripping `#`s) contains
136
+ // the configured heading. Substring match keeps the config tolerant of
137
+ // emoji prefixes and trailing decorations in the actual document.
138
+ let startIdx = -1;
139
+ let startLevel = 0;
140
+ for (let i = 0; i < lines.length; i++) {
141
+ const m = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
142
+ if (!m) continue;
143
+ const headingText = m[2].toLowerCase();
144
+ if (headingText.includes(needle)) {
145
+ startIdx = i;
146
+ startLevel = m[1].length;
147
+ break;
148
+ }
149
+ }
150
+ if (startIdx === -1) return '';
151
+
152
+ // Find the end: next heading of <= startLevel depth.
153
+ let endIdx = lines.length;
154
+ for (let i = startIdx + 1; i < lines.length; i++) {
155
+ const m = lines[i].match(/^(#{1,6})\s+/);
156
+ if (m && m[1].length <= startLevel) {
157
+ endIdx = i;
158
+ break;
159
+ }
160
+ }
161
+ return lines.slice(startIdx, endIdx).join('\n');
162
+ }
163
+
164
+ /**
165
+ * Parse a markdown file (or a slice of one) and return the set of tokens
166
+ * found in "list contexts" — table rows (first column) and bullet items.
167
+ * Backtick-wrapped only. This deliberately ignores backticks in prose,
168
+ * code blocks, and headings.
169
+ *
170
+ * Code blocks (```...```) are stripped first so that a fenced shell example
171
+ * like `npx docguard-cli demo` cannot inflate the documented set with the
172
+ * `demo` token. Tables and bullets remain the only authoritative inventory.
173
+ */
174
+ function extractDocumentedTokens(content) {
175
+ const tokens = new Set();
176
+ // Strip fenced code blocks — shell examples, json snippets, mermaid blocks,
177
+ // and any inline reference inside them is NOT a list entry.
178
+ const stripped = content.replace(/```[\s\S]*?```/g, '');
179
+
180
+ // Normalize a captured token. Strips a leading `docguard ` or `/` (common
181
+ // doc conventions for command tables and slash-command lists), reduces
182
+ // multi-word forms to the first token, and lower-cases — downstream
183
+ // comparison is case-insensitive so README's `**API-Surface**` matches
184
+ // code-truth `api-surface`.
185
+ const normalize = (raw) => {
186
+ if (!raw) return '';
187
+ const cleaned = String(raw).trim()
188
+ .replace(/^docguard\s+/i, '')
189
+ .replace(/^\//, '');
190
+ const firstToken = cleaned.split(/\s+/)[0];
191
+ return firstToken.toLowerCase();
192
+ };
193
+
194
+ // Pattern A: backticked token in a list context.
195
+ // - Start of a table row: `| `name` | … |`
196
+ // - Numbered-cell table row: `| 1 | `name` | … |`
197
+ // - Bullet list item: `- `name` …`
198
+ // Restricting to these contexts keeps prose backticks out of the
199
+ // documented set; a backticked `--verbose` mid-paragraph is not a list
200
+ // entry and must not inflate the set.
201
+ const backtickListRe =
202
+ /(?:^\s*\|\s*(?:\d+\s*\|\s*)?|^\s*[-*+]\s+)`([^`\n]+)`/gim;
203
+ let m;
204
+ while ((m = backtickListRe.exec(stripped)) !== null) {
205
+ const t = normalize(m[1]);
206
+ if (t) tokens.add(t);
207
+ }
208
+
209
+ // Pattern B: bolded token in a table row. Matches the validators-style
210
+ // tables that use `| N | **Name** | description |` — backticks alone
211
+ // miss every entry in those tables. Restricted to lines starting with
212
+ // `|` so prose-level **bold** is not pulled in.
213
+ const boldRowRe = /^\s*\|.*?\*\*([^*\n]+)\*\*/gim;
214
+ while ((m = boldRowRe.exec(stripped)) !== null) {
215
+ const t = normalize(m[1]);
216
+ if (t) tokens.add(t);
217
+ }
218
+
219
+ return tokens;
220
+ }
221
+
222
+ /**
223
+ * Validate surface drift for a single surface against its target docs.
224
+ * Returns warnings, fixes, passed count, and total count.
225
+ */
226
+ function checkSurface(projectDir, surface) {
227
+ const out = { warnings: [], fixes: [], passed: 0, total: 0 };
228
+ const name = surface.name || 'unnamed';
229
+ const extractor = surface.extract || 'basename-no-ext';
230
+ const ignore = new Set(surface.ignore || []);
231
+ const docs = Array.isArray(surface.docs) && surface.docs.length > 0
232
+ ? surface.docs
233
+ : ['README.md'];
234
+
235
+ // Discover code-truth set from glob.
236
+ if (!surface.glob || typeof surface.glob !== 'string') {
237
+ out.warnings.push(
238
+ `surfaceSync: surface "${name}" has no \`glob\` — skipping. Add a glob like "cli/commands/*.mjs".`
239
+ );
240
+ return out;
241
+ }
242
+ const files = expandGlob(projectDir, surface.glob);
243
+ // Lower-case code-truth to keep comparison case-insensitive — README
244
+ // commonly uses display-cased identifiers (`**API-Surface**`, `Freshness`)
245
+ // while file basenames are lowercase (`api-surface.mjs`). The `ignore`
246
+ // list is matched against the case-folded form, so users can write
247
+ // either `["Setup"]` or `["setup"]` and it works.
248
+ const ignoreLower = new Set([...ignore].map(s => String(s).toLowerCase()));
249
+ const codeTruth = new Set(
250
+ files
251
+ .map(f => applyExtractor(f, extractor).toLowerCase())
252
+ .filter(token => !ignoreLower.has(token))
253
+ );
254
+ if (codeTruth.size === 0) {
255
+ // No items discovered — surface is N/A for this project. Don't warn
256
+ // (the user has explicitly enabled the surface, but the glob found
257
+ // nothing — maybe they renamed a directory; surface up the info but
258
+ // do NOT escalate into a check failure).
259
+ return out;
260
+ }
261
+
262
+ // For each target doc, compute documented set and diff.
263
+ for (const docRel of docs) {
264
+ const docPath = resolve(projectDir, docRel);
265
+ if (!existsSync(docPath)) {
266
+ // Doc doesn't exist — silently skip; another validator (structure)
267
+ // covers missing-doc cases.
268
+ continue;
269
+ }
270
+
271
+ let content;
272
+ try {
273
+ content = readFileSync(docPath, 'utf-8');
274
+ } catch {
275
+ continue;
276
+ }
277
+
278
+ // Optional section scope: restrict scanning to a single heading's body.
279
+ // Without this, every backticked first-column token in the doc is
280
+ // pooled into one "documented" set — and a commands surface ends up
281
+ // matching against the validators table too, producing cross-table
282
+ // false positives. Section-scoping is the user's per-surface override
283
+ // when one doc lists multiple surfaces.
284
+ const scope = surface.section
285
+ ? sliceSection(content, surface.section)
286
+ : content;
287
+
288
+ const documented = extractDocumentedTokens(scope);
289
+ out.total++;
290
+
291
+ const missingFromDoc = [...codeTruth].filter(t => !documented.has(t));
292
+ // missingFromCode tells you about items the doc lists that don't exist
293
+ // in code — usually removed or renamed surfaces. Filter through `ignore`
294
+ // too so deprecation aliases listed in the doc don't fire warnings.
295
+ const missingFromCode = [...documented]
296
+ .filter(t => !codeTruth.has(t) && !ignoreLower.has(t))
297
+ // Only consider tokens that match the surface naming convention to
298
+ // avoid pulling in unrelated backticked items from a different table
299
+ // that happens to be in the same doc.
300
+ .filter(t => surfaceNameLooksValid(t));
301
+
302
+ if (missingFromDoc.length === 0 && missingFromCode.length === 0) {
303
+ out.passed++;
304
+ continue;
305
+ }
306
+
307
+ const parts = [];
308
+ if (missingFromDoc.length > 0) {
309
+ const shown = missingFromDoc.slice(0, 8).map(t => `\`${t}\``).join(', ');
310
+ const extra = missingFromDoc.length - 8;
311
+ const tail = extra > 0 ? ` (+${extra} more)` : '';
312
+ parts.push(`${missingFromDoc.length} in code but missing from ${docRel}: ${shown}${tail}`);
313
+ }
314
+ if (missingFromCode.length > 0) {
315
+ const shown = missingFromCode.slice(0, 8).map(t => `\`${t}\``).join(', ');
316
+ const extra = missingFromCode.length - 8;
317
+ const tail = extra > 0 ? ` (+${extra} more)` : '';
318
+ parts.push(`${missingFromCode.length} listed in ${docRel} but not found in code: ${shown}${tail}`);
319
+ }
320
+ out.warnings.push(`Surface "${name}" drift: ${parts.join('; ')}`);
321
+ }
322
+
323
+ return out;
324
+ }
325
+
326
+ /**
327
+ * Loose sanity filter for tokens before reporting them as "missing from code".
328
+ * Real surface names are short identifiers; if a regex catches something
329
+ * that looks like prose, drop it silently.
330
+ */
331
+ function surfaceNameLooksValid(t) {
332
+ return typeof t === 'string'
333
+ && t.length >= 2
334
+ && t.length <= 64
335
+ && /^[a-z][a-z0-9_.-]*$/i.test(t);
336
+ }
337
+
338
+ /**
339
+ * Public entry point. Returns N/A when no surfaces are configured — by
340
+ * design, this validator is opt-in. Projects that don't declare surfaces
341
+ * see zero noise.
342
+ */
343
+ export function validateSurfaceSync(projectDir, config) {
344
+ const surfaceCfg = (config && config.surfaceSync && Array.isArray(config.surfaceSync.surfaces))
345
+ ? config.surfaceSync.surfaces
346
+ : [];
347
+
348
+ const result = { errors: [], warnings: [], fixes: [], passed: 0, total: 0 };
349
+
350
+ if (surfaceCfg.length === 0) {
351
+ // No surfaces configured → N/A. The validator infrastructure surfaces
352
+ // this as "nothing to validate" rather than a fail.
353
+ return result;
354
+ }
355
+
356
+ for (const surface of surfaceCfg) {
357
+ const r = checkSurface(projectDir, surface);
358
+ result.warnings.push(...r.warnings);
359
+ result.fixes.push(...r.fixes);
360
+ result.passed += r.passed;
361
+ result.total += r.total;
362
+ }
363
+
364
+ return result;
365
+ }
@@ -64,6 +64,10 @@ const TRACE_MAP = {
64
64
  'API-REFERENCE.md': {
65
65
  sourcePatterns: [
66
66
  { label: 'Route handlers', glob: /(routes?|controllers?|handlers?)\// },
67
+ // Next.js App Router (and Pages Router) — `app/api/`, `src/app/api/`,
68
+ // `pages/api/`, `src/pages/api/`. Without this, a perfectly-populated
69
+ // Next.js API tree gets reported as "API-REFERENCE.md — unlinked doc".
70
+ { label: 'Next.js API routes', glob: /(^|\/)(app|pages)\/api\// },
67
71
  { label: 'OpenAPI spec', glob: /(openapi|swagger)\.(json|ya?ml)/ },
68
72
  { label: 'API middleware', glob: /middleware\// },
69
73
  ],
@@ -115,6 +119,15 @@ export function validateTraceability(projectDir, config) {
115
119
  const projectFiles = [];
116
120
  scanDir(projectDir, projectDir, projectFiles);
117
121
 
122
+ // Scan source files for `// @doc <filename>.md` annotations. An annotation
123
+ // is an explicit author signal that a source file documents (or is
124
+ // documented by) a canonical doc. It is the user-facing escape hatch when
125
+ // a project's directory layout doesn't match the built-in TRACE_MAP globs
126
+ // (e.g. a route file outside any `routes/` / `app/api/` tree). The
127
+ // annotation is also shown in templates and templates/commands docs, so
128
+ // users have been told it works — actually honoring it is the fix here.
129
+ const docAnnotations = scanDocAnnotations(projectFiles, projectDir);
130
+
118
131
  // ── Part 1: Source Traceability (existing) ──
119
132
  for (const [docName, traceInfo] of Object.entries(TRACE_MAP)) {
120
133
  // Skip docs not in the user's required list
@@ -129,6 +142,14 @@ export function validateTraceability(projectDir, config) {
129
142
  continue;
130
143
  }
131
144
 
145
+ // Explicit `// @doc <docName>` annotation counts as a link regardless of
146
+ // whether the file path matches any built-in pattern. Checked first so
147
+ // path-pattern misses don't drown out explicit author intent.
148
+ if (docAnnotations.has(docName) && docAnnotations.get(docName).size > 0) {
149
+ passed++;
150
+ continue;
151
+ }
152
+
132
153
  // Count matching source files
133
154
  // ⚡ Bolt: Fast early return using .some() instead of .filter()
134
155
  let hasSource = false;
@@ -354,6 +375,50 @@ function getRequirementDocPaths(projectDir, config) {
354
375
 
355
376
  // ── Helpers ────────────────────────────────────────────────────────────────
356
377
 
378
+ /**
379
+ * Scan code files for `// @doc <name>.md` annotations. Returns a Map from
380
+ * canonical doc basename → Set of source file paths that annotate it.
381
+ *
382
+ * Annotation forms accepted:
383
+ * - `// @doc API-REFERENCE.md`
384
+ * - `// @doc docs-canonical/API-REFERENCE.md` (basename is what matters)
385
+ * - `/* @doc API-REFERENCE.md *​/` (block comment, single line)
386
+ * - `# @doc API-REFERENCE.md` (Python / Ruby comment style)
387
+ *
388
+ * Only the basename of the referenced doc is keyed; callers compare against
389
+ * the doc basename (e.g. `API-REFERENCE.md`). We cap how many files we open
390
+ * to keep this scan O(N) and stop reading the rest of a file after the
391
+ * first 4 KB — annotations belong at the top of a file by convention, and
392
+ * reading every byte of every source file just to find a header comment
393
+ * would balloon scan time on large monorepos.
394
+ */
395
+ function scanDocAnnotations(projectFiles, projectDir) {
396
+ const map = new Map();
397
+ const annotationRe = /(?:\/\/|\/\*|#)\s*@doc\s+(\S+\.md)/g;
398
+ const CODE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.rs', '.java']);
399
+ const HEAD_BYTES = 4096;
400
+
401
+ for (const relPath of projectFiles) {
402
+ const ext = extname(relPath);
403
+ if (!CODE_EXT.has(ext)) continue;
404
+ const full = resolve(projectDir, relPath);
405
+ let content;
406
+ try { content = readFileSync(full, 'utf-8'); } catch { continue; }
407
+ // Annotations live near the top of the file. Slicing avoids reading
408
+ // megabytes of bundled / minified output looking for a header comment.
409
+ const head = content.length > HEAD_BYTES ? content.slice(0, HEAD_BYTES) : content;
410
+ if (!head.includes('@doc')) continue;
411
+ annotationRe.lastIndex = 0;
412
+ let m;
413
+ while ((m = annotationRe.exec(head)) !== null) {
414
+ const docName = basename(m[1]);
415
+ if (!map.has(docName)) map.set(docName, new Set());
416
+ map.get(docName).add(relPath);
417
+ }
418
+ }
419
+ return map;
420
+ }
421
+
357
422
  function scanDir(rootDir, dir, files) {
358
423
  let entries;
359
424
  try { entries = readdirSync(dir); } catch { return; }
@@ -68,7 +68,7 @@ diagnose → AI reads prompts → AI fixes docs → guard verifies
68
68
  ## Verify
69
69
 
70
70
  ```bash
71
- npx docguard-cli guard # Pass/fail check (23 validators)
71
+ npx docguard-cli guard # Pass/fail check (24 validators)
72
72
  npx docguard-cli score # 0-100 maturity score
73
73
  ```
74
74
 
@@ -4,7 +4,7 @@ Enterprise-grade Canonical-Driven Development (CDD) enforcement and **AI-readabl
4
4
 
5
5
  ## Features
6
6
 
7
- - **23 Validators** — Structure, Security, Doc Quality, Test-Spec, Drift-Comments, API-Surface, Freshness, Cross-Reference, and 13 more
7
+ - **24 Validators** — Structure, Security, Doc Quality, Test-Spec, Drift-Comments, API-Surface, Freshness, Cross-Reference, and 13 more
8
8
  - **Language-agnostic** — JS/TS, Python, Rust, Go, Java/Kotlin, Ruby, PHP, C#. Polyglot/monorepo-aware.
9
9
  - **AI-powered Generate** — `generate --plan` builds the code-truth skeleton in `<!-- docguard:section -->` markers and emits a structured agent task manifest; the AI writes the prose.
10
10
  - **Always up to date** — `sync` surgically refreshes code-truth doc sections in place, **preserves human prose**, flags prose for agent review.
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.21.1"
6
+ version: "0.22.0"
7
7
  description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with 19 automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. Zero NPM runtime dependencies."
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.21.1
9
+ version: 0.22.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.21.1 -->
12
+ <!-- docguard:version: 0.22.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.21.1
10
+ version: 0.22.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.21.1 -->
13
+ <!-- docguard:version: 0.22.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.21.1
9
+ version: 0.22.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.21.1 -->
12
+ <!-- docguard:version: 0.22.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.21.1
9
+ version: 0.22.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.21.1 -->
12
+ <!-- docguard:version: 0.22.0 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,7 +4,7 @@ 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.21.1
7
+ version: 0.22.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.21.1",
3
+ "version": "0.22.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": {
@@ -102,6 +102,8 @@
102
102
  "specKit": { "type": "boolean" },
103
103
  "crossReference": { "type": "boolean" },
104
104
  "generatedStaleness":{ "type": "boolean" },
105
+ "canonicalSync": { "type": "boolean" },
106
+ "surfaceSync": { "type": "boolean" },
105
107
  "metricsConsistency":{ "type": "boolean" }
106
108
  },
107
109
  "additionalProperties": false
@@ -149,6 +151,30 @@
149
151
  }
150
152
  },
151
153
  "additionalProperties": true
154
+ },
155
+ "surfaceSync": {
156
+ "type": "object",
157
+ "description": "Surface-Sync validator: item-level drift between code-derived enumerables (commands, validators, templates) and the lists in target docs (README.md, AGENTS.md). Default: N/A unless `surfaces` is declared.",
158
+ "properties": {
159
+ "surfaces": {
160
+ "type": "array",
161
+ "description": "Enumerable surfaces to check. Each surface compares code-truth (from a glob) against documented list entries in target markdown files.",
162
+ "items": {
163
+ "type": "object",
164
+ "properties": {
165
+ "name": { "type": "string", "description": "Human-readable surface name used in warnings (e.g. \"commands\")." },
166
+ "glob": { "type": "string", "description": "Glob discovering the code-truth files (e.g. `cli/commands/*.mjs`). Only leaf `*` supported." },
167
+ "extract": { "type": "string", "enum": ["basename", "basename-no-ext"], "description": "How to derive the surface name from each file path." },
168
+ "ignore": { "type": "array", "items": { "type": "string" }, "description": "Surface names to skip (e.g. known deprecation aliases)." },
169
+ "docs": { "type": "array", "items": { "type": "string" }, "description": "Target markdown files to scan for documented list entries." },
170
+ "section": { "type": "string", "description": "Optional heading text to scope the scan (substring match, case-insensitive). Without it, the entire doc is scanned and tables for OTHER surfaces produce cross-table false positives. Use when one doc lists multiple surfaces." }
171
+ },
172
+ "required": ["name", "glob"],
173
+ "additionalProperties": false
174
+ }
175
+ }
176
+ },
177
+ "additionalProperties": true
152
178
  }
153
179
  },
154
180
  "additionalProperties": true