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/README.md +34 -15
- package/cli/commands/diff.mjs +15 -17
- package/cli/commands/guard.mjs +124 -7
- package/cli/commands/init.mjs +52 -1
- package/cli/commands/upgrade.mjs +250 -0
- package/cli/docguard.mjs +32 -3
- package/cli/ensure-skills.mjs +8 -1
- package/cli/shared-ignore.mjs +50 -0
- package/cli/shared-source.mjs +17 -6
- package/cli/shared.mjs +62 -0
- package/cli/validators/cross-reference.mjs +281 -0
- package/cli/validators/docs-coverage.mjs +4 -1
- package/cli/validators/environment.mjs +9 -3
- package/commands/docguard.guard.md +2 -2
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +1 -1
- package/extensions/spec-kit-docguard/commands/guard.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +11 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -3
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +1 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +51 -0
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +48 -0
- package/package.json +1 -1
- package/templates/commands/docguard.guard.md +2 -2
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
|
-
|
|
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
|
-
|
|
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.`);
|
package/cli/ensure-skills.mjs
CHANGED
|
@@ -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' },
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -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).
|
package/cli/shared-source.mjs
CHANGED
|
@@ -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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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(
|
|
231
|
-
|
|
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
|
-
|
|
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)
|
|
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_]
|
|
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
|
|
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
|
|
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
|
|-----------|---------------|
|
package/docs/quickstart.md
CHANGED
|
@@ -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 (
|
|
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
|
-
- **
|
|
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
|
|
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.
|
|
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"
|