docguard-cli 0.13.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/cli/commands/impact.mjs +169 -0
- package/cli/docguard.mjs +4 -0
- package/cli/validators/cross-reference.mjs +101 -7
- package/cli/validators/freshness.mjs +41 -5
- package/cli/validators/generated-staleness.mjs +57 -2
- package/cli/validators/structure.mjs +25 -15
- 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 +2 -2
- 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
|
@@ -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/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) ──────────
|
|
@@ -514,6 +515,9 @@ async function main() {
|
|
|
514
515
|
case 'update':
|
|
515
516
|
await runUpgrade(projectDir, config, flags);
|
|
516
517
|
break;
|
|
518
|
+
case 'impact':
|
|
519
|
+
runImpact(projectDir, config, flags);
|
|
520
|
+
break;
|
|
517
521
|
default:
|
|
518
522
|
console.error(`${c.red}Unknown command: ${command}${c.reset}`);
|
|
519
523
|
console.log(`Run ${c.cyan}docguard --help${c.reset} for usage.`);
|
|
@@ -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
|
|
|
@@ -274,8 +363,13 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
274
363
|
const matches = anchors && (anchors.has(ref.anchor) || anchors.has(normalizedAnchor));
|
|
275
364
|
if (!matches) {
|
|
276
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}?)` : '';
|
|
277
371
|
warnings.push(
|
|
278
|
-
`${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}`
|
|
279
373
|
);
|
|
280
374
|
continue;
|
|
281
375
|
}
|
|
@@ -9,7 +9,24 @@
|
|
|
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
|
-
|
|
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
|
+
}
|
|
13
30
|
|
|
14
31
|
const IGNORE_DIRS = new Set([
|
|
15
32
|
'node_modules', '.git', '.next', 'dist', 'build',
|
|
@@ -22,10 +39,29 @@ const IGNORE_DIRS = new Set([
|
|
|
22
39
|
* Returns null if the file isn't tracked or git isn't available.
|
|
23
40
|
*/
|
|
24
41
|
function getLastGitDate(filePath, dir) {
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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.
|
|
55
|
+
try {
|
|
56
|
+
const result = execFileSync(
|
|
57
|
+
'git',
|
|
58
|
+
['log', '-1', '--format=%aI', '--', filePath],
|
|
59
|
+
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
60
|
+
).trim();
|
|
61
|
+
return result ? new Date(result) : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
29
65
|
}
|
|
30
66
|
|
|
31
67
|
/**
|
|
@@ -23,12 +23,38 @@
|
|
|
23
23
|
* @req SC-M1-004 — N/A when no source=code sections present in any doc
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
27
27
|
import { resolve, basename } from 'node:path';
|
|
28
28
|
|
|
29
29
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
30
30
|
import { getSection } from '../writers/sections.mjs';
|
|
31
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
|
+
|
|
32
58
|
export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
33
59
|
const result = { errors: [], warnings: [], passed: 0, total: 0 };
|
|
34
60
|
|
|
@@ -46,6 +72,9 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
46
72
|
|
|
47
73
|
// Walk each doc's source=code sections and compare against on-disk content.
|
|
48
74
|
let anySourceCodeSection = false;
|
|
75
|
+
const draftThresholdDays = (config.draftStalenessDays != null)
|
|
76
|
+
? Number(config.draftStalenessDays)
|
|
77
|
+
: DRAFT_STALENESS_DAYS;
|
|
49
78
|
|
|
50
79
|
for (const doc of plan.docs) {
|
|
51
80
|
const fullPath = resolve(projectDir, doc.path);
|
|
@@ -53,6 +82,29 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
53
82
|
let content;
|
|
54
83
|
try { content = readFileSync(fullPath, 'utf-8'); } catch { continue; }
|
|
55
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
|
+
|
|
56
108
|
for (const sec of doc.sections) {
|
|
57
109
|
if (sec.source !== 'code') continue;
|
|
58
110
|
anySourceCodeSection = true;
|
|
@@ -89,7 +141,10 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
89
141
|
}
|
|
90
142
|
}
|
|
91
143
|
|
|
92
|
-
|
|
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) {
|
|
93
148
|
return { ...result, applicable: false };
|
|
94
149
|
}
|
|
95
150
|
|
|
@@ -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
|
|
@@ -3,7 +3,7 @@ schema_version: "1.0"
|
|
|
3
3
|
extension:
|
|
4
4
|
id: "docguard"
|
|
5
5
|
name: "DocGuard — CDD Enforcement"
|
|
6
|
-
version: "0.13.
|
|
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.13.
|
|
9
|
+
version: 0.13.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.13.
|
|
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.13.
|
|
10
|
+
version: 0.13.1
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.13.
|
|
13
|
+
<!-- docguard:version: 0.13.1 -->
|
|
14
14
|
|
|
15
15
|
# DocGuard Guard Skill
|
|
16
16
|
|
|
@@ -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.13.
|
|
9
|
+
version: 0.13.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.13.
|
|
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.13.
|
|
9
|
+
version: 0.13.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.13.
|
|
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.13.
|
|
7
|
+
version: 0.13.1
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
10
|
|
package/package.json
CHANGED