docguard-cli 0.11.2 → 0.13.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 +35 -16
- package/cli/commands/fix.mjs +55 -0
- package/cli/commands/guard.mjs +129 -5
- package/cli/commands/init.mjs +52 -1
- package/cli/commands/sync.mjs +50 -0
- package/cli/commands/trace.mjs +105 -0
- package/cli/commands/upgrade.mjs +250 -0
- package/cli/docguard.mjs +39 -3
- package/cli/shared-git.mjs +0 -0
- package/cli/shared-ignore.mjs +50 -0
- package/cli/shared.mjs +62 -0
- package/cli/validators/cross-reference.mjs +289 -0
- package/cli/validators/docs-sync.mjs +15 -0
- package/cli/validators/freshness.mjs +5 -10
- package/cli/validators/generated-staleness.mjs +97 -0
- package/cli/writers/fix-memory.mjs +133 -0
- package/cli/writers/mechanical.mjs +22 -0
- 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
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
// Standard root-level docs that are commonly cross-referenced. We index
|
|
187
|
+
// them so links like [CONTRIBUTING.md](CONTRIBUTING.md#some-section) can
|
|
188
|
+
// resolve. The list is conservative — adding everything would pull in
|
|
189
|
+
// boilerplate (LICENSE, NOTICE) that doesn't have meaningful headings.
|
|
190
|
+
for (const f of [
|
|
191
|
+
'README.md', 'AGENTS.md', 'CHANGELOG.md', 'DRIFT-LOG.md', 'ROADMAP.md',
|
|
192
|
+
'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md',
|
|
193
|
+
'PHILOSOPHY.md', 'STANDARD.md', 'COMPARISONS.md',
|
|
194
|
+
]) {
|
|
195
|
+
const p = resolve(projectDir, f);
|
|
196
|
+
if (existsSync(p)) docs.push(p);
|
|
197
|
+
}
|
|
198
|
+
return docs;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Validator entrypoint — matches the standard signature returning
|
|
203
|
+
* { errors, warnings, passed, total }.
|
|
204
|
+
*/
|
|
205
|
+
export function validateCrossReferences(projectDir, _config = {}) {
|
|
206
|
+
const errors = [];
|
|
207
|
+
const warnings = [];
|
|
208
|
+
let passed = 0;
|
|
209
|
+
let total = 0;
|
|
210
|
+
|
|
211
|
+
const docs = collectCanonicalDocs(projectDir);
|
|
212
|
+
if (docs.length === 0) {
|
|
213
|
+
return { errors, warnings, passed, total, applicable: false };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Build a map of doc path → anchor set for fast lookups during ref resolution.
|
|
217
|
+
const anchorIndex = new Map();
|
|
218
|
+
for (const d of docs) {
|
|
219
|
+
try {
|
|
220
|
+
const content = readFileSync(d, 'utf-8');
|
|
221
|
+
const headings = extractHeadings(content);
|
|
222
|
+
anchorIndex.set(d, new Set(headings.map(h => h.anchor)));
|
|
223
|
+
} catch {
|
|
224
|
+
anchorIndex.set(d, new Set());
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Walk every doc and validate each ref.
|
|
229
|
+
for (const docPath of docs) {
|
|
230
|
+
let content;
|
|
231
|
+
try { content = readFileSync(docPath, 'utf-8'); } catch { continue; }
|
|
232
|
+
const refs = extractRefs(content, docPath);
|
|
233
|
+
const docName = basename(docPath);
|
|
234
|
+
|
|
235
|
+
for (const ref of refs) {
|
|
236
|
+
total++;
|
|
237
|
+
|
|
238
|
+
// Resolve the target file (if any)
|
|
239
|
+
let targetPath = null;
|
|
240
|
+
if (ref.file) {
|
|
241
|
+
// Skip non-markdown files — we only validate doc cross-refs, not
|
|
242
|
+
// links to images / code / config files. Those have their own truth.
|
|
243
|
+
if (!ref.file.toLowerCase().endsWith('.md')) {
|
|
244
|
+
passed++;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
targetPath = resolveTarget(docPath, ref.file, projectDir);
|
|
248
|
+
if (!targetPath) {
|
|
249
|
+
warnings.push(
|
|
250
|
+
`${docName}:${ref.line} — broken link: target file "${ref.file}" not found`
|
|
251
|
+
);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
// Intra-doc anchor reference
|
|
256
|
+
targetPath = docPath;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// If there's an anchor, verify it resolves in the target doc.
|
|
260
|
+
// URL-decode the anchor first — some editors percent-encode chars
|
|
261
|
+
// like `️` (variation selector) in TOC links that wouldn't
|
|
262
|
+
// appear in the actual GitHub-rendered anchor. Decoding makes
|
|
263
|
+
// `#%EF%B8%8F-cicd-integration` compare against `#-cicd-integration`.
|
|
264
|
+
if (ref.anchor) {
|
|
265
|
+
let decodedAnchor;
|
|
266
|
+
try {
|
|
267
|
+
decodedAnchor = decodeURIComponent(ref.anchor);
|
|
268
|
+
} catch {
|
|
269
|
+
decodedAnchor = ref.anchor;
|
|
270
|
+
}
|
|
271
|
+
// After decode, re-apply the slug pipeline so we compare like-for-like.
|
|
272
|
+
const normalizedAnchor = slugifyHeading(decodedAnchor);
|
|
273
|
+
const anchors = anchorIndex.get(targetPath);
|
|
274
|
+
const matches = anchors && (anchors.has(ref.anchor) || anchors.has(normalizedAnchor));
|
|
275
|
+
if (!matches) {
|
|
276
|
+
const where = targetPath === docPath ? 'same doc' : basename(targetPath);
|
|
277
|
+
warnings.push(
|
|
278
|
+
`${docName}:${ref.line} — broken anchor: "#${ref.anchor}" in ${where} doesn't match any heading`
|
|
279
|
+
);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
passed++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { errors, warnings, passed, total };
|
|
289
|
+
}
|
|
@@ -80,6 +80,17 @@ export function validateDocsSync(projectDir, config) {
|
|
|
80
80
|
return results; // No canonical docs to check against
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// N-1: When the guard runs in --changed-only mode, config.changedFiles is
|
|
84
|
+
// populated with paths that changed since the given ref. We use it to scope
|
|
85
|
+
// route/service checks to ONLY the files actually changed — turning a
|
|
86
|
+
// whole-tree scan into a surgical check. If the list is empty (no changes,
|
|
87
|
+
// or git unavailable), we fall back to scanning everything.
|
|
88
|
+
const changedSet = config && Array.isArray(config.changedFiles) && config.changedFiles.length > 0
|
|
89
|
+
? new Set(config.changedFiles)
|
|
90
|
+
: null;
|
|
91
|
+
// Closure: true if the given relative path should be considered.
|
|
92
|
+
const inScope = (relPath) => !changedSet || changedSet.has(relPath);
|
|
93
|
+
|
|
83
94
|
// Find route/API files (monorepo-aware) and check they're mentioned in docs.
|
|
84
95
|
// Note: bare 'api' is intentionally excluded — it collides with frontend
|
|
85
96
|
// API client conventions (src/api/client.ts). Backend routes use
|
|
@@ -95,6 +106,8 @@ export function validateDocsSync(projectDir, config) {
|
|
|
95
106
|
const relPath = file.replace(projectDir + '/', '');
|
|
96
107
|
if (isTestFile(relPath)) continue;
|
|
97
108
|
if (!isValidRouteFile(relPath)) continue;
|
|
109
|
+
// N-1: skip files outside the --changed-only scope.
|
|
110
|
+
if (!inScope(relPath)) continue;
|
|
98
111
|
|
|
99
112
|
results.total++;
|
|
100
113
|
const name = basename(file, ext);
|
|
@@ -118,6 +131,8 @@ export function validateDocsSync(projectDir, config) {
|
|
|
118
131
|
|
|
119
132
|
const relPath = file.replace(projectDir + '/', '');
|
|
120
133
|
if (isTestFile(relPath)) continue;
|
|
134
|
+
// N-1: skip files outside the --changed-only scope.
|
|
135
|
+
if (!inScope(relPath)) continue;
|
|
121
136
|
|
|
122
137
|
results.total++;
|
|
123
138
|
const name = basename(file, ext);
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, extname } from 'node:path';
|
|
11
11
|
import { execSync, execFileSync } from 'node:child_process';
|
|
12
|
+
import { getLastCommitDate } from '../shared-git.mjs';
|
|
12
13
|
|
|
13
14
|
const IGNORE_DIRS = new Set([
|
|
14
15
|
'node_modules', '.git', '.next', 'dist', 'build',
|
|
@@ -21,16 +22,10 @@ const IGNORE_DIRS = new Set([
|
|
|
21
22
|
* Returns null if the file isn't tracked or git isn't available.
|
|
22
23
|
*/
|
|
23
24
|
function getLastGitDate(filePath, dir) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
29
|
-
).trim();
|
|
30
|
-
return result ? new Date(result) : null;
|
|
31
|
-
} catch {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
25
|
+
// Delegate to shared-git so rename history (--follow) is preserved.
|
|
26
|
+
// Without --follow, a `git mv` resets the file's "last commit date" and
|
|
27
|
+
// the Freshness counter silently misses drift introduced by the rename.
|
|
28
|
+
return getLastCommitDate(dir, filePath);
|
|
34
29
|
}
|
|
35
30
|
|
|
36
31
|
/**
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated-Doc Staleness Validator — M-1 / S-7
|
|
3
|
+
*
|
|
4
|
+
* Re-runs the memory-plan scanner and compares each `source=code` section's
|
|
5
|
+
* expected body against what's actually committed in the canonical docs.
|
|
6
|
+
* Flags sections where the doc says one thing but the scanner produces
|
|
7
|
+
* another — that's drift, and it means either:
|
|
8
|
+
* (a) Code changed and `docguard sync --write` hasn't been run, OR
|
|
9
|
+
* (b) Someone hand-edited a code-truth section (which shouldn't happen —
|
|
10
|
+
* human prose belongs in source=human sections).
|
|
11
|
+
*
|
|
12
|
+
* Why this matters: K-1's auto-fix Action runs `fix --write` (mechanical
|
|
13
|
+
* fixes) but doesn't run `sync --write` (memory refresh). Projects that
|
|
14
|
+
* skip the nightly sync recipe accumulate hidden drift in source=code
|
|
15
|
+
* sections. This validator surfaces it as a warning so CI can catch it.
|
|
16
|
+
*
|
|
17
|
+
* Cheap: just diffs in-memory strings; no extra git or filesystem walk
|
|
18
|
+
* beyond what memory-plan already does.
|
|
19
|
+
*
|
|
20
|
+
* @req SC-M1-001 — flag source=code sections whose body differs from scanner output
|
|
21
|
+
* @req SC-M1-002 — no warning when sections match
|
|
22
|
+
* @req SC-M1-003 — N/A when no canonical docs exist
|
|
23
|
+
* @req SC-M1-004 — N/A when no source=code sections present in any doc
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
27
|
+
import { resolve, basename } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
30
|
+
import { getSection } from '../writers/sections.mjs';
|
|
31
|
+
|
|
32
|
+
export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
33
|
+
const result = { errors: [], warnings: [], passed: 0, total: 0 };
|
|
34
|
+
|
|
35
|
+
// Build the canonical memory plan (what the docs SHOULD contain). If this
|
|
36
|
+
// fails or produces no docs, the validator is N/A.
|
|
37
|
+
let plan;
|
|
38
|
+
try {
|
|
39
|
+
plan = buildMemoryPlan(projectDir, config);
|
|
40
|
+
} catch {
|
|
41
|
+
return { ...result, applicable: false };
|
|
42
|
+
}
|
|
43
|
+
if (!plan || !Array.isArray(plan.docs) || plan.docs.length === 0) {
|
|
44
|
+
return { ...result, applicable: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Walk each doc's source=code sections and compare against on-disk content.
|
|
48
|
+
let anySourceCodeSection = false;
|
|
49
|
+
|
|
50
|
+
for (const doc of plan.docs) {
|
|
51
|
+
const fullPath = resolve(projectDir, doc.path);
|
|
52
|
+
if (!existsSync(fullPath)) continue;
|
|
53
|
+
let content;
|
|
54
|
+
try { content = readFileSync(fullPath, 'utf-8'); } catch { continue; }
|
|
55
|
+
|
|
56
|
+
for (const sec of doc.sections) {
|
|
57
|
+
if (sec.source !== 'code') continue;
|
|
58
|
+
anySourceCodeSection = true;
|
|
59
|
+
|
|
60
|
+
const onDisk = getSection(content, sec.id);
|
|
61
|
+
// If the section isn't present in the doc at all, that's a Structure /
|
|
62
|
+
// Doc Sections concern — not ours. Skip without counting.
|
|
63
|
+
if (!onDisk) continue;
|
|
64
|
+
|
|
65
|
+
result.total++;
|
|
66
|
+
const expected = String(sec.body || '').trim();
|
|
67
|
+
const actual = String(onDisk.body || '').trim();
|
|
68
|
+
|
|
69
|
+
if (expected === actual) {
|
|
70
|
+
result.passed++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Compute a short diff hint — first changed line — so the warning is
|
|
75
|
+
// actionable without dumping the whole section.
|
|
76
|
+
const exp = expected.split('\n');
|
|
77
|
+
const act = actual.split('\n');
|
|
78
|
+
let firstDiff = -1;
|
|
79
|
+
for (let i = 0; i < Math.max(exp.length, act.length); i++) {
|
|
80
|
+
if (exp[i] !== act[i]) { firstDiff = i; break; }
|
|
81
|
+
}
|
|
82
|
+
const hint = firstDiff >= 0
|
|
83
|
+
? ` (first drift at line ${firstDiff + 1} of section: "${(act[firstDiff] || '').slice(0, 60)}…" vs scanner: "${(exp[firstDiff] || '').slice(0, 60)}…")`
|
|
84
|
+
: '';
|
|
85
|
+
|
|
86
|
+
result.warnings.push(
|
|
87
|
+
`${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!anySourceCodeSection) {
|
|
93
|
+
return { ...result, applicable: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix Memory — M-2 / S-10
|
|
3
|
+
*
|
|
4
|
+
* Persists a JSON log of every mechanical fix `docguard fix --write` applies,
|
|
5
|
+
* stored at `.docguard/fixed.json`. Two purposes:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Audit trail.** Users (and reviewers) can ask "what did the bot
|
|
8
|
+
* change in this repo and when?" without digging through git history.
|
|
9
|
+
* Especially valuable for the K-1 auto-fix Action which commits as
|
|
10
|
+
* `docguard-bot` — the memory file is the human-readable record.
|
|
11
|
+
*
|
|
12
|
+
* 2. **Future suppression hook.** A future `fix --write` can check the
|
|
13
|
+
* memory and skip fixes that were applied and then reverted — avoiding
|
|
14
|
+
* ping-pong loops where the bot keeps re-applying a fix the user keeps
|
|
15
|
+
* undoing. For v0.13 we just record; suppression is v0.14+.
|
|
16
|
+
*
|
|
17
|
+
* Format (JSON, gitignore-friendly):
|
|
18
|
+
* {
|
|
19
|
+
* "schemaVersion": "1",
|
|
20
|
+
* "entries": [
|
|
21
|
+
* {
|
|
22
|
+
* "id": "<sha256 of type+file+before+after, first 12 chars>",
|
|
23
|
+
* "type": "replace-version",
|
|
24
|
+
* "file": "README.md",
|
|
25
|
+
* "summary": "v0.11.2 → v0.12.0",
|
|
26
|
+
* "appliedAt": "2026-05-26T01:35:00Z",
|
|
27
|
+
* "appliedBy": "fix --write" | "sync --write" | "docguard-bot"
|
|
28
|
+
* }
|
|
29
|
+
* ]
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* The file is intentionally small (no full before/after content) to stay
|
|
33
|
+
* checkable into git for teams that want the audit trail under version
|
|
34
|
+
* control. Capped at 500 entries (rolling).
|
|
35
|
+
*
|
|
36
|
+
* @req SC-M2-001 — loadFixMemory returns an empty array when no file exists
|
|
37
|
+
* @req SC-M2-002 — appendFixes creates .docguard/ if needed
|
|
38
|
+
* @req SC-M2-003 — appendFixes is idempotent (same fix logged twice → one entry)
|
|
39
|
+
* @req SC-M2-004 — fingerprint dedupes by type+file+summary (not timestamp)
|
|
40
|
+
* @req SC-M2-005 — entries are capped at MAX_ENTRIES (oldest dropped)
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
44
|
+
import { resolve, dirname } from 'node:path';
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
|
|
47
|
+
const MEMORY_PATH = '.docguard/fixed.json';
|
|
48
|
+
const SCHEMA_VERSION = '1';
|
|
49
|
+
const MAX_ENTRIES = 500;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compute a stable fingerprint for a fix. Used for dedup — two fixes with
|
|
53
|
+
* the same type+file+summary are considered the same operation, even if
|
|
54
|
+
* applied at different times.
|
|
55
|
+
*/
|
|
56
|
+
export function fingerprintFix(fix) {
|
|
57
|
+
const key = `${fix.type || ''}|${fix.file || ''}|${fix.summary || ''}`;
|
|
58
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 12);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load the fix memory from disk. Returns { schemaVersion, entries } —
|
|
63
|
+
* always a valid shape, even if the file is missing or malformed.
|
|
64
|
+
*/
|
|
65
|
+
export function loadFixMemory(projectDir) {
|
|
66
|
+
const p = resolve(projectDir, MEMORY_PATH);
|
|
67
|
+
if (!existsSync(p)) {
|
|
68
|
+
return { schemaVersion: SCHEMA_VERSION, entries: [] };
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'));
|
|
72
|
+
if (!data || !Array.isArray(data.entries)) {
|
|
73
|
+
return { schemaVersion: SCHEMA_VERSION, entries: [] };
|
|
74
|
+
}
|
|
75
|
+
return { schemaVersion: data.schemaVersion || SCHEMA_VERSION, entries: data.entries };
|
|
76
|
+
} catch {
|
|
77
|
+
return { schemaVersion: SCHEMA_VERSION, entries: [] };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Append fixes to the memory file. Dedupes by fingerprint — re-applying the
|
|
83
|
+
* same fix updates the existing entry's appliedAt instead of adding a row.
|
|
84
|
+
*
|
|
85
|
+
* `fixes` is an array of { type, file, summary } objects. The function adds
|
|
86
|
+
* `id` + `appliedAt` + `appliedBy` automatically.
|
|
87
|
+
*
|
|
88
|
+
* Returns the updated memory object.
|
|
89
|
+
*/
|
|
90
|
+
export function appendFixes(projectDir, fixes, appliedBy = 'fix --write') {
|
|
91
|
+
if (!Array.isArray(fixes) || fixes.length === 0) {
|
|
92
|
+
return loadFixMemory(projectDir);
|
|
93
|
+
}
|
|
94
|
+
const mem = loadFixMemory(projectDir);
|
|
95
|
+
const now = new Date().toISOString();
|
|
96
|
+
const byId = new Map(mem.entries.map(e => [e.id, e]));
|
|
97
|
+
|
|
98
|
+
for (const f of fixes) {
|
|
99
|
+
const id = fingerprintFix(f);
|
|
100
|
+
const entry = {
|
|
101
|
+
id,
|
|
102
|
+
type: f.type || 'unknown',
|
|
103
|
+
file: f.file || '',
|
|
104
|
+
summary: f.summary || '',
|
|
105
|
+
appliedAt: now,
|
|
106
|
+
appliedBy,
|
|
107
|
+
};
|
|
108
|
+
byId.set(id, entry); // overwrites prior with same fingerprint → updates appliedAt
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let entries = Array.from(byId.values());
|
|
112
|
+
// Sort newest-first so the cap drops the oldest.
|
|
113
|
+
entries.sort((a, b) => (b.appliedAt || '').localeCompare(a.appliedAt || ''));
|
|
114
|
+
if (entries.length > MAX_ENTRIES) entries = entries.slice(0, MAX_ENTRIES);
|
|
115
|
+
|
|
116
|
+
const next = { schemaVersion: SCHEMA_VERSION, entries };
|
|
117
|
+
|
|
118
|
+
const fullPath = resolve(projectDir, MEMORY_PATH);
|
|
119
|
+
const dir = dirname(fullPath);
|
|
120
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
121
|
+
writeFileSync(fullPath, JSON.stringify(next, null, 2) + '\n', 'utf-8');
|
|
122
|
+
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* True if a candidate fix (by fingerprint) has been applied before.
|
|
128
|
+
* Currently informational — future versions may use this to suppress.
|
|
129
|
+
*/
|
|
130
|
+
export function isFixRecorded(projectDir, candidate) {
|
|
131
|
+
const id = fingerprintFix(candidate);
|
|
132
|
+
return loadFixMemory(projectDir).entries.some(e => e.id === id);
|
|
133
|
+
}
|
|
@@ -102,6 +102,12 @@ export function applyMechanicalFix(projectDir, fix, opts = {}) {
|
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Apply a batch of fixes; returns a summary.
|
|
105
|
+
*
|
|
106
|
+
* M-2: When `opts.recordHistory` is true (default true when not in dry-run),
|
|
107
|
+
* each successfully applied fix is appended to `.docguard/fixed.json` so
|
|
108
|
+
* the project has a persistent audit trail. Pass `recordHistory: false` to
|
|
109
|
+
* disable (used by dry-run tests).
|
|
110
|
+
*
|
|
105
111
|
* @returns {{ applied: object[], skipped: object[] }}
|
|
106
112
|
*/
|
|
107
113
|
export function applyMechanicalFixes(projectDir, fixes, opts = {}) {
|
|
@@ -112,5 +118,21 @@ export function applyMechanicalFixes(projectDir, fixes, opts = {}) {
|
|
|
112
118
|
if (r.applied) applied.push({ ...fix, detail: r.detail });
|
|
113
119
|
else if (r.skipped) skipped.push({ ...fix, reason: r.skipped });
|
|
114
120
|
}
|
|
121
|
+
|
|
122
|
+
if (applied.length > 0 && opts.recordHistory !== false) {
|
|
123
|
+
// Lazy-import to avoid the circular risk and keep mechanical.mjs's
|
|
124
|
+
// synchronous-only contract clean for callers that don't want history.
|
|
125
|
+
import('./fix-memory.mjs').then(({ appendFixes }) => {
|
|
126
|
+
const entries = applied.map(f => ({
|
|
127
|
+
type: f.type,
|
|
128
|
+
file: f.file || f.path || '',
|
|
129
|
+
summary: f.summary || f.detail || `${f.type} applied`,
|
|
130
|
+
}));
|
|
131
|
+
appendFixes(projectDir, entries, opts.appliedBy || 'fix --write');
|
|
132
|
+
}).catch(() => {
|
|
133
|
+
// Never let history-write break the fix flow — it's auxiliary.
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
115
137
|
return { applied, skipped };
|
|
116
138
|
}
|
|
@@ -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 22 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 22 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 (22 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 22 validators.
|
|
18
18
|
|
|
19
19
|
## User Input
|
|
20
20
|
|