docguard-cli 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/fix.mjs +9 -3
- package/cli/commands/guard.mjs +34 -5
- package/cli/commands/impact.mjs +169 -0
- package/cli/commands/upgrade.mjs +76 -2
- package/cli/docguard.mjs +13 -0
- package/cli/shared-source.mjs +10 -0
- package/cli/validators/api-surface.mjs +16 -0
- package/cli/validators/cross-reference.mjs +101 -7
- package/cli/validators/freshness.mjs +41 -5
- package/cli/validators/generated-staleness.mjs +71 -3
- package/cli/validators/structure.mjs +25 -15
- package/cli/writers/fix-memory.mjs +49 -1
- package/cli/writers/mechanical.mjs +75 -0
- 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
package/cli/commands/fix.mjs
CHANGED
|
@@ -272,13 +272,16 @@ IMPORTANT: A new contributor should be able to follow this doc and have the proj
|
|
|
272
272
|
* insert-changelog-unreleased (Changelog).
|
|
273
273
|
* @returns {{ applied: object[], skipped: object[], total: number }}
|
|
274
274
|
*/
|
|
275
|
-
export function applyAllMechanicalFixes(projectDir, config,
|
|
275
|
+
export function applyAllMechanicalFixes(projectDir, config, opts = {}) {
|
|
276
|
+
const { force = false, forceRedo = false } = opts;
|
|
276
277
|
const guardData = runGuardInternal(projectDir, config);
|
|
277
278
|
const fixes = [];
|
|
278
279
|
for (const v of guardData.validators) {
|
|
279
280
|
if (Array.isArray(v.fixes)) fixes.push(...v.fixes);
|
|
280
281
|
}
|
|
281
|
-
|
|
282
|
+
// v0.14-P1: forwarding forceRedo so users with `--force-redo` can override
|
|
283
|
+
// ping-pong suppression for a specific fix they actually want re-applied.
|
|
284
|
+
const { applied, skipped } = applyMechanicalFixes(projectDir, fixes, { force, forceRedo });
|
|
282
285
|
return { applied, skipped, total: fixes.length };
|
|
283
286
|
}
|
|
284
287
|
|
|
@@ -333,7 +336,10 @@ function runHistoryMode(projectDir, flags) {
|
|
|
333
336
|
|
|
334
337
|
function runWriteMode(projectDir, config, flags) {
|
|
335
338
|
const isJson = flags.format === 'json';
|
|
336
|
-
const { applied, skipped, total } = applyAllMechanicalFixes(projectDir, config, {
|
|
339
|
+
const { applied, skipped, total } = applyAllMechanicalFixes(projectDir, config, {
|
|
340
|
+
force: flags.force,
|
|
341
|
+
forceRedo: flags.forceRedo, // v0.14-P1: bypass ping-pong suppression
|
|
342
|
+
});
|
|
337
343
|
|
|
338
344
|
if (isJson) {
|
|
339
345
|
console.log(JSON.stringify({
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -109,27 +109,36 @@ export function runGuardInternal(projectDir, config) {
|
|
|
109
109
|
// Metrics-Consistency runs post-loop (needs guard results)
|
|
110
110
|
];
|
|
111
111
|
|
|
112
|
+
// v0.14-Q2: per-validator timing. Cheap (one `performance.now()` pair per
|
|
113
|
+
// validator) and the data is what we'd need to optimize anything later.
|
|
114
|
+
// Exposed via --profile in the public guard.
|
|
112
115
|
for (const { key, name, fn } of validatorMap) {
|
|
113
116
|
if (validators[key] === false) {
|
|
114
|
-
results.push({ name, key, status: 'skipped', quality: null, errors: [], warnings: [], passed: 0, total: 0 });
|
|
117
|
+
results.push({ name, key, status: 'skipped', quality: null, errors: [], warnings: [], passed: 0, total: 0, durationMs: 0 });
|
|
115
118
|
continue;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
const start = performance.now();
|
|
118
122
|
try {
|
|
119
123
|
const result = fn();
|
|
120
|
-
|
|
124
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
125
|
+
results.push({ ...result, name, key, durationMs, ...classifyResult(result) });
|
|
121
126
|
} catch (err) {
|
|
122
|
-
|
|
127
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
128
|
+
results.push({ name, key, status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
|
|
123
129
|
}
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
// ── Metrics-Consistency runs AFTER all other validators (needs their results) ──
|
|
127
133
|
if (validators.metricsConsistency !== false) {
|
|
134
|
+
const start = performance.now();
|
|
128
135
|
try {
|
|
129
136
|
const result = validateMetricsConsistency(projectDir, config, results);
|
|
130
|
-
|
|
137
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
138
|
+
results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', durationMs, ...classifyResult(result) });
|
|
131
139
|
} catch (err) {
|
|
132
|
-
|
|
140
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
141
|
+
results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
|
|
133
142
|
}
|
|
134
143
|
}
|
|
135
144
|
|
|
@@ -326,6 +335,26 @@ export function runGuard(projectDir, config, flags) {
|
|
|
326
335
|
const badgeUrl = `https://img.shields.io/badge/CDD_Guard-${data.passed}%2F${data.total}_passed-${bColor}`;
|
|
327
336
|
console.log(`\n ${c.dim}📎 Badge: ${c.reset}`);
|
|
328
337
|
|
|
338
|
+
// v0.14-Q2: --timings prints per-validator timing, sorted slowest-first.
|
|
339
|
+
// Designed for self-diagnosis on slow repos: shows exactly which validator
|
|
340
|
+
// to optimize first. Cheap to opt into; off by default to keep output clean.
|
|
341
|
+
// (Originally proposed as `--profile` but that flag is taken by `init`.)
|
|
342
|
+
if (flags.timings) {
|
|
343
|
+
console.log(`\n ${c.bold}⏱ Profile${c.reset} ${c.dim}(per-validator wall time, slowest first)${c.reset}`);
|
|
344
|
+
const timed = data.validators
|
|
345
|
+
.filter(v => typeof v.durationMs === 'number' && v.status !== 'skipped')
|
|
346
|
+
.sort((a, b) => b.durationMs - a.durationMs);
|
|
347
|
+
const total = timed.reduce((sum, v) => sum + v.durationMs, 0);
|
|
348
|
+
for (const v of timed.slice(0, 10)) {
|
|
349
|
+
const pct = total > 0 ? Math.round((v.durationMs / total) * 100) : 0;
|
|
350
|
+
const bar = '▇'.repeat(Math.max(1, Math.round(pct / 5)));
|
|
351
|
+
console.log(` ${v.durationMs.toFixed(1).padStart(7)}ms ${pct.toString().padStart(2)}% ${bar.padEnd(20)} ${v.name}`);
|
|
352
|
+
}
|
|
353
|
+
if (timed.length > 10) console.log(` ${c.dim}... ${timed.length - 10} faster validators omitted${c.reset}`);
|
|
354
|
+
console.log(` ${c.dim}─────────${c.reset}`);
|
|
355
|
+
console.log(` ${c.bold}${total.toFixed(1).padStart(7)}ms${c.reset} ${c.dim}total validator time${c.reset}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
329
358
|
// Schema upgrade nudge — fires when the project's .docguard.json schema is
|
|
330
359
|
// behind the CLI's CURRENT_SCHEMA_VERSION. Cheap, file-local check; no
|
|
331
360
|
// network access. Suppressed in JSON output to keep machine consumers clean.
|
|
@@ -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/upgrade.mjs
CHANGED
|
@@ -124,6 +124,65 @@ function applyCliUpgrade() {
|
|
|
124
124
|
return r;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* v0.14-P4: open a PR with the schema migration. Used when the team wants
|
|
129
|
+
* a reviewable change instead of an in-place edit. Requires `gh` CLI on
|
|
130
|
+
* PATH. Returns { ok: bool, prUrl?: string, error?: string }.
|
|
131
|
+
*/
|
|
132
|
+
function openUpgradePR(projectDir, migratedConfig, fromVersion, toVersion) {
|
|
133
|
+
// Pre-flight: gh must be installed
|
|
134
|
+
const which = spawnSync('which', ['gh'], { encoding: 'utf-8' });
|
|
135
|
+
if (which.status !== 0) {
|
|
136
|
+
return { ok: false, error: 'gh CLI not found. Install: https://cli.github.com' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const branch = `docguard/upgrade-schema-${toVersion}-${Date.now().toString(36)}`;
|
|
140
|
+
// Branch off current HEAD
|
|
141
|
+
let r = spawnSync('git', ['checkout', '-b', branch], { cwd: projectDir, encoding: 'utf-8' });
|
|
142
|
+
if (r.status !== 0) return { ok: false, error: `git checkout failed: ${r.stderr || r.stdout}` };
|
|
143
|
+
|
|
144
|
+
// Write the migrated config
|
|
145
|
+
try {
|
|
146
|
+
writeFileSync(
|
|
147
|
+
resolve(projectDir, '.docguard.json'),
|
|
148
|
+
JSON.stringify(migratedConfig, null, 2) + '\n',
|
|
149
|
+
'utf-8'
|
|
150
|
+
);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return { ok: false, error: `write .docguard.json failed: ${e.message}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Commit
|
|
156
|
+
r = spawnSync('git', ['add', '.docguard.json'], { cwd: projectDir, encoding: 'utf-8' });
|
|
157
|
+
if (r.status !== 0) return { ok: false, error: `git add failed: ${r.stderr}` };
|
|
158
|
+
|
|
159
|
+
const commitMsg = `chore(docguard): migrate .docguard.json schema ${fromVersion} → ${toVersion}\n\nAutomated migration via \`docguard upgrade --apply --pr\`.`;
|
|
160
|
+
r = spawnSync('git', ['commit', '-m', commitMsg], { cwd: projectDir, encoding: 'utf-8' });
|
|
161
|
+
if (r.status !== 0) return { ok: false, error: `git commit failed: ${r.stderr || r.stdout}` };
|
|
162
|
+
|
|
163
|
+
// Push
|
|
164
|
+
r = spawnSync('git', ['push', '-u', 'origin', branch], { cwd: projectDir, encoding: 'utf-8' });
|
|
165
|
+
if (r.status !== 0) return { ok: false, error: `git push failed: ${r.stderr || r.stdout}` };
|
|
166
|
+
|
|
167
|
+
// Open PR
|
|
168
|
+
const prBody =
|
|
169
|
+
`Automated schema migration from \`${fromVersion}\` → \`${toVersion}\`.\n\n` +
|
|
170
|
+
`This PR was opened by \`docguard upgrade --apply --pr\`. It updates the\n` +
|
|
171
|
+
`\`.docguard.json\` schema version and any additive fields the new schema\n` +
|
|
172
|
+
`introduces (e.g. \`severity: {}\` for v0.5).\n\n` +
|
|
173
|
+
`Review and merge to keep your team's DocGuard config in sync.\n\n` +
|
|
174
|
+
`> 🤖 Generated by [DocGuard](https://github.com/raccioly/docguard)`;
|
|
175
|
+
r = spawnSync('gh', [
|
|
176
|
+
'pr', 'create',
|
|
177
|
+
'--title', `chore(docguard): migrate schema ${fromVersion} → ${toVersion}`,
|
|
178
|
+
'--body', prBody,
|
|
179
|
+
], { cwd: projectDir, encoding: 'utf-8' });
|
|
180
|
+
if (r.status !== 0) return { ok: false, error: `gh pr create failed: ${r.stderr || r.stdout}` };
|
|
181
|
+
|
|
182
|
+
const prUrl = (r.stdout || '').trim().split('\n').pop();
|
|
183
|
+
return { ok: true, prUrl };
|
|
184
|
+
}
|
|
185
|
+
|
|
127
186
|
export async function runUpgrade(projectDir, _config, flags) {
|
|
128
187
|
const checkOnly = flags.checkOnly || flags['check-only'];
|
|
129
188
|
const apply = flags.apply;
|
|
@@ -222,8 +281,23 @@ export async function runUpgrade(projectDir, _config, flags) {
|
|
|
222
281
|
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
223
282
|
const { changed, newConfig } = migrateSchema(cfg, projectSchema);
|
|
224
283
|
if (changed) {
|
|
225
|
-
|
|
226
|
-
|
|
284
|
+
// v0.14-P4: --pr opens a PR for review instead of in-place editing.
|
|
285
|
+
// Useful when the team wants a reviewable diff or has branch-protected
|
|
286
|
+
// .docguard.json. Falls back to in-place if pre-flight fails.
|
|
287
|
+
if (flags.pr) {
|
|
288
|
+
console.log(` ${c.dim}Opening PR with migrated config...${c.reset}`);
|
|
289
|
+
const pr = openUpgradePR(projectDir, newConfig, projectSchema, newConfig.version);
|
|
290
|
+
if (pr.ok) {
|
|
291
|
+
console.log(` ${c.green}✓ Schema migration PR opened:${c.reset} ${c.cyan}${pr.prUrl}${c.reset}`);
|
|
292
|
+
} else {
|
|
293
|
+
console.error(` ${c.red}✗ PR creation failed:${c.reset} ${pr.error}`);
|
|
294
|
+
console.log(` ${c.dim}Tip: run without --pr to apply in place, or fix the underlying issue.${c.reset}`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
writeFileSync(cfgPath, JSON.stringify(newConfig, null, 2) + '\n', 'utf-8');
|
|
299
|
+
console.log(` ${c.green}✓ Schema migrated ${projectSchema} → ${newConfig.version}.${c.reset}`);
|
|
300
|
+
}
|
|
227
301
|
} else {
|
|
228
302
|
console.log(` ${c.dim}Schema migration was a no-op (no recipe registered yet for ${projectSchema} → ${CURRENT_SCHEMA_VERSION}).${c.reset}`);
|
|
229
303
|
}
|
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) ──────────
|
|
@@ -385,6 +386,15 @@ async function main() {
|
|
|
385
386
|
flags.reverse = true;
|
|
386
387
|
} else if (args[i] === '--history') {
|
|
387
388
|
flags.history = true;
|
|
389
|
+
} else if (args[i] === '--force-redo') {
|
|
390
|
+
flags.forceRedo = true;
|
|
391
|
+
} else if (args[i] === '--pr') {
|
|
392
|
+
flags.pr = true;
|
|
393
|
+
} else if (args[i] === '--timings' || args[i] === '--show-timings') {
|
|
394
|
+
// v0.14-Q2: per-validator timing display. Renamed from `--profile` to
|
|
395
|
+
// avoid collision with `docguard init --profile <name>`. `--show-timings`
|
|
396
|
+
// is the long form for users who prefer explicit verbs.
|
|
397
|
+
flags.timings = true;
|
|
388
398
|
} else if (!args[i].startsWith('--') && i > 0) {
|
|
389
399
|
// Positional args go into flags.args for commands that take them (e.g.
|
|
390
400
|
// `docguard trace --reverse <path>`). Skip the command itself (i === 0).
|
|
@@ -514,6 +524,9 @@ async function main() {
|
|
|
514
524
|
case 'update':
|
|
515
525
|
await runUpgrade(projectDir, config, flags);
|
|
516
526
|
break;
|
|
527
|
+
case 'impact':
|
|
528
|
+
runImpact(projectDir, config, flags);
|
|
529
|
+
break;
|
|
517
530
|
default:
|
|
518
531
|
console.error(`${c.red}Unknown command: ${command}${c.reset}`);
|
|
519
532
|
console.log(`Run ${c.cyan}docguard --help${c.reset} for usage.`);
|
package/cli/shared-source.mjs
CHANGED
|
@@ -254,6 +254,16 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
254
254
|
}
|
|
255
255
|
};
|
|
256
256
|
|
|
257
|
+
// v0.14-P2: when config.changedFiles is populated (by --changed-only),
|
|
258
|
+
// restrict the scan to ONLY those paths. Skips the recursive tree walk
|
|
259
|
+
// entirely — turns "scan 5000 files" into "scan 3 files" in pre-commit mode.
|
|
260
|
+
if (Array.isArray(config.changedFiles) && config.changedFiles.length > 0) {
|
|
261
|
+
for (const rel of config.changedFiles) {
|
|
262
|
+
visit(resolve(projectDir, rel));
|
|
263
|
+
}
|
|
264
|
+
return names;
|
|
265
|
+
}
|
|
266
|
+
|
|
257
267
|
for (const root of roots) walk(root);
|
|
258
268
|
return names;
|
|
259
269
|
}
|
|
@@ -192,6 +192,22 @@ export function validateApiSurface(projectDir, config) {
|
|
|
192
192
|
const warnings = [];
|
|
193
193
|
const fixes = [];
|
|
194
194
|
|
|
195
|
+
// v0.14-P2: when --changed-only scoping is active and NONE of the changed
|
|
196
|
+
// files look like route/spec/controller files, this validator has nothing
|
|
197
|
+
// to add — return N/A so the lite-mode total reflects only what was actually
|
|
198
|
+
// checked. Route patterns mirror the SECTION_FILE_MATCHERS in sync.mjs.
|
|
199
|
+
if (Array.isArray(config.changedFiles)) {
|
|
200
|
+
const ROUTE_RE = /(^|\/)(routes|controllers|handlers|app\/api)\/|openapi|swagger/i;
|
|
201
|
+
const anyRouteFile = config.changedFiles.some(f => ROUTE_RE.test(f));
|
|
202
|
+
if (!anyRouteFile) {
|
|
203
|
+
return {
|
|
204
|
+
errors, warnings, passed: 0, total: 0, fixes,
|
|
205
|
+
applicable: false,
|
|
206
|
+
note: 'no route/spec files in changed set',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
195
211
|
const drift = computeApiSurfaceDrift(projectDir, config);
|
|
196
212
|
|
|
197
213
|
// ── Multi-spec divergence (independent of the API-REFERENCE doc) ──
|
|
@@ -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,14 +23,44 @@
|
|
|
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
|
+
// v0.14-P3: also emit a `fixes` array. Each fix is structured so
|
|
60
|
+
// `applyMechanicalFixes` can consume it via the new regenerate-section
|
|
61
|
+
// applier. Lets `fix --write` actually CLOSE the loop on drift instead
|
|
62
|
+
// of just warning. No AI needed — the scanner already knows the right body.
|
|
63
|
+
const result = { errors: [], warnings: [], passed: 0, total: 0, fixes: [] };
|
|
34
64
|
|
|
35
65
|
// Build the canonical memory plan (what the docs SHOULD contain). If this
|
|
36
66
|
// fails or produces no docs, the validator is N/A.
|
|
@@ -46,6 +76,9 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
46
76
|
|
|
47
77
|
// Walk each doc's source=code sections and compare against on-disk content.
|
|
48
78
|
let anySourceCodeSection = false;
|
|
79
|
+
const draftThresholdDays = (config.draftStalenessDays != null)
|
|
80
|
+
? Number(config.draftStalenessDays)
|
|
81
|
+
: DRAFT_STALENESS_DAYS;
|
|
49
82
|
|
|
50
83
|
for (const doc of plan.docs) {
|
|
51
84
|
const fullPath = resolve(projectDir, doc.path);
|
|
@@ -53,6 +86,29 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
53
86
|
let content;
|
|
54
87
|
try { content = readFileSync(fullPath, 'utf-8'); } catch { continue; }
|
|
55
88
|
|
|
89
|
+
// S-7: a docguard:generated doc with frontmatter `status: draft` that
|
|
90
|
+
// hasn't been updated in N days is probably a forgotten skeleton.
|
|
91
|
+
// Counted as a check (so total reflects it) and warned when stale.
|
|
92
|
+
const status = extractDocStatus(content);
|
|
93
|
+
if (status === 'draft') {
|
|
94
|
+
result.total++;
|
|
95
|
+
try {
|
|
96
|
+
const mtime = statSync(fullPath).mtime;
|
|
97
|
+
const ageDays = (Date.now() - mtime.getTime()) / (1000 * 60 * 60 * 24);
|
|
98
|
+
if (ageDays > draftThresholdDays) {
|
|
99
|
+
result.warnings.push(
|
|
100
|
+
`${basename(doc.path)} has been in \`status: draft\` for ${Math.floor(ageDays)} days. ` +
|
|
101
|
+
`Promote to status:current or remove. Run \`/docguard.fix --doc ${basename(doc.path)}\` to draft the prose.`
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
result.passed++;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Couldn't stat the file — skip the staleness check, don't count it.
|
|
108
|
+
result.total--;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
56
112
|
for (const sec of doc.sections) {
|
|
57
113
|
if (sec.source !== 'code') continue;
|
|
58
114
|
anySourceCodeSection = true;
|
|
@@ -86,10 +142,22 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
86
142
|
result.warnings.push(
|
|
87
143
|
`${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections.`
|
|
88
144
|
);
|
|
145
|
+
// v0.14-P3: structured fix so `docguard fix --write` can fix this
|
|
146
|
+
// mechanically (no AI needed — scanner already produced the right body).
|
|
147
|
+
result.fixes.push({
|
|
148
|
+
type: 'regenerate-section',
|
|
149
|
+
doc: doc.path,
|
|
150
|
+
sectionId: sec.id,
|
|
151
|
+
body: sec.body,
|
|
152
|
+
summary: `${basename(doc.path)} § ${sec.id} regenerated from scanner`,
|
|
153
|
+
});
|
|
89
154
|
}
|
|
90
155
|
}
|
|
91
156
|
|
|
92
|
-
|
|
157
|
+
// S-7: even when no source=code sections exist, a draft-status check
|
|
158
|
+
// counts the validator as applicable. Only return N/A when we genuinely
|
|
159
|
+
// had nothing to evaluate.
|
|
160
|
+
if (!anySourceCodeSection && result.total === 0) {
|
|
93
161
|
return { ...result, applicable: false };
|
|
94
162
|
}
|
|
95
163
|
|
|
@@ -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
|
|
@@ -97,6 +97,12 @@ export function appendFixes(projectDir, fixes, appliedBy = 'fix --write') {
|
|
|
97
97
|
|
|
98
98
|
for (const f of fixes) {
|
|
99
99
|
const id = fingerprintFix(f);
|
|
100
|
+
const prior = byId.get(id);
|
|
101
|
+
// v0.14-P1: maintain applyCount across applies so ping-pong suppression
|
|
102
|
+
// can tell a fresh fix (count 1) from a recurring one (count 2+).
|
|
103
|
+
const applyCount = (prior && typeof prior.applyCount === 'number')
|
|
104
|
+
? prior.applyCount + 1
|
|
105
|
+
: 1;
|
|
100
106
|
const entry = {
|
|
101
107
|
id,
|
|
102
108
|
type: f.type || 'unknown',
|
|
@@ -104,8 +110,11 @@ export function appendFixes(projectDir, fixes, appliedBy = 'fix --write') {
|
|
|
104
110
|
summary: f.summary || '',
|
|
105
111
|
appliedAt: now,
|
|
106
112
|
appliedBy,
|
|
113
|
+
applyCount,
|
|
114
|
+
// Keep firstAppliedAt for audit clarity — when did we first see this fix?
|
|
115
|
+
firstAppliedAt: (prior && prior.firstAppliedAt) || now,
|
|
107
116
|
};
|
|
108
|
-
byId.set(id, entry); // overwrites prior with same fingerprint
|
|
117
|
+
byId.set(id, entry); // overwrites prior with same fingerprint
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
let entries = Array.from(byId.values());
|
|
@@ -131,3 +140,42 @@ export function isFixRecorded(projectDir, candidate) {
|
|
|
131
140
|
const id = fingerprintFix(candidate);
|
|
132
141
|
return loadFixMemory(projectDir).entries.some(e => e.id === id);
|
|
133
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* v0.14-P1 — fix-history suppression.
|
|
146
|
+
*
|
|
147
|
+
* Decide whether a candidate fix should be SUPPRESSED on this run because
|
|
148
|
+
* it's a known ping-pong pattern. A "ping-pong" is when the same
|
|
149
|
+
* fingerprint has been applied + reverted N or more times — usually a sign
|
|
150
|
+
* the user disagrees with the fix and we should stop re-suggesting it.
|
|
151
|
+
*
|
|
152
|
+
* Rules:
|
|
153
|
+
* - Default threshold: 2 (apply → revert → apply is the third attempt → suppress)
|
|
154
|
+
* - Configurable via opts.pingPongThreshold
|
|
155
|
+
* - Override entirely via opts.force (set when caller passes --force-redo)
|
|
156
|
+
*
|
|
157
|
+
* Returns { suppressed: boolean, reason?: string }.
|
|
158
|
+
*
|
|
159
|
+
* @req SC-P1-001 — never suppresses on first apply
|
|
160
|
+
* @req SC-P1-002 — suppresses after N applies of the same fingerprint
|
|
161
|
+
* @req SC-P1-003 — force: true overrides suppression
|
|
162
|
+
*/
|
|
163
|
+
export function shouldSuppressFix(projectDir, candidate, opts = {}) {
|
|
164
|
+
if (opts.force) return { suppressed: false };
|
|
165
|
+
const id = fingerprintFix(candidate);
|
|
166
|
+
const mem = loadFixMemory(projectDir);
|
|
167
|
+
// Count occurrences of this fingerprint. Each `appendFixes` for an existing
|
|
168
|
+
// ID overwrites in place, so a single entry could represent many applies;
|
|
169
|
+
// we track a separate `applyCount` field for accurate ping-pong detection.
|
|
170
|
+
const entry = mem.entries.find(e => e.id === id);
|
|
171
|
+
if (!entry) return { suppressed: false };
|
|
172
|
+
const count = entry.applyCount || 1;
|
|
173
|
+
const threshold = opts.pingPongThreshold || 2;
|
|
174
|
+
if (count >= threshold) {
|
|
175
|
+
return {
|
|
176
|
+
suppressed: true,
|
|
177
|
+
reason: `applied ${count} time(s) before — possible ping-pong. Use --force-redo to apply anyway.`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return { suppressed: false };
|
|
181
|
+
}
|
|
@@ -14,6 +14,29 @@
|
|
|
14
14
|
* Pure file edits, no LLM. Zero NPM dependencies.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
// v0.14-P1: resolve the suppression predicate at module load. Top-level
|
|
18
|
+
// await is supported by ESM; if the import fails (e.g. partial install),
|
|
19
|
+
// `_shouldSuppress` stays null and suppression is silently disabled —
|
|
20
|
+
// fail-open, never block legit fixes.
|
|
21
|
+
let _shouldSuppress = null;
|
|
22
|
+
try {
|
|
23
|
+
const mod = await import('./fix-memory.mjs');
|
|
24
|
+
if (mod && typeof mod.shouldSuppressFix === 'function') {
|
|
25
|
+
_shouldSuppress = mod.shouldSuppressFix;
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
_shouldSuppress = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// v0.14-P3: section read/write API — loaded once at module init for the
|
|
32
|
+
// regenerate-section applier. Same defensive pattern as the suppressor.
|
|
33
|
+
let _sectionsModule = null;
|
|
34
|
+
try {
|
|
35
|
+
_sectionsModule = await import('./sections.mjs');
|
|
36
|
+
} catch {
|
|
37
|
+
_sectionsModule = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
41
|
import { resolve } from 'node:path';
|
|
19
42
|
import { removeEndpoints, hasGeneratedMarker } from './api-reference.mjs';
|
|
@@ -84,11 +107,48 @@ function applyRemoveEndpoint(projectDir, fix, { force = false } = {}) {
|
|
|
84
107
|
return { applied: true, detail: `${fix.doc}: removed ${fix.method} ${fix.path}` };
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
/**
|
|
111
|
+
* v0.14-P3 — regenerate-section: rewrite a `source=code` section's body
|
|
112
|
+
* with the scanner's expected output. Emitted by the Generated-Staleness
|
|
113
|
+
* validator (M-1) when on-disk content drifts from what the memory plan
|
|
114
|
+
* would produce.
|
|
115
|
+
*
|
|
116
|
+
* Idempotent: if the section already matches `fix.body`, do nothing.
|
|
117
|
+
* Bounded: only writes inside `<!-- docguard:section id=X source=code -->`
|
|
118
|
+
* markers — never touches surrounding prose.
|
|
119
|
+
*
|
|
120
|
+
* fix shape: { type: 'regenerate-section', doc, sectionId, body }
|
|
121
|
+
*/
|
|
122
|
+
function applyRegenerateSection(projectDir, fix) {
|
|
123
|
+
if (!fix.doc || !fix.sectionId || fix.body == null) {
|
|
124
|
+
return { applied: false, skipped: 'regenerate-section needs doc, sectionId, body' };
|
|
125
|
+
}
|
|
126
|
+
const full = resolve(projectDir, fix.doc);
|
|
127
|
+
if (!existsSync(full)) return { applied: false, skipped: `doc not found: ${fix.doc}` };
|
|
128
|
+
const content = readFileSync(full, 'utf-8');
|
|
129
|
+
// Lazy-import the section writer to avoid a top-level circular risk.
|
|
130
|
+
// section APIs are synchronous and well-isolated; this works because
|
|
131
|
+
// mechanical.mjs already uses top-level await for fix-memory.
|
|
132
|
+
const { getSection, replaceSection } = _sectionsModule || {};
|
|
133
|
+
if (typeof getSection !== 'function' || typeof replaceSection !== 'function') {
|
|
134
|
+
return { applied: false, skipped: 'sections module unavailable' };
|
|
135
|
+
}
|
|
136
|
+
const existing = getSection(content, fix.sectionId);
|
|
137
|
+
if (!existing) return { applied: false, skipped: `section ${fix.sectionId} not present in ${fix.doc}` };
|
|
138
|
+
if (existing.body.trim() === String(fix.body).trim()) {
|
|
139
|
+
return { applied: false, skipped: `${fix.doc} § ${fix.sectionId} already current` };
|
|
140
|
+
}
|
|
141
|
+
const next = replaceSection(content, fix.sectionId, fix.body).content;
|
|
142
|
+
writeFileSync(full, next, 'utf-8');
|
|
143
|
+
return { applied: true, detail: `${fix.doc}: regenerated § ${fix.sectionId}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
87
146
|
const APPLIERS = {
|
|
88
147
|
'replace-count': applyReplaceCount,
|
|
89
148
|
'replace-version': applyReplaceVersion,
|
|
90
149
|
'insert-changelog-unreleased': applyInsertChangelogUnreleased,
|
|
91
150
|
'remove-endpoint': applyRemoveEndpoint,
|
|
151
|
+
'regenerate-section': applyRegenerateSection,
|
|
92
152
|
};
|
|
93
153
|
|
|
94
154
|
export const MECHANICAL_FIX_TYPES = Object.keys(APPLIERS);
|
|
@@ -113,7 +173,22 @@ export function applyMechanicalFix(projectDir, fix, opts = {}) {
|
|
|
113
173
|
export function applyMechanicalFixes(projectDir, fixes, opts = {}) {
|
|
114
174
|
const applied = [];
|
|
115
175
|
const skipped = [];
|
|
176
|
+
|
|
116
177
|
for (const fix of fixes) {
|
|
178
|
+
// v0.14-P1: ping-pong suppression. If this same fingerprint has been
|
|
179
|
+
// applied >= N times before (default 2) and --force-redo isn't set,
|
|
180
|
+
// skip with a clear reason. Suppression is OFF when:
|
|
181
|
+
// - recordHistory === false (e.g. dry-run tests don't want this state)
|
|
182
|
+
// - forceRedo === true (user explicitly asked to re-apply)
|
|
183
|
+
if (opts.recordHistory !== false && !opts.forceRedo && _shouldSuppress) {
|
|
184
|
+
const decision = _shouldSuppress(projectDir, fix, {
|
|
185
|
+
pingPongThreshold: opts.pingPongThreshold,
|
|
186
|
+
});
|
|
187
|
+
if (decision.suppressed) {
|
|
188
|
+
skipped.push({ ...fix, reason: `suppressed: ${decision.reason}` });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
117
192
|
const r = applyMechanicalFix(projectDir, fix, opts);
|
|
118
193
|
if (r.applied) applied.push({ ...fix, detail: r.detail });
|
|
119
194
|
else if (r.skipped) skipped.push({ ...fix, reason: r.skipped });
|
|
@@ -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.14.0"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with 19 automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. Zero NPM runtime dependencies."
|
|
8
8
|
author: "Ricardo Accioly"
|
|
9
9
|
repository: "https://github.com/raccioly/docguard"
|
|
@@ -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.14.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.14.0 -->
|
|
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.14.0
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.14.0 -->
|
|
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.
|
|
9
|
+
version: 0.14.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.14.0 -->
|
|
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.14.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.14.0 -->
|
|
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.14.0
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
10
|
|
package/package.json
CHANGED