docguard-cli 0.12.0 → 0.13.1
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 +2 -2
- package/cli/commands/fix.mjs +55 -0
- package/cli/commands/guard.mjs +20 -3
- package/cli/commands/impact.mjs +169 -0
- package/cli/commands/sync.mjs +50 -0
- package/cli/commands/trace.mjs +105 -0
- package/cli/docguard.mjs +13 -0
- package/cli/shared-git.mjs +0 -0
- package/cli/validators/cross-reference.mjs +110 -8
- package/cli/validators/docs-sync.mjs +15 -0
- package/cli/validators/freshness.mjs +31 -0
- package/cli/validators/generated-staleness.mjs +152 -0
- package/cli/validators/structure.mjs +25 -15
- 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/commands/guard.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -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/package.json +1 -1
- package/templates/commands/docguard.guard.md +2 -2
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
- [What is DocGuard?](#what-is-docguard)
|
|
18
18
|
- [Quick Start](#-quick-start)
|
|
19
19
|
- [Spec Kit Integration](#-spec-kit-integration)
|
|
20
|
-
- [
|
|
20
|
+
- [Usage](#usage)
|
|
21
21
|
- [Validators](#-validators)
|
|
22
22
|
- [Templates](#-templates)
|
|
23
23
|
- [AI Agent Support](#-ai-agent-support)
|
|
@@ -343,7 +343,7 @@ DocGuard provides AI agent slash commands for integrated workflows. Installed au
|
|
|
343
343
|
|
|
344
344
|
| Command | What It Does |
|
|
345
345
|
|:--------|:-------------|
|
|
346
|
-
| `/docguard.guard` | Run quality validation — check all
|
|
346
|
+
| `/docguard.guard` | Run quality validation — check all 22 validators |
|
|
347
347
|
| `/docguard.review` | Analyze doc quality and suggest improvements |
|
|
348
348
|
| `/docguard.fix` | Generate targeted fix prompts for specific issues |
|
|
349
349
|
| `/docguard.score` | Show CDD maturity score with category breakdown |
|
package/cli/commands/fix.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { c } from '../shared.mjs';
|
|
|
21
21
|
import { computeApiSurfaceDrift } from '../validators/api-surface.mjs';
|
|
22
22
|
import { removeEndpoints, hasGeneratedMarker } from '../writers/api-reference.mjs';
|
|
23
23
|
import { applyMechanicalFixes } from '../writers/mechanical.mjs';
|
|
24
|
+
import { loadFixMemory } from '../writers/fix-memory.mjs';
|
|
24
25
|
import { runGuardInternal } from './guard.mjs';
|
|
25
26
|
|
|
26
27
|
const API_DOC = 'docs-canonical/API-REFERENCE.md';
|
|
@@ -281,6 +282,55 @@ export function applyAllMechanicalFixes(projectDir, config, { force = false } =
|
|
|
281
282
|
return { applied, skipped, total: fixes.length };
|
|
282
283
|
}
|
|
283
284
|
|
|
285
|
+
/**
|
|
286
|
+
* M-2 — `docguard fix --history` shows the audit log of mechanical fixes
|
|
287
|
+
* that have been applied to this project. Reads `.docguard/fixed.json`
|
|
288
|
+
* and pretty-prints (or emits JSON when --format json).
|
|
289
|
+
*/
|
|
290
|
+
function runHistoryMode(projectDir, flags) {
|
|
291
|
+
const mem = loadFixMemory(projectDir);
|
|
292
|
+
const isJson = flags.format === 'json';
|
|
293
|
+
|
|
294
|
+
if (isJson) {
|
|
295
|
+
console.log(JSON.stringify(mem, null, 2));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (mem.entries.length === 0) {
|
|
300
|
+
console.log(`${c.bold}🗂 DocGuard Fix History${c.reset}`);
|
|
301
|
+
console.log(`${c.dim} No fixes recorded yet. Run \`docguard fix --write\` to start the audit log.${c.reset}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.log(`${c.bold}🗂 DocGuard Fix History${c.reset} ${c.dim}(${mem.entries.length} entries, newest first)${c.reset}\n`);
|
|
306
|
+
|
|
307
|
+
// Group by date for readability
|
|
308
|
+
const byDate = new Map();
|
|
309
|
+
for (const e of mem.entries) {
|
|
310
|
+
const day = (e.appliedAt || '').slice(0, 10);
|
|
311
|
+
if (!byDate.has(day)) byDate.set(day, []);
|
|
312
|
+
byDate.get(day).push(e);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Show the most recent N days (cap output at 20 entries)
|
|
316
|
+
let printed = 0;
|
|
317
|
+
for (const [day, dayEntries] of byDate) {
|
|
318
|
+
if (printed >= 20) break;
|
|
319
|
+
console.log(` ${c.cyan}${day}${c.reset} ${c.dim}(${dayEntries.length} fix${dayEntries.length > 1 ? 'es' : ''})${c.reset}`);
|
|
320
|
+
for (const e of dayEntries.slice(0, 5)) {
|
|
321
|
+
if (printed >= 20) break;
|
|
322
|
+
const time = (e.appliedAt || '').slice(11, 16);
|
|
323
|
+
console.log(` ${c.dim}${time}${c.reset} ${e.type} → ${c.cyan}${e.file}${c.reset} ${c.dim}${e.summary || ''}${c.reset}`);
|
|
324
|
+
printed++;
|
|
325
|
+
}
|
|
326
|
+
if (dayEntries.length > 5) console.log(` ${c.dim}... ${dayEntries.length - 5} more on this day${c.reset}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (mem.entries.length > 20) {
|
|
330
|
+
console.log(`\n ${c.dim}... ${mem.entries.length - 20} older entries. Use ${c.cyan}--format json${c.dim} for the full log.${c.reset}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
284
334
|
function runWriteMode(projectDir, config, flags) {
|
|
285
335
|
const isJson = flags.format === 'json';
|
|
286
336
|
const { applied, skipped, total } = applyAllMechanicalFixes(projectDir, config, { force: flags.force });
|
|
@@ -321,6 +371,11 @@ export function runFix(projectDir, config, flags) {
|
|
|
321
371
|
const autoFix = flags.auto || false;
|
|
322
372
|
const specificDoc = flags.doc || null;
|
|
323
373
|
|
|
374
|
+
// M-2: --history shows the audit trail of past mechanical fixes.
|
|
375
|
+
if (flags.history) {
|
|
376
|
+
return runHistoryMode(projectDir, flags);
|
|
377
|
+
}
|
|
378
|
+
|
|
324
379
|
// --write: deterministically APPLY mechanical fixes (no LLM). Currently:
|
|
325
380
|
// remove API-REFERENCE.md endpoints the OpenAPI spec confirms no longer exist.
|
|
326
381
|
if (flags.write) {
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { c, resolveSeverity } from '../shared.mjs';
|
|
11
11
|
import { detectAgentMode, isSpecKitInitialized } from '../ensure-skills.mjs';
|
|
12
12
|
import { checkUpgradeStatus } from './upgrade.mjs';
|
|
13
|
+
import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
|
|
13
14
|
import { validateStructure, validateDocSections } from '../validators/structure.mjs';
|
|
14
15
|
import { validateDrift } from '../validators/drift.mjs';
|
|
15
16
|
import { validateChangelog } from '../validators/changelog.mjs';
|
|
@@ -27,6 +28,7 @@ import { validateMetricsConsistency } from '../validators/metrics-consistency.mj
|
|
|
27
28
|
import { validateDocsCoverage } from '../validators/docs-coverage.mjs';
|
|
28
29
|
import { validateDocQuality } from '../validators/doc-quality.mjs';
|
|
29
30
|
import { validateCrossReferences } from '../validators/cross-reference.mjs';
|
|
31
|
+
import { validateGeneratedStaleness } from '../validators/generated-staleness.mjs';
|
|
30
32
|
import { validateTodoTracking } from '../validators/todo-tracking.mjs';
|
|
31
33
|
import { validateSchemaSync } from '../validators/schema-sync.mjs';
|
|
32
34
|
import { validateSpecKitIntegration } from '../scanners/speckit.mjs';
|
|
@@ -103,6 +105,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
103
105
|
{ key: 'schemaSync', name: 'Schema-Sync', fn: () => validateSchemaSync(projectDir, config) },
|
|
104
106
|
{ key: 'specKit', name: 'Spec-Kit', fn: () => validateSpecKitIntegration(projectDir, config) },
|
|
105
107
|
{ key: 'crossReference', name: 'Cross-Reference', fn: () => validateCrossReferences(projectDir, config) },
|
|
108
|
+
{ key: 'generatedStaleness', name: 'Generated-Staleness', fn: () => validateGeneratedStaleness(projectDir, config) },
|
|
106
109
|
// Metrics-Consistency runs post-loop (needs guard results)
|
|
107
110
|
];
|
|
108
111
|
|
|
@@ -197,7 +200,7 @@ function liteValidatorsConfig() {
|
|
|
197
200
|
'structure', 'docsSync', 'drift', 'changelog', 'testSpec', 'environment',
|
|
198
201
|
'security', 'architecture', 'freshness', 'traceability', 'docsDiff',
|
|
199
202
|
'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
|
|
200
|
-
'schemaSync', 'specKit', 'crossReference', 'metricsConsistency',
|
|
203
|
+
'schemaSync', 'specKit', 'crossReference', 'generatedStaleness', 'metricsConsistency',
|
|
201
204
|
];
|
|
202
205
|
const out = {};
|
|
203
206
|
for (const k of all) out[k] = CHANGED_ONLY_VALIDATORS.includes(k);
|
|
@@ -212,8 +215,22 @@ export function runGuard(projectDir, config, flags) {
|
|
|
212
215
|
// fast subset (Docs-Sync, Environment, API-Surface). Designed for husky/
|
|
213
216
|
// lefthook hooks; expects to finish in under 2 seconds.
|
|
214
217
|
if (flags.changedOnly) {
|
|
215
|
-
|
|
216
|
-
|
|
218
|
+
// Compute the set of changed files since the given ref (default HEAD~1 —
|
|
219
|
+
// the pre-commit common case: "files changed in this commit vs the last
|
|
220
|
+
// committed state"). Validators that opt into `config.changedFiles` can
|
|
221
|
+
// scope to this list; others run normally over the whole tree.
|
|
222
|
+
const ref = flags.since || 'HEAD~1';
|
|
223
|
+
const changed = isGitRepo(projectDir) ? changedFilesSince(projectDir, ref) : [];
|
|
224
|
+
config = {
|
|
225
|
+
...config,
|
|
226
|
+
validators: liteValidatorsConfig(),
|
|
227
|
+
changedFiles: changed,
|
|
228
|
+
changedSinceRef: ref,
|
|
229
|
+
};
|
|
230
|
+
const label = changed.length > 0
|
|
231
|
+
? `${changed.length} file(s) changed since ${ref}`
|
|
232
|
+
: `no changes since ${ref} — running all ${CHANGED_ONLY_VALIDATORS.length} lite validators on full tree`;
|
|
233
|
+
console.log(`${c.cyan}⚡ docguard guard --changed-only${c.reset} ${c.dim}(${label})${c.reset}\n`);
|
|
217
234
|
}
|
|
218
235
|
|
|
219
236
|
const data = runGuardInternal(projectDir, config);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Impact Command — S-11
|
|
3
|
+
*
|
|
4
|
+
* After a commit (or before opening a PR), shows which canonical doc
|
|
5
|
+
* sections reference any file that changed since `--since` (default HEAD~1).
|
|
6
|
+
* Combines the L-2 reverse-trace logic with the changed-files diff so you
|
|
7
|
+
* get "you should re-read these doc sections" in one command.
|
|
8
|
+
*
|
|
9
|
+
* Use cases:
|
|
10
|
+
* - Post-commit hook: `docguard impact --since HEAD~1` runs after each
|
|
11
|
+
* commit and reminds the developer which docs to update.
|
|
12
|
+
* - PR prep: `docguard impact --since main` shows the doc surface area
|
|
13
|
+
* touched by the whole branch.
|
|
14
|
+
*
|
|
15
|
+
* JSON mode emits a structured `{ changedFiles, affectedDocs }` payload
|
|
16
|
+
* for CI integrations and PR-comment bots.
|
|
17
|
+
*
|
|
18
|
+
* @req SC-S11-001 — impact reports per-file → doc mappings
|
|
19
|
+
* @req SC-S11-002 — files with no doc references are listed as "no impact"
|
|
20
|
+
* @req SC-S11-003 — --format json emits parseable structured output
|
|
21
|
+
* @req SC-S11-004 — non-code files (.md, .json, etc.) are skipped from impact analysis
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
|
+
import { resolve, basename } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { c } from '../shared.mjs';
|
|
28
|
+
import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* File extensions we consider "code" for the purposes of impact analysis.
|
|
32
|
+
* Match the set used by other validators (Docs-Sync, Freshness).
|
|
33
|
+
*/
|
|
34
|
+
const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)$/;
|
|
35
|
+
|
|
36
|
+
function escapeRegex(s) {
|
|
37
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find canonical doc references for a single file. Reuses the same three
|
|
42
|
+
* match strategies as trace --reverse for consistency: direct path,
|
|
43
|
+
* basename, backticked module name.
|
|
44
|
+
*/
|
|
45
|
+
function findReferences(file, docs) {
|
|
46
|
+
const refs = [];
|
|
47
|
+
const normalized = file.replace(/^\.\//, '');
|
|
48
|
+
const base = basename(normalized);
|
|
49
|
+
const stem = base.replace(/\.[^.]+$/, '');
|
|
50
|
+
const stemRe = new RegExp(`\`${escapeRegex(stem)}\``);
|
|
51
|
+
for (const [docName, lines] of docs) {
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const line = lines[i];
|
|
54
|
+
let kind = null;
|
|
55
|
+
if (line.includes(normalized)) kind = 'path';
|
|
56
|
+
else if (line.includes(base)) kind = 'basename';
|
|
57
|
+
else if (stemRe.test(line)) kind = 'module';
|
|
58
|
+
if (kind) {
|
|
59
|
+
refs.push({ doc: docName, line: i + 1, kind });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return refs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function runImpact(projectDir, _config, flags) {
|
|
67
|
+
const isJson = flags.format === 'json';
|
|
68
|
+
const since = flags.since || 'HEAD~1';
|
|
69
|
+
|
|
70
|
+
if (!isGitRepo(projectDir)) {
|
|
71
|
+
if (isJson) {
|
|
72
|
+
console.log(JSON.stringify({ since, error: 'not a git repository', changedFiles: [], affectedDocs: [] }, null, 2));
|
|
73
|
+
} else {
|
|
74
|
+
console.error(`${c.red}Not a git repository — impact requires git history.${c.reset}`);
|
|
75
|
+
}
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const changed = changedFilesSince(projectDir, since);
|
|
80
|
+
// Filter to code files only — markdown/json/yaml changes don't have "doc
|
|
81
|
+
// impact" in the same sense; they ARE the docs (or config).
|
|
82
|
+
const codeChanged = changed.filter(f => CODE_EXTENSIONS.test(f));
|
|
83
|
+
|
|
84
|
+
// Index canonical docs once
|
|
85
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
86
|
+
const docsIndex = new Map(); // docName → lines[]
|
|
87
|
+
if (existsSync(docsDir)) {
|
|
88
|
+
try {
|
|
89
|
+
for (const f of readdirSync(docsDir)) {
|
|
90
|
+
if (!f.endsWith('.md')) continue;
|
|
91
|
+
try {
|
|
92
|
+
const content = readFileSync(resolve(docsDir, f), 'utf-8');
|
|
93
|
+
docsIndex.set(f, content.split('\n'));
|
|
94
|
+
} catch { /* skip unreadable */ }
|
|
95
|
+
}
|
|
96
|
+
} catch { /* skip if dir unreadable */ }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Compute per-file references
|
|
100
|
+
const fileImpact = []; // { file, references: [{doc, line, kind}] }
|
|
101
|
+
for (const f of codeChanged) {
|
|
102
|
+
fileImpact.push({ file: f, references: findReferences(f, docsIndex) });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Roll up: which docs are affected, with all source files
|
|
106
|
+
const docMap = new Map(); // doc → Set<file>
|
|
107
|
+
for (const { file, references } of fileImpact) {
|
|
108
|
+
for (const r of references) {
|
|
109
|
+
if (!docMap.has(r.doc)) docMap.set(r.doc, new Set());
|
|
110
|
+
docMap.get(r.doc).add(file);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const affectedDocs = Array.from(docMap.entries()).map(([doc, files]) => ({
|
|
114
|
+
doc,
|
|
115
|
+
files: Array.from(files),
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
// ── JSON output ──
|
|
119
|
+
if (isJson) {
|
|
120
|
+
console.log(JSON.stringify({
|
|
121
|
+
since,
|
|
122
|
+
changedFiles: codeChanged,
|
|
123
|
+
ignoredFiles: changed.filter(f => !CODE_EXTENSIONS.test(f)),
|
|
124
|
+
affectedDocs,
|
|
125
|
+
timestamp: new Date().toISOString(),
|
|
126
|
+
}, null, 2));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Text output ──
|
|
131
|
+
console.log(`${c.bold}📊 DocGuard Impact${c.reset} ${c.dim}(since ${since})${c.reset}\n`);
|
|
132
|
+
|
|
133
|
+
if (changed.length === 0) {
|
|
134
|
+
console.log(` ${c.green}✅ No file changes since ${since}.${c.reset}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (codeChanged.length === 0) {
|
|
138
|
+
console.log(` ${c.dim}No code files changed (${changed.length} non-code files: ${changed.slice(0, 3).join(', ')}${changed.length > 3 ? '…' : ''}).${c.reset}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(` ${c.cyan}${codeChanged.length}${c.reset} code file(s) changed.\n`);
|
|
143
|
+
|
|
144
|
+
if (affectedDocs.length === 0) {
|
|
145
|
+
console.log(` ${c.yellow}⚠ No canonical docs reference any of the changed files.${c.reset}`);
|
|
146
|
+
console.log(` ${c.dim}This often means the changed code is undocumented. Consider:${c.reset}`);
|
|
147
|
+
console.log(` ${c.dim} - Running ${c.cyan}docguard generate --plan${c.dim} to add doc skeletons${c.reset}`);
|
|
148
|
+
console.log(` ${c.dim} - Reviewing whether the change belongs in an existing doc${c.reset}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log(` ${c.green}${affectedDocs.length}${c.reset} canonical doc(s) reference the changed files:\n`);
|
|
153
|
+
for (const { doc, files } of affectedDocs) {
|
|
154
|
+
console.log(` ${c.cyan}${doc}${c.reset} ${c.dim}(${files.length} file${files.length > 1 ? 's' : ''})${c.reset}`);
|
|
155
|
+
for (const f of files.slice(0, 5)) {
|
|
156
|
+
console.log(` ${c.dim}via${c.reset} ${f}`);
|
|
157
|
+
}
|
|
158
|
+
if (files.length > 5) console.log(` ${c.dim}... ${files.length - 5} more${c.reset}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// List code files with NO doc references — these may need new docs
|
|
162
|
+
const orphaned = fileImpact.filter(fi => fi.references.length === 0).map(fi => fi.file);
|
|
163
|
+
if (orphaned.length > 0) {
|
|
164
|
+
console.log(`\n ${c.yellow}${orphaned.length} changed file(s) have NO canonical doc reference:${c.reset}`);
|
|
165
|
+
for (const f of orphaned.slice(0, 5)) console.log(` ${c.dim}• ${f}${c.reset}`);
|
|
166
|
+
if (orphaned.length > 5) console.log(` ${c.dim}... ${orphaned.length - 5} more${c.reset}`);
|
|
167
|
+
console.log(` ${c.dim}These may be undocumented — review whether they belong in an existing doc.${c.reset}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
package/cli/commands/sync.mjs
CHANGED
|
@@ -34,6 +34,48 @@ function gitChangedFiles(projectDir, since) {
|
|
|
34
34
|
return [...new Set([...committed, ...working])];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* L-1: Map each `source: 'code'` section ID to a predicate that returns true
|
|
39
|
+
* when one of the changed file paths could plausibly affect it. Conservative
|
|
40
|
+
* by design — when in doubt we run the section's sync, never skip it.
|
|
41
|
+
*
|
|
42
|
+
* The predicates are matched against project-relative POSIX paths (the form
|
|
43
|
+
* `git diff --name-only` returns).
|
|
44
|
+
*/
|
|
45
|
+
const SECTION_FILE_MATCHERS = {
|
|
46
|
+
'tech-stack': (p) => /package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|pom\.xml$|Gemfile$/.test(p),
|
|
47
|
+
'frontend-modules': (p) => /(^|\/)(src\/)?(stores|hooks|contexts|features)\//.test(p),
|
|
48
|
+
'endpoints-table': (p) => /(^|\/)(routes|controllers|handlers|app\/api)\//.test(p)
|
|
49
|
+
|| /\.(yaml|yml|json)$/i.test(p) && /openapi|swagger/i.test(p),
|
|
50
|
+
'entities-table': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
|
|
51
|
+
|| /\.prisma$/.test(p),
|
|
52
|
+
'relationships': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
|
|
53
|
+
|| /\.prisma$/.test(p),
|
|
54
|
+
'screens-table': (p) => /(^|\/)(screens|pages|app)\//.test(p)
|
|
55
|
+
|| /\.(tsx|jsx)$/.test(p),
|
|
56
|
+
'flows': (p) => /(^|\/)(screens|pages|app|routes)\//.test(p),
|
|
57
|
+
'integrations-table':(p) => /package\.json$|pyproject\.toml$|requirements.*\.txt$|Cargo\.toml$/.test(p),
|
|
58
|
+
'features-table': (p) => /(^|\/)(features|domains)\//.test(p),
|
|
59
|
+
'features': (p) => /(^|\/)(features|domains)\//.test(p),
|
|
60
|
+
'env-vars-table': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p)
|
|
61
|
+
|| /\.(ts|tsx|js|jsx|mjs|py|go|rs|java|kt|rb)$/.test(p), // any code may use env
|
|
62
|
+
'setup': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decide whether a given code-truth section should be re-synced based on the
|
|
67
|
+
* set of changed files. Returns true when:
|
|
68
|
+
* - changedFiles is null/empty (no scope info → sync everything), OR
|
|
69
|
+
* - any changed file matches the section's known source patterns, OR
|
|
70
|
+
* - the section has no matcher registered (unknown → conservative: sync)
|
|
71
|
+
*/
|
|
72
|
+
function sectionTouchedByChanges(sectionId, changedFiles) {
|
|
73
|
+
if (!changedFiles || changedFiles.length === 0) return true;
|
|
74
|
+
const matcher = SECTION_FILE_MATCHERS[sectionId];
|
|
75
|
+
if (!matcher) return true; // unknown section → don't accidentally skip it
|
|
76
|
+
return changedFiles.some(matcher);
|
|
77
|
+
}
|
|
78
|
+
|
|
37
79
|
export function runSync(projectDir, config, flags) {
|
|
38
80
|
const plan = buildMemoryPlan(projectDir, config);
|
|
39
81
|
const apply = !!flags.write;
|
|
@@ -63,6 +105,14 @@ export function runSync(projectDir, config, flags) {
|
|
|
63
105
|
const existing = getSection(content, sec.id);
|
|
64
106
|
if (!existing) continue; // sync refreshes sections that already exist
|
|
65
107
|
if (existing.body.trim() === String(sec.body).trim()) continue; // already current
|
|
108
|
+
// L-1: when --since is provided, only update sections whose underlying
|
|
109
|
+
// source files appear in the changed set. Avoids spurious updates when
|
|
110
|
+
// the section's CONTENT would naturally drift (e.g. timestamp-driven
|
|
111
|
+
// counters) but no real source file changed.
|
|
112
|
+
if (changed !== null && !sectionTouchedByChanges(sec.id, changed)) {
|
|
113
|
+
skipped.push({ doc: doc.path, reason: `section ${sec.id} unchanged since ${flags.since} (no underlying source files in diff)` });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
66
116
|
codeSectionChanged = true;
|
|
67
117
|
updates.push({ doc: doc.path, section: sec.id, status: apply ? 'updated' : 'stale' });
|
|
68
118
|
if (apply) { content = replaceSection(content, sec.id, sec.body).content; docChanged = true; }
|
package/cli/commands/trace.mjs
CHANGED
|
@@ -84,7 +84,112 @@ const TRACE_MAP = {
|
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* L-2 / S-3 — Reverse trace: given a code file, find which canonical doc
|
|
89
|
+
* sections mention it. Mirror of the forward trace (doc → code).
|
|
90
|
+
*
|
|
91
|
+
* Match strategies (each yields a hit):
|
|
92
|
+
* 1. Direct path match: full project-relative path appears in doc text.
|
|
93
|
+
* 2. Basename match: e.g. `users.ts` appears (covers cases where the doc
|
|
94
|
+
* refers to the file by name without the full path).
|
|
95
|
+
* 3. Module name match: file stem (e.g. `users`) appears as a fenced
|
|
96
|
+
* `code` reference. Tighter than 2 — avoids matching common nouns.
|
|
97
|
+
*
|
|
98
|
+
* Output: one line per (doc, match-line) pair, with the surrounding context.
|
|
99
|
+
*/
|
|
100
|
+
export function runTraceReverse(projectDir, config, flags) {
|
|
101
|
+
const target = flags.args && flags.args[0];
|
|
102
|
+
if (!target) {
|
|
103
|
+
console.error(`${c.red}Error: trace --reverse requires a target path${c.reset}`);
|
|
104
|
+
console.log(`Usage: ${c.cyan}docguard trace --reverse <code-path>${c.reset}`);
|
|
105
|
+
console.log(`Example: ${c.cyan}docguard trace --reverse src/routes/users.ts${c.reset}`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Suppress chrome in JSON mode so stdout stays parseable.
|
|
110
|
+
const isJson = flags.format === 'json';
|
|
111
|
+
if (!isJson) {
|
|
112
|
+
console.log(`${c.bold}🔄 DocGuard Trace (reverse) — ${target}${c.reset}`);
|
|
113
|
+
console.log(`${c.dim} Finding canonical doc sections that reference this file...${c.reset}\n`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
117
|
+
if (!existsSync(docsDir)) {
|
|
118
|
+
if (isJson) {
|
|
119
|
+
console.log(JSON.stringify({ target, matches: [], error: 'no docs-canonical/ directory' }, null, 2));
|
|
120
|
+
} else {
|
|
121
|
+
console.log(` ${c.yellow}No docs-canonical/ directory found.${c.reset}`);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Normalize the target path: strip leading ./
|
|
127
|
+
const normalized = target.replace(/^\.\//, '');
|
|
128
|
+
const base = basename(normalized);
|
|
129
|
+
const stem = base.replace(/\.[^.]+$/, '');
|
|
130
|
+
|
|
131
|
+
const matches = []; // { doc, line, content, kind }
|
|
132
|
+
for (const f of readdirSync(docsDir)) {
|
|
133
|
+
if (!f.endsWith('.md')) continue;
|
|
134
|
+
const docPath = resolve(docsDir, f);
|
|
135
|
+
let content;
|
|
136
|
+
try { content = readFileSync(docPath, 'utf-8'); } catch { continue; }
|
|
137
|
+
const lines = content.split('\n');
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
const line = lines[i];
|
|
140
|
+
let kind = null;
|
|
141
|
+
if (line.includes(normalized)) kind = 'path';
|
|
142
|
+
else if (line.includes(base)) kind = 'basename';
|
|
143
|
+
else if (new RegExp(`\`${escapeRegex(stem)}\``).test(line)) kind = 'module';
|
|
144
|
+
if (kind) {
|
|
145
|
+
matches.push({ doc: f, line: i + 1, content: line.trim(), kind });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (flags.format === 'json') {
|
|
151
|
+
console.log(JSON.stringify({
|
|
152
|
+
target: normalized,
|
|
153
|
+
matches,
|
|
154
|
+
timestamp: new Date().toISOString(),
|
|
155
|
+
}, null, 2));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (matches.length === 0) {
|
|
160
|
+
console.log(` ${c.yellow}⚠️ No canonical doc references "${normalized}"${c.reset}`);
|
|
161
|
+
console.log(` ${c.dim}Consider documenting this file in docs-canonical/ARCHITECTURE.md or DATA-MODEL.md${c.reset}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Group by doc for readable output
|
|
166
|
+
const byDoc = new Map();
|
|
167
|
+
for (const m of matches) {
|
|
168
|
+
if (!byDoc.has(m.doc)) byDoc.set(m.doc, []);
|
|
169
|
+
byDoc.get(m.doc).push(m);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(` ${c.green}✅ ${matches.length} reference(s) across ${byDoc.size} doc(s):${c.reset}\n`);
|
|
173
|
+
for (const [doc, hits] of byDoc) {
|
|
174
|
+
console.log(` ${c.cyan}${doc}${c.reset} ${c.dim}(${hits.length} hit${hits.length > 1 ? 's' : ''})${c.reset}`);
|
|
175
|
+
for (const h of hits.slice(0, 5)) {
|
|
176
|
+
const trimmed = h.content.length > 80 ? h.content.slice(0, 77) + '…' : h.content;
|
|
177
|
+
console.log(` ${c.dim}L${h.line} [${h.kind}]${c.reset} ${trimmed}`);
|
|
178
|
+
}
|
|
179
|
+
if (hits.length > 5) console.log(` ${c.dim}... ${hits.length - 5} more${c.reset}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function escapeRegex(s) {
|
|
184
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
185
|
+
}
|
|
186
|
+
|
|
87
187
|
export function runTrace(projectDir, config, flags) {
|
|
188
|
+
// L-2: dispatch to reverse mode when --reverse is set.
|
|
189
|
+
if (flags.reverse) {
|
|
190
|
+
return runTraceReverse(projectDir, config, flags);
|
|
191
|
+
}
|
|
192
|
+
|
|
88
193
|
console.log(`${c.bold}🔗 DocGuard Trace — ${config.projectName}${c.reset}`);
|
|
89
194
|
console.log(`${c.dim} Directory: ${projectDir}${c.reset}`);
|
|
90
195
|
console.log(`${c.dim} Generating requirements traceability matrix...${c.reset}\n`);
|
package/cli/docguard.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { runTrace } from './commands/trace.mjs';
|
|
|
41
41
|
import { runLlms } from './commands/llms.mjs';
|
|
42
42
|
import { runSetup } from './commands/setup.mjs';
|
|
43
43
|
import { runUpgrade } from './commands/upgrade.mjs';
|
|
44
|
+
import { runImpact } from './commands/impact.mjs';
|
|
44
45
|
import { ensureSkills } from './ensure-skills.mjs';
|
|
45
46
|
|
|
46
47
|
// ── Shared constants (imported to break circular dependencies) ──────────
|
|
@@ -381,6 +382,15 @@ async function main() {
|
|
|
381
382
|
flags.apply = true;
|
|
382
383
|
} else if (args[i] === '--changed-only') {
|
|
383
384
|
flags.changedOnly = true;
|
|
385
|
+
} else if (args[i] === '--reverse') {
|
|
386
|
+
flags.reverse = true;
|
|
387
|
+
} else if (args[i] === '--history') {
|
|
388
|
+
flags.history = true;
|
|
389
|
+
} else if (!args[i].startsWith('--') && i > 0) {
|
|
390
|
+
// Positional args go into flags.args for commands that take them (e.g.
|
|
391
|
+
// `docguard trace --reverse <path>`). Skip the command itself (i === 0).
|
|
392
|
+
flags.args = flags.args || [];
|
|
393
|
+
flags.args.push(args[i]);
|
|
384
394
|
} else if (args[i] === '--doc' && args[i + 1]) {
|
|
385
395
|
flags.doc = args[i + 1];
|
|
386
396
|
i++;
|
|
@@ -505,6 +515,9 @@ async function main() {
|
|
|
505
515
|
case 'update':
|
|
506
516
|
await runUpgrade(projectDir, config, flags);
|
|
507
517
|
break;
|
|
518
|
+
case 'impact':
|
|
519
|
+
runImpact(projectDir, config, flags);
|
|
520
|
+
break;
|
|
508
521
|
default:
|
|
509
522
|
console.error(`${c.red}Unknown command: ${command}${c.reset}`);
|
|
510
523
|
console.log(`Run ${c.cyan}docguard --help${c.reset} for usage.`);
|
|
Binary file
|
|
@@ -155,14 +155,103 @@ export function extractRefs(content, sourcePath) {
|
|
|
155
155
|
* Resolve a target file path relative to a source markdown file.
|
|
156
156
|
* Returns the absolute path or null if the file doesn't exist.
|
|
157
157
|
*/
|
|
158
|
+
/**
|
|
159
|
+
* S-12: Suggest the closest matching anchor when a broken-anchor warning
|
|
160
|
+
* fires. Uses a cheap two-pass match:
|
|
161
|
+
* 1. Exact substring match (anchor is contained in or contains a heading)
|
|
162
|
+
* 2. Levenshtein-like edit-distance within a budget (max 3 edits)
|
|
163
|
+
*
|
|
164
|
+
* Returns the best-matching slug string, or null when no candidate scores
|
|
165
|
+
* well enough to suggest with confidence. Suggestion threshold tuned so
|
|
166
|
+
* cosmetic typos surface but unrelated headings don't false-positive.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} broken - the slug the user wrote (e.g. "athena-setup")
|
|
169
|
+
* @param {Set<string>} candidates - anchors that exist in the target doc
|
|
170
|
+
* @returns {string|null}
|
|
171
|
+
*/
|
|
172
|
+
export function suggestAnchor(broken, candidates) {
|
|
173
|
+
if (!broken || !candidates || candidates.size === 0) return null;
|
|
174
|
+
|
|
175
|
+
// Pass 1: substring containment — high-confidence match. Both sides must
|
|
176
|
+
// be at least 4 chars to avoid spurious matches against very short anchors
|
|
177
|
+
// (e.g. `#a` would otherwise match any broken slug containing the letter a).
|
|
178
|
+
const MIN_SUBSTRING = 4;
|
|
179
|
+
for (const c of candidates) {
|
|
180
|
+
if (c.length < MIN_SUBSTRING || broken.length < MIN_SUBSTRING) continue;
|
|
181
|
+
if (c.startsWith(broken) || broken.startsWith(c) || c.includes(broken) || broken.includes(c)) {
|
|
182
|
+
// Additionally require >= 50% overlap of the shorter into the longer.
|
|
183
|
+
// Avoids "user-id" matching "user-management-and-administration" via
|
|
184
|
+
// the bare "user" prefix.
|
|
185
|
+
const overlap = Math.min(c.length, broken.length) / Math.max(c.length, broken.length);
|
|
186
|
+
if (overlap >= 0.5) return c;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Pass 2: edit distance — pick the closest if within budget.
|
|
191
|
+
let best = null;
|
|
192
|
+
let bestDist = Infinity;
|
|
193
|
+
for (const c of candidates) {
|
|
194
|
+
// Cheap early-out: huge length difference can't be within budget.
|
|
195
|
+
if (Math.abs(c.length - broken.length) > 8) continue;
|
|
196
|
+
const d = editDistance(broken, c);
|
|
197
|
+
if (d < bestDist) { bestDist = d; best = c; }
|
|
198
|
+
}
|
|
199
|
+
// Budget: max(3, length / 5) — proportional to slug length but cap small.
|
|
200
|
+
const budget = Math.max(3, Math.floor(broken.length / 5));
|
|
201
|
+
if (bestDist <= budget) return best;
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Levenshtein edit distance. O(m·n) time, O(min) space. We bound input
|
|
207
|
+
* size before calling (S-12's pass 2 pre-filters), so a textbook impl is
|
|
208
|
+
* fine. Adding a dependency for one cheap routine isn't worth it.
|
|
209
|
+
*/
|
|
210
|
+
function editDistance(a, b) {
|
|
211
|
+
if (a === b) return 0;
|
|
212
|
+
if (!a.length) return b.length;
|
|
213
|
+
if (!b.length) return a.length;
|
|
214
|
+
let prev = new Array(b.length + 1);
|
|
215
|
+
let curr = new Array(b.length + 1);
|
|
216
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
217
|
+
for (let i = 1; i <= a.length; i++) {
|
|
218
|
+
curr[0] = i;
|
|
219
|
+
for (let j = 1; j <= b.length; j++) {
|
|
220
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
221
|
+
curr[j] = Math.min(
|
|
222
|
+
prev[j] + 1, // deletion
|
|
223
|
+
curr[j - 1] + 1, // insertion
|
|
224
|
+
prev[j - 1] + cost, // substitution
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
[prev, curr] = [curr, prev];
|
|
228
|
+
}
|
|
229
|
+
return prev[b.length];
|
|
230
|
+
}
|
|
231
|
+
|
|
158
232
|
function resolveTarget(sourcePath, targetRel, projectDir) {
|
|
159
233
|
if (!targetRel) return null;
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
234
|
+
// B-6: try BOTH the literal path and the URL-decoded form. CommonMark
|
|
235
|
+
// accepts `[name](../WU%20Documentation/foo.md)` for paths with spaces,
|
|
236
|
+
// and the decoded form (`../WU Documentation/foo.md`) is what hits the
|
|
237
|
+
// filesystem. The angle-bracket form `<../WU Documentation/foo.md>` is
|
|
238
|
+
// already non-URL-encoded by the time it reaches us. Try literal first
|
|
239
|
+
// (handles paths that legitimately contain `%`), then decoded.
|
|
240
|
+
const candidates = [targetRel];
|
|
241
|
+
try {
|
|
242
|
+
const decoded = decodeURIComponent(targetRel);
|
|
243
|
+
if (decoded !== targetRel) candidates.push(decoded);
|
|
244
|
+
} catch {
|
|
245
|
+
// Malformed % escapes — fall back to literal-only.
|
|
246
|
+
}
|
|
247
|
+
for (const cand of candidates) {
|
|
248
|
+
// Try relative to source's directory first
|
|
249
|
+
const fromSource = resolve(dirname(sourcePath), cand);
|
|
250
|
+
if (existsSync(fromSource)) return fromSource;
|
|
251
|
+
// Also try from project root (some authors write `docs-canonical/X.md`)
|
|
252
|
+
const fromRoot = resolve(projectDir, cand);
|
|
253
|
+
if (existsSync(fromRoot)) return fromRoot;
|
|
254
|
+
}
|
|
166
255
|
return null;
|
|
167
256
|
}
|
|
168
257
|
|
|
@@ -183,7 +272,15 @@ function collectCanonicalDocs(projectDir) {
|
|
|
183
272
|
}
|
|
184
273
|
} catch {}
|
|
185
274
|
}
|
|
186
|
-
|
|
275
|
+
// Standard root-level docs that are commonly cross-referenced. We index
|
|
276
|
+
// them so links like [CONTRIBUTING.md](CONTRIBUTING.md#some-section) can
|
|
277
|
+
// resolve. The list is conservative — adding everything would pull in
|
|
278
|
+
// boilerplate (LICENSE, NOTICE) that doesn't have meaningful headings.
|
|
279
|
+
for (const f of [
|
|
280
|
+
'README.md', 'AGENTS.md', 'CHANGELOG.md', 'DRIFT-LOG.md', 'ROADMAP.md',
|
|
281
|
+
'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md',
|
|
282
|
+
'PHILOSOPHY.md', 'STANDARD.md', 'COMPARISONS.md',
|
|
283
|
+
]) {
|
|
187
284
|
const p = resolve(projectDir, f);
|
|
188
285
|
if (existsSync(p)) docs.push(p);
|
|
189
286
|
}
|
|
@@ -266,8 +363,13 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
266
363
|
const matches = anchors && (anchors.has(ref.anchor) || anchors.has(normalizedAnchor));
|
|
267
364
|
if (!matches) {
|
|
268
365
|
const where = targetPath === docPath ? 'same doc' : basename(targetPath);
|
|
366
|
+
// S-12: suggest the closest matching anchor when there's a near-miss.
|
|
367
|
+
// Three of five wu user-fixes were "heading renamed, link not updated"
|
|
368
|
+
// — a suggested-slug hint makes those deterministic-fixable.
|
|
369
|
+
const suggestion = anchors ? suggestAnchor(normalizedAnchor, anchors) : null;
|
|
370
|
+
const hint = suggestion ? ` (did you mean #${suggestion}?)` : '';
|
|
269
371
|
warnings.push(
|
|
270
|
-
`${docName}:${ref.line} — broken anchor: "#${ref.anchor}" in ${where} doesn't match any heading`
|
|
372
|
+
`${docName}:${ref.line} — broken anchor: "#${ref.anchor}" in ${where} doesn't match any heading${hint}`
|
|
271
373
|
);
|
|
272
374
|
continue;
|
|
273
375
|
}
|
|
@@ -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);
|
|
@@ -10,6 +10,24 @@ 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
12
|
|
|
13
|
+
// B-5 fix (v0.13.1): use a defensive import. If `shared-git.mjs` is missing
|
|
14
|
+
// or unloadable in the end-user install (whatever the root cause — partial
|
|
15
|
+
// upgrade, package corruption, weird module resolution), we fall back to
|
|
16
|
+
// the original inline implementation below. The worst-case outcome is
|
|
17
|
+
// "rename detection doesn't work", NOT "validator crashes with a useless
|
|
18
|
+
// ReferenceError". Reported by wu-whatsappinbox v0.13.x feedback.
|
|
19
|
+
let _sharedGetLastCommitDate = null;
|
|
20
|
+
try {
|
|
21
|
+
const mod = await import('../shared-git.mjs');
|
|
22
|
+
if (mod && typeof mod.getLastCommitDate === 'function') {
|
|
23
|
+
_sharedGetLastCommitDate = mod.getLastCommitDate;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Silently fall back. Test in tests/freshness-resilience.test.mjs verifies
|
|
27
|
+
// the validator stays operational when the import goes sideways.
|
|
28
|
+
_sharedGetLastCommitDate = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
const IGNORE_DIRS = new Set([
|
|
14
32
|
'node_modules', '.git', '.next', 'dist', 'build',
|
|
15
33
|
'coverage', '.cache', '__pycache__', '.venv', 'vendor',
|
|
@@ -21,6 +39,19 @@ const IGNORE_DIRS = new Set([
|
|
|
21
39
|
* Returns null if the file isn't tracked or git isn't available.
|
|
22
40
|
*/
|
|
23
41
|
function getLastGitDate(filePath, dir) {
|
|
42
|
+
// Prefer the shared-git --follow-aware path when available (v0.13+ default).
|
|
43
|
+
// Fall back to inline implementation if the import failed at module load —
|
|
44
|
+
// this guarantees the validator never throws a ReferenceError even in
|
|
45
|
+
// environments where ESM resolution is broken.
|
|
46
|
+
if (_sharedGetLastCommitDate) {
|
|
47
|
+
try {
|
|
48
|
+
return _sharedGetLastCommitDate(dir, filePath);
|
|
49
|
+
} catch {
|
|
50
|
+
// fall through to inline
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Inline pre-v0.13 implementation — works without rename detection, but
|
|
54
|
+
// is guaranteed to not throw a "not defined" error.
|
|
24
55
|
try {
|
|
25
56
|
const result = execFileSync(
|
|
26
57
|
'git',
|
|
@@ -0,0 +1,152 @@
|
|
|
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, statSync } 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
|
+
/**
|
|
33
|
+
* S-7: how long a generated doc may sit in `status: draft` before we warn.
|
|
34
|
+
* 14 days is the v0.13.1 default — long enough to absorb a typical sprint,
|
|
35
|
+
* short enough to surface forgotten skeletons before they rot.
|
|
36
|
+
*/
|
|
37
|
+
const DRAFT_STALENESS_DAYS = 14;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse the frontmatter `status:` field from a markdown doc.
|
|
41
|
+
* Returns the trimmed value or null. Tolerant of either YAML-style
|
|
42
|
+
* fences (`---`) or HTML-comment-style (`<!-- status: draft -->`) markers.
|
|
43
|
+
*/
|
|
44
|
+
function extractDocStatus(content) {
|
|
45
|
+
if (!content) return null;
|
|
46
|
+
// YAML frontmatter: --- ... ---
|
|
47
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
48
|
+
if (fmMatch) {
|
|
49
|
+
const sm = fmMatch[1].match(/^\s*status:\s*(\S+)\s*$/m);
|
|
50
|
+
if (sm) return sm[1].toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
// Inline `<!-- status: draft -->` marker (common in docguard:generated docs).
|
|
53
|
+
const inline = content.match(/<!--\s*status:\s*(\w+)\s*-->/i);
|
|
54
|
+
if (inline) return inline[1].toLowerCase();
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
59
|
+
const result = { errors: [], warnings: [], passed: 0, total: 0 };
|
|
60
|
+
|
|
61
|
+
// Build the canonical memory plan (what the docs SHOULD contain). If this
|
|
62
|
+
// fails or produces no docs, the validator is N/A.
|
|
63
|
+
let plan;
|
|
64
|
+
try {
|
|
65
|
+
plan = buildMemoryPlan(projectDir, config);
|
|
66
|
+
} catch {
|
|
67
|
+
return { ...result, applicable: false };
|
|
68
|
+
}
|
|
69
|
+
if (!plan || !Array.isArray(plan.docs) || plan.docs.length === 0) {
|
|
70
|
+
return { ...result, applicable: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Walk each doc's source=code sections and compare against on-disk content.
|
|
74
|
+
let anySourceCodeSection = false;
|
|
75
|
+
const draftThresholdDays = (config.draftStalenessDays != null)
|
|
76
|
+
? Number(config.draftStalenessDays)
|
|
77
|
+
: DRAFT_STALENESS_DAYS;
|
|
78
|
+
|
|
79
|
+
for (const doc of plan.docs) {
|
|
80
|
+
const fullPath = resolve(projectDir, doc.path);
|
|
81
|
+
if (!existsSync(fullPath)) continue;
|
|
82
|
+
let content;
|
|
83
|
+
try { content = readFileSync(fullPath, 'utf-8'); } catch { continue; }
|
|
84
|
+
|
|
85
|
+
// S-7: a docguard:generated doc with frontmatter `status: draft` that
|
|
86
|
+
// hasn't been updated in N days is probably a forgotten skeleton.
|
|
87
|
+
// Counted as a check (so total reflects it) and warned when stale.
|
|
88
|
+
const status = extractDocStatus(content);
|
|
89
|
+
if (status === 'draft') {
|
|
90
|
+
result.total++;
|
|
91
|
+
try {
|
|
92
|
+
const mtime = statSync(fullPath).mtime;
|
|
93
|
+
const ageDays = (Date.now() - mtime.getTime()) / (1000 * 60 * 60 * 24);
|
|
94
|
+
if (ageDays > draftThresholdDays) {
|
|
95
|
+
result.warnings.push(
|
|
96
|
+
`${basename(doc.path)} has been in \`status: draft\` for ${Math.floor(ageDays)} days. ` +
|
|
97
|
+
`Promote to status:current or remove. Run \`/docguard.fix --doc ${basename(doc.path)}\` to draft the prose.`
|
|
98
|
+
);
|
|
99
|
+
} else {
|
|
100
|
+
result.passed++;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Couldn't stat the file — skip the staleness check, don't count it.
|
|
104
|
+
result.total--;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const sec of doc.sections) {
|
|
109
|
+
if (sec.source !== 'code') continue;
|
|
110
|
+
anySourceCodeSection = true;
|
|
111
|
+
|
|
112
|
+
const onDisk = getSection(content, sec.id);
|
|
113
|
+
// If the section isn't present in the doc at all, that's a Structure /
|
|
114
|
+
// Doc Sections concern — not ours. Skip without counting.
|
|
115
|
+
if (!onDisk) continue;
|
|
116
|
+
|
|
117
|
+
result.total++;
|
|
118
|
+
const expected = String(sec.body || '').trim();
|
|
119
|
+
const actual = String(onDisk.body || '').trim();
|
|
120
|
+
|
|
121
|
+
if (expected === actual) {
|
|
122
|
+
result.passed++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Compute a short diff hint — first changed line — so the warning is
|
|
127
|
+
// actionable without dumping the whole section.
|
|
128
|
+
const exp = expected.split('\n');
|
|
129
|
+
const act = actual.split('\n');
|
|
130
|
+
let firstDiff = -1;
|
|
131
|
+
for (let i = 0; i < Math.max(exp.length, act.length); i++) {
|
|
132
|
+
if (exp[i] !== act[i]) { firstDiff = i; break; }
|
|
133
|
+
}
|
|
134
|
+
const hint = firstDiff >= 0
|
|
135
|
+
? ` (first drift at line ${firstDiff + 1} of section: "${(act[firstDiff] || '').slice(0, 60)}…" vs scanner: "${(exp[firstDiff] || '').slice(0, 60)}…")`
|
|
136
|
+
: '';
|
|
137
|
+
|
|
138
|
+
result.warnings.push(
|
|
139
|
+
`${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// S-7: even when no source=code sections exist, a draft-status check
|
|
145
|
+
// counts the validator as applicable. Only return N/A when we genuinely
|
|
146
|
+
// had nothing to evaluate.
|
|
147
|
+
if (!anySourceCodeSection && result.total === 0) {
|
|
148
|
+
return { ...result, applicable: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
@@ -19,23 +19,33 @@ export function validateStructure(projectDir, config) {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// Check agent file (any one is fine)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
// Check agent file (any one is fine) — defensive: tolerate missing config
|
|
23
|
+
// shapes (B-5 class of safety net: never let a config gap leak as a
|
|
24
|
+
// ReferenceError / TypeError into the user's guard output).
|
|
25
|
+
const agentFiles = Array.isArray(config.requiredFiles?.agentFile)
|
|
26
|
+
? config.requiredFiles.agentFile
|
|
27
|
+
: (typeof config.requiredFiles?.agentFile === 'string' ? [config.requiredFiles.agentFile] : []);
|
|
28
|
+
if (agentFiles.length > 0) {
|
|
29
|
+
results.total++;
|
|
30
|
+
const agentFileFound = agentFiles.some(f =>
|
|
31
|
+
existsSync(resolve(projectDir, f))
|
|
32
|
+
);
|
|
33
|
+
if (agentFileFound) {
|
|
34
|
+
results.passed++;
|
|
35
|
+
} else {
|
|
36
|
+
results.errors.push(`Missing agent file: ${agentFiles.join(' or ')}`);
|
|
37
|
+
}
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
// Check changelog
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
results.
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// Check changelog — same defensive pattern.
|
|
41
|
+
const changelogPath = config.requiredFiles?.changelog;
|
|
42
|
+
if (changelogPath) {
|
|
43
|
+
results.total++;
|
|
44
|
+
if (existsSync(resolve(projectDir, changelogPath))) {
|
|
45
|
+
results.passed++;
|
|
46
|
+
} else {
|
|
47
|
+
results.errors.push(`Missing required file: ${changelogPath}`);
|
|
48
|
+
}
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
// Check drift log
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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.13.1"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with 19 automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. Zero NPM runtime dependencies."
|
|
8
8
|
author: "Ricardo Accioly"
|
|
9
9
|
repository: "https://github.com/raccioly/docguard"
|
|
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.13.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.13.1 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Fix Skill
|
|
15
15
|
|
|
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
|
|
|
7
7
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
8
8
|
metadata:
|
|
9
9
|
author: docguard
|
|
10
|
-
version: 0.
|
|
10
|
+
version: 0.13.1
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.13.1 -->
|
|
14
14
|
|
|
15
15
|
# DocGuard Guard Skill
|
|
16
16
|
|
|
@@ -139,7 +139,7 @@ For each finding, provide a **specific, actionable fix** — not "fix the issue"
|
|
|
139
139
|
|
|
140
140
|
Based on the triage results:
|
|
141
141
|
|
|
142
|
-
- **If all PASS**: "All
|
|
142
|
+
- **If all PASS**: "All 22 validators passed. Project is CDD-compliant. Ready to commit."
|
|
143
143
|
- **If only MEDIUM/LOW warnings**: "Non-blocking warnings found. Safe to commit, but consider running `/docguard.fix` for automated remediation."
|
|
144
144
|
- **If HIGH or CRITICAL failures**: "Blocking issues found. Fix these before committing. Suggest running `/docguard.fix --doc [most impactful doc]` next."
|
|
145
145
|
|
|
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.13.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.13.1 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Review Skill
|
|
15
15
|
|
|
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.13.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.13.1 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Score Skill
|
|
15
15
|
|
|
@@ -4,7 +4,7 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
|
|
|
4
4
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
5
5
|
metadata:
|
|
6
6
|
author: docguard
|
|
7
|
-
version: 0.
|
|
7
|
+
version: 0.13.1
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Run DocGuard guard validation — check all
|
|
2
|
+
description: Run DocGuard guard validation — check all 22 validators and fix any issues
|
|
3
3
|
handoffs:
|
|
4
4
|
- label: Fix Issues
|
|
5
5
|
agent: docguard.fix
|
|
@@ -19,7 +19,7 @@ You are an AI agent enforcing Canonical-Driven Development (CDD) compliance usin
|
|
|
19
19
|
npx docguard-cli guard
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Read the output. It shows pass (✅), warn (⚠️), or fail (❌) for each of the
|
|
22
|
+
Read the output. It shows pass (✅), warn (⚠️), or fail (❌) for each of the 22 validators:
|
|
23
23
|
|
|
24
24
|
| Priority | Validators |
|
|
25
25
|
|----------|-----------|
|