docguard-cli 0.11.1 → 0.12.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/cli/docguard.mjs CHANGED
@@ -40,10 +40,12 @@ import { runPublish } from './commands/publish.mjs';
40
40
  import { runTrace } from './commands/trace.mjs';
41
41
  import { runLlms } from './commands/llms.mjs';
42
42
  import { runSetup } from './commands/setup.mjs';
43
+ import { runUpgrade } from './commands/upgrade.mjs';
43
44
  import { ensureSkills } from './ensure-skills.mjs';
44
45
 
45
46
  // ── Shared constants (imported to break circular dependencies) ──────────
46
47
  import { c, PROFILES } from './shared.mjs';
48
+ import { mergeIgnoreFile } from './shared-ignore.mjs';
47
49
  export { c, PROFILES };
48
50
 
49
51
  // ── Config Loading ─────────────────────────────────────────────────────────
@@ -138,6 +140,9 @@ export function loadConfig(projectDir) {
138
140
  merged.testPatterns.push(merged.testPattern);
139
141
  }
140
142
  }
143
+ // Merge .docguardignore patterns into config.ignore so every validator
144
+ // honors them without having to know about the file.
145
+ mergeIgnoreFile(projectDir, merged);
141
146
  return merged;
142
147
  } catch (e) {
143
148
  console.error(`${c.red}Error parsing .docguard.json: ${e.message}${c.reset}`);
@@ -148,6 +153,9 @@ export function loadConfig(projectDir) {
148
153
  // No config file — auto-detect everything
149
154
  defaults.projectType = autoDetectProjectType(projectDir);
150
155
  defaults.projectTypeConfig = getProjectTypeDefaults(defaults.projectType);
156
+ // .docguardignore is read even when no .docguard.json exists — keeps
157
+ // ignore-only projects (no config but want to skip paths) working.
158
+ mergeIgnoreFile(projectDir, defaults);
151
159
  return defaults;
152
160
  }
153
161
 
@@ -365,6 +373,14 @@ async function main() {
365
373
  } else if (args[i] === '--since' && args[i + 1]) {
366
374
  flags.since = args[i + 1];
367
375
  i++;
376
+ } else if (args[i] === '--show-failing') {
377
+ flags.showFailing = true;
378
+ } else if (args[i] === '--check-only') {
379
+ flags.checkOnly = true;
380
+ } else if (args[i] === '--apply') {
381
+ flags.apply = true;
382
+ } else if (args[i] === '--changed-only') {
383
+ flags.changedOnly = true;
368
384
  } else if (args[i] === '--doc' && args[i + 1]) {
369
385
  flags.doc = args[i + 1];
370
386
  i++;
@@ -403,12 +419,21 @@ async function main() {
403
419
  process.exit(0);
404
420
  }
405
421
 
406
- printBanner();
422
+ // In JSON mode the entire stdout MUST be parseable JSON. The banner and
423
+ // ensureSkills' install message would corrupt the output for any
424
+ // programmatic consumer (CI, dashboards, the Score-on-PR Action recipe).
425
+ // Headless flags (`--write`, `--check-only`, `--auto`) also suppress chrome.
426
+ const jsonMode = flags.format === 'json';
427
+ const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly;
428
+
429
+ if (!jsonMode) printBanner();
407
430
 
408
431
  const config = loadConfig(projectDir);
409
432
 
410
- // Silent auto-check: install skills/commands if missing
411
- if (command !== 'setup' && command !== 'init') {
433
+ // Silent auto-check: install skills/commands if missing. Skip entirely in
434
+ // headless modes where the user wants deterministic, parseable output and
435
+ // doesn't expect side effects on their AI-agent skill directories.
436
+ if (command !== 'setup' && command !== 'init' && !headless) {
412
437
  ensureSkills(projectDir, flags);
413
438
  }
414
439
 
@@ -476,6 +501,10 @@ async function main() {
476
501
  case 'llms':
477
502
  runLlms(projectDir, config, flags);
478
503
  break;
504
+ case 'upgrade':
505
+ case 'update':
506
+ await runUpgrade(projectDir, config, flags);
507
+ break;
479
508
  default:
480
509
  console.error(`${c.red}Unknown command: ${command}${c.reset}`);
481
510
  console.log(`Run ${c.cyan}docguard --help${c.reset} for usage.`);
@@ -54,8 +54,13 @@ export function detectAgentMode(projectDir) {
54
54
  '.specify',
55
55
  '.github/copilot-instructions.md',
56
56
  'CLAUDE.md',
57
+ 'GEMINI.md',
57
58
  '.gemini',
58
59
  '.agents',
60
+ '.antigravity',
61
+ 'ANTIGRAVITY.md',
62
+ '.kiro',
63
+ '.windsurf',
59
64
  ];
60
65
 
61
66
  for (const signal of llmSignals) {
@@ -105,7 +110,9 @@ export function detectAIAgent(projectDir) {
105
110
  { signal: '.claude', agent: 'claude' },
106
111
  { signal: 'CLAUDE.md', agent: 'claude' },
107
112
  { signal: '.gemini', agent: 'gemini' },
108
- { signal: '.agents', agent: 'agy' }, // Antigravity
113
+ { signal: '.agents', agent: 'agy' }, // Antigravity (Spec Kit convention)
114
+ { signal: '.antigravity', agent: 'agy' }, // Antigravity (alt convention)
115
+ { signal: 'ANTIGRAVITY.md', agent: 'agy' }, // Antigravity rules file
109
116
  { signal: '.github/copilot-instructions.md', agent: 'copilot' },
110
117
  { signal: '.windsurf', agent: 'windsurf' },
111
118
  { signal: '.codex', agent: 'codex' },
@@ -40,6 +40,56 @@ export const DEFAULT_IGNORE_DIRS = new Set([
40
40
  const ALWAYS_REJECT_PATH_RE =
41
41
  /(?:^|[/\\])(?:node_modules|\.claude[/\\]worktrees|\.git[/\\]worktrees|\.jj)(?:[/\\]|$)/;
42
42
 
43
+ /**
44
+ * Read `.docguardignore` from a project directory and return its patterns.
45
+ *
46
+ * Format: gitignore-style — one pattern per line, `#` for comments, blank lines
47
+ * ignored. Returned patterns are normalized but not transformed (callers
48
+ * decide whether to expand directory globs).
49
+ *
50
+ * Returns [] if the file is missing or unreadable — never throws.
51
+ */
52
+ import { readFileSync, existsSync } from 'node:fs';
53
+ import { resolve as resolvePath } from 'node:path';
54
+
55
+ export function loadDocguardIgnore(projectDir) {
56
+ const p = resolvePath(projectDir, '.docguardignore');
57
+ if (!existsSync(p)) return [];
58
+ try {
59
+ const raw = readFileSync(p, 'utf-8');
60
+ return raw
61
+ .split(/\r?\n/)
62
+ .map(line => line.trim())
63
+ .filter(line => line && !line.startsWith('#'));
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Merge `.docguardignore` patterns into a config object's `ignore` array.
71
+ *
72
+ * Used at config-load time so every validator sees the combined set without
73
+ * having to know about the file. Mutates and returns the config for ergonomics.
74
+ *
75
+ * Idempotent — calling twice produces the same result. Skips duplicates.
76
+ */
77
+ export function mergeIgnoreFile(projectDir, config) {
78
+ const filePatterns = loadDocguardIgnore(projectDir);
79
+ if (filePatterns.length === 0) return config;
80
+ const existing = Array.isArray(config.ignore) ? config.ignore : [];
81
+ const seen = new Set(existing);
82
+ const merged = [...existing];
83
+ for (const p of filePatterns) {
84
+ if (!seen.has(p)) {
85
+ merged.push(p);
86
+ seen.add(p);
87
+ }
88
+ }
89
+ config.ignore = merged;
90
+ return config;
91
+ }
92
+
43
93
  /**
44
94
  * Convert a glob pattern to a RegExp.
45
95
  * Supports: * (any chars except /), ** (any path segments), . (literal dot).
@@ -210,11 +210,16 @@ export function grepEnvUsage(projectDir, config = {}) {
210
210
  const roots = resolveSourceRoots(projectDir, config);
211
211
  const seen = new Set();
212
212
 
213
+ // Require names to start with a letter and END with a letter/digit (NOT an
214
+ // underscore) — fixes "VITE_" being captured as a literal env var name.
215
+ const NAME = '([A-Z][A-Z0-9_]*[A-Z0-9])';
213
216
  const patterns = [
214
- /process\.env\.([A-Z][A-Z0-9_]+)/g,
215
- /process\.env\[\s*['"]([A-Z][A-Z0-9_]+)['"]\s*\]/g,
216
- /import\.meta\.env\.([A-Z][A-Z0-9_]+)/g,
217
+ new RegExp(`process\\.env\\.${NAME}`, 'g'),
218
+ new RegExp(`process\\.env\\[\\s*['"]${NAME}['"]\\s*\\]`, 'g'),
219
+ new RegExp(`import\\.meta\\.env\\.${NAME}`, 'g'),
217
220
  ];
221
+ // Vite injects these at build time; they are not user-set env vars.
222
+ const VITE_INTRINSICS = new Set(['DEV', 'PROD', 'MODE', 'BASE_URL', 'SSR']);
218
223
 
219
224
  const visit = (filePath) => {
220
225
  if (seen.has(filePath)) return;
@@ -225,10 +230,16 @@ export function grepEnvUsage(projectDir, config = {}) {
225
230
  let content;
226
231
  try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
227
232
  if (!content.includes('env')) return;
228
- for (const re of patterns) {
233
+ // patterns[2] is the import.meta.env one — its matches are Vite-injected
234
+ // when the name is an intrinsic, and must not be reported as user env vars.
235
+ for (let i = 0; i < patterns.length; i++) {
229
236
  let m;
230
- const rx = new RegExp(re.source, 'g');
231
- while ((m = rx.exec(content)) !== null) names.add(m[1]);
237
+ const rx = new RegExp(patterns[i].source, 'g');
238
+ const isViteSource = i === 2;
239
+ while ((m = rx.exec(content)) !== null) {
240
+ if (isViteSource && VITE_INTRINSICS.has(m[1])) continue;
241
+ names.add(m[1]);
242
+ }
232
243
  }
233
244
  };
234
245
 
package/cli/shared.mjs CHANGED
@@ -4,6 +4,68 @@
4
4
  * All commands import from here instead of docguard.mjs.
5
5
  */
6
6
 
7
+ /**
8
+ * Current .docguard.json schema version that this CLI version writes via
9
+ * `docguard init`. Bump this when adding fields that need migration (e.g.
10
+ * v0.12 adds `severity` overrides per validator).
11
+ *
12
+ * The post-guard nudge fires when an existing project's stored
13
+ * `.docguard.json.version` is BEHIND this constant — pointing users at
14
+ * `docguard upgrade` to migrate.
15
+ */
16
+ export const CURRENT_SCHEMA_VERSION = '0.5';
17
+
18
+ /**
19
+ * Allowed severity values for per-validator `severity` overrides in
20
+ * `.docguard.json`. Affects EXIT-CODE behavior of `docguard guard`:
21
+ * - 'high': warnings from this validator fail CI (exit 1)
22
+ * - 'medium': default — warnings exit 2 (informational)
23
+ * - 'low': warnings ignored for exit code (exit 0)
24
+ *
25
+ * Display (the per-validator status lines and the summary) is unchanged
26
+ * regardless of severity — severity is a CI/operational knob, not a UI one.
27
+ */
28
+ export const SEVERITY_LEVELS = new Set(['high', 'medium', 'low']);
29
+
30
+ /**
31
+ * Resolve a validator's effective severity from config.
32
+ * Returns 'medium' (default) if no override is set or the override is bogus.
33
+ */
34
+ export function resolveSeverity(config, validatorKey) {
35
+ const s = config && config.severity && config.severity[validatorKey];
36
+ if (typeof s === 'string' && SEVERITY_LEVELS.has(s.toLowerCase())) {
37
+ return s.toLowerCase();
38
+ }
39
+ return 'medium';
40
+ }
41
+
42
+ /**
43
+ * Parse a dotted-decimal version string into a tuple of integers for
44
+ * comparison. Tolerates extra suffixes (e.g. `0.4-beta` → [0, 4]).
45
+ * Returns null when the string is unparseable.
46
+ */
47
+ export function parseVersion(v) {
48
+ if (!v || typeof v !== 'string') return null;
49
+ const m = v.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
50
+ if (!m) return null;
51
+ return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
52
+ }
53
+
54
+ /**
55
+ * Compare two version strings. Returns -1 if a<b, 0 if equal, 1 if a>b.
56
+ * Unparseable inputs sort as equal (no nag).
57
+ */
58
+ export function compareVersions(a, b) {
59
+ const pa = parseVersion(a);
60
+ const pb = parseVersion(b);
61
+ if (!pa || !pb) return 0;
62
+ for (let i = 0; i < 3; i++) {
63
+ if (pa[i] < pb[i]) return -1;
64
+ if (pa[i] > pb[i]) return 1;
65
+ }
66
+ return 0;
67
+ }
68
+
7
69
  // ── Colors (ANSI escape codes, zero deps) ──────────────────────────────────
8
70
  export const c = {
9
71
  reset: '\x1b[0m',
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Cross-Reference Validator — S-8 / K-7
3
+ *
4
+ * Scans canonical docs for cross-references between docs and verifies they
5
+ * resolve. Catches stale links when a section is renamed or removed.
6
+ *
7
+ * Reference forms supported (in rough order of frequency):
8
+ * - Markdown relative links: [text](./OTHER.md)
9
+ * [text](./OTHER.md#anchor)
10
+ * [text](#anchor-in-same-doc)
11
+ * - Bare anchor refs: see §3.2 ARCHITECTURE.md
12
+ * (Section 3.2 in DATA-MODEL.md)
13
+ * - Bracketed section refs: [Section X.Y]
14
+ * [ARCHITECTURE.md § Components]
15
+ *
16
+ * For each ref we extract: target_file (optional), anchor (optional). Then
17
+ * we check:
18
+ * 1. target_file exists (relative to projectDir or canonical doc dir)
19
+ * 2. anchor resolves to a heading or an explicit `<a name="...">` in that file
20
+ *
21
+ * Zero NPM dependencies. Pure Node.js built-ins.
22
+ *
23
+ * @req SC-K7-001 — broken markdown links between canonical docs are reported
24
+ * @req SC-K7-002 — broken intra-doc anchors are reported
25
+ * @req SC-K7-003 — external URLs (http/https) are NOT checked (those are not our problem)
26
+ * @req SC-K7-004 — code-fenced examples don't trigger false positives
27
+ */
28
+
29
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
30
+ import { resolve, join, dirname, basename } from 'node:path';
31
+
32
+ /**
33
+ * Slugify a heading the way GitHub's markdown anchors work.
34
+ * Mirrors GFM rules: lowercase, strip non-word chars, hyphens for spaces.
35
+ * Good-enough for the 95% case; not bit-perfect.
36
+ */
37
+ export function slugifyHeading(text) {
38
+ return text
39
+ // Remove leading "## " (markdown header marker) WITHOUT trimming the
40
+ // leading whitespace that may follow it — that whitespace is the
41
+ // position where an emoji used to live and GitHub's anchor builder
42
+ // converts it to a leading dash. Example: `## ⚡ Quick Start` →
43
+ // anchor `#-quick-start` (with leading dash). Stripping it here
44
+ // would cause false-positive "broken anchor" warnings on any TOC
45
+ // generated by GitHub itself.
46
+ .replace(/^#+\s+/, '')
47
+ .toLowerCase()
48
+ // Strip emoji + decorative pictographs (they leave a position which
49
+ // becomes a dash after whitespace-collapse below).
50
+ .replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu, '')
51
+ // Strip GFM-deleted punctuation
52
+ .replace(/[.,;:!?'"`()[\]{}<>|\\/]/g, '')
53
+ // Convert whitespace runs to single dashes
54
+ .replace(/\s+/g, '-')
55
+ // Drop any remaining non-word/non-dash chars
56
+ .replace(/[^a-z0-9-]/g, '');
57
+ // NB: we do NOT collapse adjacent dashes and we do NOT strip leading/
58
+ // trailing dashes — GitHub's GFM keeps them. `& Academic` (with `&`
59
+ // removed) leaves two adjacent spaces which become `--`. README TOCs
60
+ // generated by GitHub itself rely on this exact behavior.
61
+ }
62
+
63
+ /**
64
+ * Extract all headings from a markdown file. Returns an array of
65
+ * { level, text, anchor } where anchor is the GFM-style slug.
66
+ *
67
+ * Skips headings inside ``` fenced code blocks to avoid false positives
68
+ * from example code.
69
+ */
70
+ export function extractHeadings(content) {
71
+ const lines = content.split('\n');
72
+ const headings = [];
73
+ let inCodeFence = false;
74
+ for (const line of lines) {
75
+ if (line.startsWith('```')) {
76
+ inCodeFence = !inCodeFence;
77
+ continue;
78
+ }
79
+ if (inCodeFence) continue;
80
+ const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
81
+ if (m) {
82
+ const text = m[2];
83
+ headings.push({ level: m[1].length, text, anchor: slugifyHeading(text) });
84
+ }
85
+ }
86
+ return headings;
87
+ }
88
+
89
+ /**
90
+ * Extract all candidate cross-references from a markdown file.
91
+ *
92
+ * Returns an array of { source, file, anchor, raw } where:
93
+ * - source = path of the document containing the reference (for reporting)
94
+ * - file = target file path, RELATIVE to the source file's directory.
95
+ * null if the link is intra-doc (just `#anchor`).
96
+ * - anchor = anchor part without `#`. null if no anchor.
97
+ * - raw = the original text matched (for error messages)
98
+ *
99
+ * Skips:
100
+ * - External http(s) URLs (not our problem)
101
+ * - mailto: links
102
+ * - Code-fenced blocks
103
+ * - Inline `code` with backticks
104
+ */
105
+ export function extractRefs(content, sourcePath) {
106
+ const refs = [];
107
+ const lines = content.split('\n');
108
+ let inCodeFence = false;
109
+
110
+ // [text](target) where target is one of:
111
+ // ./RELATIVE.md
112
+ // ../OTHER.md#anchor
113
+ // #intra-doc-anchor
114
+ // We DON'T match http(s) targets here.
115
+ const markdownLinkRe = /\[([^\]]+)\]\(((?!https?:|mailto:)[^)]+)\)/g;
116
+
117
+ for (let i = 0; i < lines.length; i++) {
118
+ const line = lines[i];
119
+ if (line.startsWith('```')) {
120
+ inCodeFence = !inCodeFence;
121
+ continue;
122
+ }
123
+ if (inCodeFence) continue;
124
+
125
+ // Strip inline `code` so we don't match links inside it
126
+ const stripped = line.replace(/`[^`]+`/g, '');
127
+
128
+ let m;
129
+ markdownLinkRe.lastIndex = 0;
130
+ while ((m = markdownLinkRe.exec(stripped)) !== null) {
131
+ const target = m[2].trim();
132
+ // Drop any title text: [foo](bar "title") → bar
133
+ const cleanTarget = target.split(/\s+/)[0];
134
+ const hashIdx = cleanTarget.indexOf('#');
135
+ let file, anchor;
136
+ if (hashIdx === 0) {
137
+ // Intra-doc anchor only
138
+ file = null;
139
+ anchor = cleanTarget.slice(1);
140
+ } else if (hashIdx > 0) {
141
+ file = cleanTarget.slice(0, hashIdx);
142
+ anchor = cleanTarget.slice(hashIdx + 1);
143
+ } else {
144
+ file = cleanTarget;
145
+ anchor = null;
146
+ }
147
+ refs.push({ source: sourcePath, file, anchor, raw: m[0], line: i + 1 });
148
+ }
149
+ }
150
+
151
+ return refs;
152
+ }
153
+
154
+ /**
155
+ * Resolve a target file path relative to a source markdown file.
156
+ * Returns the absolute path or null if the file doesn't exist.
157
+ */
158
+ function resolveTarget(sourcePath, targetRel, projectDir) {
159
+ if (!targetRel) return null;
160
+ // Try relative to source's directory first
161
+ const fromSource = resolve(dirname(sourcePath), targetRel);
162
+ if (existsSync(fromSource)) return fromSource;
163
+ // Also try from project root (some authors write `docs-canonical/X.md`)
164
+ const fromRoot = resolve(projectDir, targetRel);
165
+ if (existsSync(fromRoot)) return fromRoot;
166
+ return null;
167
+ }
168
+
169
+ /**
170
+ * Collect canonical markdown docs to scan. Same selection logic as other
171
+ * validators — `docs-canonical/`, root tracking files, and AGENTS.md.
172
+ */
173
+ function collectCanonicalDocs(projectDir) {
174
+ const docs = [];
175
+ const cdir = resolve(projectDir, 'docs-canonical');
176
+ if (existsSync(cdir)) {
177
+ try {
178
+ for (const f of readdirSync(cdir)) {
179
+ if (f.endsWith('.md')) {
180
+ const p = join(cdir, f);
181
+ if (statSync(p).isFile()) docs.push(p);
182
+ }
183
+ }
184
+ } catch {}
185
+ }
186
+ for (const f of ['AGENTS.md', 'CHANGELOG.md', 'DRIFT-LOG.md', 'ROADMAP.md', 'README.md']) {
187
+ const p = resolve(projectDir, f);
188
+ if (existsSync(p)) docs.push(p);
189
+ }
190
+ return docs;
191
+ }
192
+
193
+ /**
194
+ * Validator entrypoint — matches the standard signature returning
195
+ * { errors, warnings, passed, total }.
196
+ */
197
+ export function validateCrossReferences(projectDir, _config = {}) {
198
+ const errors = [];
199
+ const warnings = [];
200
+ let passed = 0;
201
+ let total = 0;
202
+
203
+ const docs = collectCanonicalDocs(projectDir);
204
+ if (docs.length === 0) {
205
+ return { errors, warnings, passed, total, applicable: false };
206
+ }
207
+
208
+ // Build a map of doc path → anchor set for fast lookups during ref resolution.
209
+ const anchorIndex = new Map();
210
+ for (const d of docs) {
211
+ try {
212
+ const content = readFileSync(d, 'utf-8');
213
+ const headings = extractHeadings(content);
214
+ anchorIndex.set(d, new Set(headings.map(h => h.anchor)));
215
+ } catch {
216
+ anchorIndex.set(d, new Set());
217
+ }
218
+ }
219
+
220
+ // Walk every doc and validate each ref.
221
+ for (const docPath of docs) {
222
+ let content;
223
+ try { content = readFileSync(docPath, 'utf-8'); } catch { continue; }
224
+ const refs = extractRefs(content, docPath);
225
+ const docName = basename(docPath);
226
+
227
+ for (const ref of refs) {
228
+ total++;
229
+
230
+ // Resolve the target file (if any)
231
+ let targetPath = null;
232
+ if (ref.file) {
233
+ // Skip non-markdown files — we only validate doc cross-refs, not
234
+ // links to images / code / config files. Those have their own truth.
235
+ if (!ref.file.toLowerCase().endsWith('.md')) {
236
+ passed++;
237
+ continue;
238
+ }
239
+ targetPath = resolveTarget(docPath, ref.file, projectDir);
240
+ if (!targetPath) {
241
+ warnings.push(
242
+ `${docName}:${ref.line} — broken link: target file "${ref.file}" not found`
243
+ );
244
+ continue;
245
+ }
246
+ } else {
247
+ // Intra-doc anchor reference
248
+ targetPath = docPath;
249
+ }
250
+
251
+ // If there's an anchor, verify it resolves in the target doc.
252
+ // URL-decode the anchor first — some editors percent-encode chars
253
+ // like `️` (variation selector) in TOC links that wouldn't
254
+ // appear in the actual GitHub-rendered anchor. Decoding makes
255
+ // `#%EF%B8%8F-cicd-integration` compare against `#-cicd-integration`.
256
+ if (ref.anchor) {
257
+ let decodedAnchor;
258
+ try {
259
+ decodedAnchor = decodeURIComponent(ref.anchor);
260
+ } catch {
261
+ decodedAnchor = ref.anchor;
262
+ }
263
+ // After decode, re-apply the slug pipeline so we compare like-for-like.
264
+ const normalizedAnchor = slugifyHeading(decodedAnchor);
265
+ const anchors = anchorIndex.get(targetPath);
266
+ const matches = anchors && (anchors.has(ref.anchor) || anchors.has(normalizedAnchor));
267
+ if (!matches) {
268
+ const where = targetPath === docPath ? 'same doc' : basename(targetPath);
269
+ warnings.push(
270
+ `${docName}:${ref.line} — broken anchor: "#${ref.anchor}" in ${where} doesn't match any heading`
271
+ );
272
+ continue;
273
+ }
274
+ }
275
+
276
+ passed++;
277
+ }
278
+ }
279
+
280
+ return { errors, warnings, passed, total };
281
+ }
@@ -422,9 +422,12 @@ function checkReadmeSections(projectDir) {
422
422
  }
423
423
  }
424
424
 
425
+ // Recommended sections are a BONUS — present = +1 to both passed and total,
426
+ // missing = no-op. Counting missing recommended toward `total` without a
427
+ // corresponding warning would be a silent fail (caught by B-4 nudge).
425
428
  for (const section of recommendedSections) {
426
- total++;
427
429
  if (section.patterns.some(p => lowerContent.includes(p))) {
430
+ total++;
428
431
  passed++;
429
432
  }
430
433
  }
@@ -41,13 +41,19 @@ export function validateEnvironment(projectDir, config) {
41
41
  // CLI/library projects that declare no env vars skip this.)
42
42
  if (ptc.needsEnvVars !== false) {
43
43
  const documented = new Set();
44
- const varRe = /`([A-Z][A-Z0-9_]{2,})`/g;
44
+ // Require the matched name to end with a letter/digit — prevents prose-only
45
+ // tokens like `VITE_` (the convention prefix) from being treated as a real
46
+ // variable name.
47
+ const varRe = /`([A-Z][A-Z0-9_]*[A-Z0-9])`/g;
45
48
  let m;
46
- while ((m = varRe.exec(content)) !== null) documented.add(m[1]);
49
+ while ((m = varRe.exec(content)) !== null) {
50
+ if (m[1].length < 3) continue; // 'OK' / 'ID' etc. are too short to be env var refs
51
+ documented.add(m[1]);
52
+ }
47
53
  for (const envFile of ['.env.example', '.env.template']) {
48
54
  const p = resolve(projectDir, envFile);
49
55
  if (!existsSync(p)) continue;
50
- const re = /^([A-Z][A-Z0-9_]+)\s*=/gm;
56
+ const re = /^([A-Z][A-Z0-9_]*[A-Z0-9])\s*=/gm;
51
57
  const ex = readFileSync(p, 'utf-8');
52
58
  let em;
53
59
  while ((em = re.exec(ex)) !== null) documented.add(em[1]);
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Run DocGuard guard validation — check project documentation against CDD standards with 20 validators
2
+ description: Run DocGuard guard validation — check project documentation against CDD standards with 21 validators
3
3
  handoffs:
4
4
  - label: Fix All Issues
5
5
  agent: docguard.fix
@@ -23,7 +23,7 @@ Run the DocGuard CLI to validate all documentation against Canonical-Driven Deve
23
23
  npx docguard-cli guard
24
24
  ```
25
25
 
26
- 2. **Parse the output**. Each of the 20 validators reports ✅ (pass), ⚠️ (warning), ❌ (fail), or ➖ (N/A — nothing to validate). **A ➖ N/A is NOT a pass**: it means the validator found nothing to check (e.g. no API-REFERENCE.md, no DB schema, no layer boundaries declared). Don't read N/A as "healthy" — read it as "not assessed".
26
+ 2. **Parse the output**. Each of the 21 validators reports ✅ (pass), ⚠️ (warning), ❌ (fail), or ➖ (N/A — nothing to validate). **A ➖ N/A is NOT a pass**: it means the validator found nothing to check (e.g. no API-REFERENCE.md, no DB schema, no layer boundaries declared). Don't read N/A as "healthy" — read it as "not assessed".
27
27
 
28
28
  | Validator | What It Checks |
29
29
  |-----------|---------------|
@@ -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 (20 validators)
71
+ npx docguard-cli guard # Pass/fail check (21 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
- - **20 Validators** — Structure, Security, Doc Quality, Test-Spec, Drift-Comments, API-Surface, Freshness, and 13 more
7
+ - **21 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.
@@ -14,7 +14,7 @@ handoffs:
14
14
 
15
15
  # DocGuard Guard
16
16
 
17
- Validate your project against its canonical documentation. Runs 160+ automated checks across 20 validators.
17
+ Validate your project against its canonical documentation. Runs 160+ automated checks across 21 validators.
18
18
 
19
19
  ## User Input
20
20
 
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.11.1"
6
+ version: "0.12.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"
@@ -53,6 +53,16 @@ provides:
53
53
  file: "commands/generate.md"
54
54
  description: "Reverse-engineer canonical docs from existing codebase"
55
55
 
56
+ # GitHub Actions workflow starters — copyable templates users drop into
57
+ # .github/workflows/ for guard/fix/sync/score integration.
58
+ workflows:
59
+ - name: "docguard-guard"
60
+ file: "templates/github-workflows/docguard-guard.yml"
61
+ description: "Mandatory CI gate — runs all 20 validators on PR + main push"
62
+ - name: "docguard-autofix"
63
+ file: "templates/github-workflows/docguard-autofix.yml"
64
+ description: "PR-time auto-fix — applies mechanical doc fixes + comments summary"
65
+
56
66
  # Helper scripts for CI/CD and automation
57
67
  scripts:
58
68
  - name: "docguard-check-docs"