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.
Files changed (31) hide show
  1. package/README.md +35 -16
  2. package/cli/commands/fix.mjs +55 -0
  3. package/cli/commands/guard.mjs +129 -5
  4. package/cli/commands/init.mjs +52 -1
  5. package/cli/commands/sync.mjs +50 -0
  6. package/cli/commands/trace.mjs +105 -0
  7. package/cli/commands/upgrade.mjs +250 -0
  8. package/cli/docguard.mjs +39 -3
  9. package/cli/shared-git.mjs +0 -0
  10. package/cli/shared-ignore.mjs +50 -0
  11. package/cli/shared.mjs +62 -0
  12. package/cli/validators/cross-reference.mjs +289 -0
  13. package/cli/validators/docs-sync.mjs +15 -0
  14. package/cli/validators/freshness.mjs +5 -10
  15. package/cli/validators/generated-staleness.mjs +97 -0
  16. package/cli/writers/fix-memory.mjs +133 -0
  17. package/cli/writers/mechanical.mjs +22 -0
  18. package/commands/docguard.guard.md +2 -2
  19. package/docs/quickstart.md +1 -1
  20. package/extensions/spec-kit-docguard/README.md +1 -1
  21. package/extensions/spec-kit-docguard/commands/guard.md +1 -1
  22. package/extensions/spec-kit-docguard/extension.yml +11 -1
  23. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  24. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -3
  25. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  26. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  27. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +1 -1
  28. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +51 -0
  29. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +48 -0
  30. package/package.json +1 -1
  31. 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
- try {
25
- const result = execFileSync(
26
- 'git',
27
- ['log', '-1', '--format=%aI', '--', filePath],
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 20 validators
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 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 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
  |-----------|---------------|
@@ -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 (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
- - **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 22 validators.
18
18
 
19
19
  ## User Input
20
20