docguard-cli 0.13.1 → 0.14.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/fix.mjs +9 -3
- package/cli/commands/guard.mjs +34 -5
- package/cli/commands/upgrade.mjs +76 -2
- package/cli/docguard.mjs +9 -0
- package/cli/shared-source.mjs +10 -0
- package/cli/validators/api-surface.mjs +16 -0
- package/cli/validators/cross-reference.mjs +43 -5
- package/cli/validators/generated-staleness.mjs +14 -1
- package/cli/validators/metrics-consistency.mjs +28 -4
- package/cli/writers/fix-memory.mjs +49 -1
- package/cli/writers/mechanical.mjs +108 -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.
|
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
|
@@ -386,6 +386,15 @@ async function main() {
|
|
|
386
386
|
flags.reverse = true;
|
|
387
387
|
} else if (args[i] === '--history') {
|
|
388
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;
|
|
389
398
|
} else if (!args[i].startsWith('--') && i > 0) {
|
|
390
399
|
// Positional args go into flags.args for commands that take them (e.g.
|
|
391
400
|
// `docguard trace --reverse <path>`). Skip the command itself (i === 0).
|
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) ──
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
30
|
-
import { resolve, join, dirname, basename } from 'node:path';
|
|
30
|
+
import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Slugify a heading the way GitHub's markdown anchors work.
|
|
@@ -294,6 +294,7 @@ function collectCanonicalDocs(projectDir) {
|
|
|
294
294
|
export function validateCrossReferences(projectDir, _config = {}) {
|
|
295
295
|
const errors = [];
|
|
296
296
|
const warnings = [];
|
|
297
|
+
const fixes = [];
|
|
297
298
|
let passed = 0;
|
|
298
299
|
let total = 0;
|
|
299
300
|
|
|
@@ -364,13 +365,27 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
364
365
|
if (!matches) {
|
|
365
366
|
const where = targetPath === docPath ? 'same doc' : basename(targetPath);
|
|
366
367
|
// 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
368
|
const suggestion = anchors ? suggestAnchor(normalizedAnchor, anchors) : null;
|
|
370
369
|
const hint = suggestion ? ` (did you mean #${suggestion}?)` : '';
|
|
370
|
+
// v0.14.1-S12+: when the suggestion is HIGH-CONFIDENCE — unambiguous
|
|
371
|
+
// and very close (edit distance <= 2) — emit a mechanical fix so
|
|
372
|
+
// `docguard fix --write` resolves it without AI. Other near-misses
|
|
373
|
+
// still get the hint but no fix (the user needs to verify intent).
|
|
374
|
+
const isHighConfidence = suggestion && isUnambiguousSuggestion(normalizedAnchor, suggestion, anchors);
|
|
371
375
|
warnings.push(
|
|
372
|
-
`${docName}:${ref.line} — broken anchor: "#${ref.anchor}" in ${where} doesn't match any heading${hint}`
|
|
376
|
+
`${docName}:${ref.line} — broken anchor: "#${ref.anchor}" in ${where} doesn't match any heading${hint}` +
|
|
377
|
+
(isHighConfidence ? ' [auto-fixable]' : '')
|
|
373
378
|
);
|
|
379
|
+
if (isHighConfidence) {
|
|
380
|
+
fixes.push({
|
|
381
|
+
type: 'replace-anchor',
|
|
382
|
+
doc: relative(projectDir, docPath),
|
|
383
|
+
line: ref.line,
|
|
384
|
+
from: ref.anchor,
|
|
385
|
+
to: suggestion,
|
|
386
|
+
summary: `${docName}:${ref.line} #${ref.anchor} → #${suggestion}`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
374
389
|
continue;
|
|
375
390
|
}
|
|
376
391
|
}
|
|
@@ -379,5 +394,28 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
379
394
|
}
|
|
380
395
|
}
|
|
381
396
|
|
|
382
|
-
return { errors, warnings, passed, total };
|
|
397
|
+
return { errors, warnings, passed, total, fixes };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* v0.14.1-S12+ — is the suggested anchor an unambiguous, high-confidence
|
|
402
|
+
* match? Two criteria, both must hold:
|
|
403
|
+
* 1. Edit distance between the broken anchor and the suggestion is <= 2
|
|
404
|
+
* (catches typos and minor renames, refuses major slug rewrites).
|
|
405
|
+
* 2. The suggestion is the ONLY anchor that close — no other anchor
|
|
406
|
+
* within distance <= 2. Avoids fixing to the wrong candidate when
|
|
407
|
+
* multiple are similar.
|
|
408
|
+
*/
|
|
409
|
+
function isUnambiguousSuggestion(broken, suggestion, anchors) {
|
|
410
|
+
if (!broken || !suggestion || !anchors) return false;
|
|
411
|
+
const sugDist = editDistance(broken, suggestion);
|
|
412
|
+
if (sugDist > 2) return false;
|
|
413
|
+
// Count other anchors that are also within the same tight budget.
|
|
414
|
+
let closeCandidates = 0;
|
|
415
|
+
for (const a of anchors) {
|
|
416
|
+
if (a === suggestion) continue;
|
|
417
|
+
if (editDistance(broken, a) <= 2) closeCandidates++;
|
|
418
|
+
if (closeCandidates > 0) return false; // ambiguous
|
|
419
|
+
}
|
|
420
|
+
return true;
|
|
383
421
|
}
|
|
@@ -56,7 +56,11 @@ function extractDocStatus(content) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
59
|
-
|
|
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: [] };
|
|
60
64
|
|
|
61
65
|
// Build the canonical memory plan (what the docs SHOULD contain). If this
|
|
62
66
|
// fails or produces no docs, the validator is N/A.
|
|
@@ -138,6 +142,15 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
138
142
|
result.warnings.push(
|
|
139
143
|
`${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections.`
|
|
140
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
|
+
});
|
|
141
154
|
}
|
|
142
155
|
}
|
|
143
156
|
|
|
@@ -64,6 +64,14 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
64
64
|
{ key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators' },
|
|
65
65
|
];
|
|
66
66
|
|
|
67
|
+
// v0.14.1-N1: dedup by (file, label, found) — a file that mentions the
|
|
68
|
+
// stale number multiple times produces ONE warning, not one per occurrence.
|
|
69
|
+
// The replace-count applier already uses replace-all semantics, so a single
|
|
70
|
+
// fix per (file, label) is sufficient. Previously: "X.md" appearing 2× with
|
|
71
|
+
// the same drift would generate 2 warnings + 2 fixes (the second a no-op).
|
|
72
|
+
const reportedDrift = new Set(); // key: `${relPath}|${label}|${found}`
|
|
73
|
+
const reportedPass = new Set(); // key: `${relPath}|${label}` — only count one pass per (file, label)
|
|
74
|
+
|
|
67
75
|
for (const mdFile of mdFiles) {
|
|
68
76
|
const relPath = relative(projectDir, mdFile);
|
|
69
77
|
// Skip changelog (historical numbers are fine by definition)
|
|
@@ -79,16 +87,32 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
79
87
|
|
|
80
88
|
regex.lastIndex = 0;
|
|
81
89
|
let match;
|
|
90
|
+
// Collect distinct (found-value) instances within THIS file first,
|
|
91
|
+
// then emit ONE warning per distinct value. A file that says "20" on
|
|
92
|
+
// line 5 and "20" on line 50 is the same drift; "20" on line 5 and
|
|
93
|
+
// "19" on line 50 are two distinct drifts.
|
|
94
|
+
const distinctFoundInFile = new Set();
|
|
82
95
|
while ((match = regex.exec(content)) !== null) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
distinctFoundInFile.add(parseInt(match[1], 10));
|
|
97
|
+
}
|
|
98
|
+
if (distinctFoundInFile.size === 0) continue;
|
|
99
|
+
|
|
100
|
+
for (const found of distinctFoundInFile) {
|
|
101
|
+
if (found > 0 && found !== actuals[key]) {
|
|
102
|
+
const driftKey = `${relPath}|${label}|${found}`;
|
|
103
|
+
if (reportedDrift.has(driftKey)) continue;
|
|
104
|
+
reportedDrift.add(driftKey);
|
|
105
|
+
total++;
|
|
86
106
|
warnings.push(
|
|
87
107
|
`${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Fix with \`docguard fix --write\``
|
|
88
108
|
);
|
|
89
|
-
// Deterministic, surgical token replacement — safe to auto-apply.
|
|
90
109
|
fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key] });
|
|
91
110
|
} else {
|
|
111
|
+
// Matches the actual count — one pass per (file, label), not per occurrence.
|
|
112
|
+
const passKey = `${relPath}|${label}`;
|
|
113
|
+
if (reportedPass.has(passKey)) continue;
|
|
114
|
+
reportedPass.add(passKey);
|
|
115
|
+
total++;
|
|
92
116
|
passed++;
|
|
93
117
|
}
|
|
94
118
|
}
|
|
@@ -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,81 @@ 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
|
+
|
|
146
|
+
/**
|
|
147
|
+
* v0.14.1-S12+ — replace-anchor: rewrite a broken markdown anchor with a
|
|
148
|
+
* high-confidence suggested slug. Emitted by Cross-Reference when its
|
|
149
|
+
* fuzzy match is unambiguous (edit distance <= 2, no other close candidates).
|
|
150
|
+
*
|
|
151
|
+
* fix shape: { type: 'replace-anchor', doc, from, to, line?, summary? }
|
|
152
|
+
*
|
|
153
|
+
* Bounded: only rewrites occurrences of `](#${from})` and `](#X${from})`-like
|
|
154
|
+
* forms — won't touch the broken slug if it happens to appear as plain text.
|
|
155
|
+
* Idempotent: if no occurrence is found (already fixed), no-op.
|
|
156
|
+
*/
|
|
157
|
+
function applyReplaceAnchor(projectDir, fix) {
|
|
158
|
+
if (!fix.doc || !fix.from || !fix.to) {
|
|
159
|
+
return { applied: false, skipped: 'replace-anchor needs doc, from, to' };
|
|
160
|
+
}
|
|
161
|
+
const full = resolve(projectDir, fix.doc);
|
|
162
|
+
if (!existsSync(full)) return { applied: false, skipped: `doc not found: ${fix.doc}` };
|
|
163
|
+
const content = readFileSync(full, 'utf-8');
|
|
164
|
+
|
|
165
|
+
// Match an anchor inside a markdown link: `](#from)` OR `](path#from)`.
|
|
166
|
+
// Use a regex that captures the prefix and suffix so we only touch the
|
|
167
|
+
// anchor part — leaving the link text and path intact.
|
|
168
|
+
const fromEsc = esc(fix.from);
|
|
169
|
+
const re = new RegExp(`(\\]\\([^)]*#)${fromEsc}([)\\s])`, 'g');
|
|
170
|
+
const next = content.replace(re, `$1${fix.to}$2`);
|
|
171
|
+
if (next === content) {
|
|
172
|
+
return { applied: false, skipped: `${fix.doc}: anchor #${fix.from} not found (already fixed?)` };
|
|
173
|
+
}
|
|
174
|
+
writeFileSync(full, next, 'utf-8');
|
|
175
|
+
return { applied: true, detail: `${fix.doc}: #${fix.from} → #${fix.to}` };
|
|
176
|
+
}
|
|
177
|
+
|
|
87
178
|
const APPLIERS = {
|
|
88
179
|
'replace-count': applyReplaceCount,
|
|
89
180
|
'replace-version': applyReplaceVersion,
|
|
90
181
|
'insert-changelog-unreleased': applyInsertChangelogUnreleased,
|
|
91
182
|
'remove-endpoint': applyRemoveEndpoint,
|
|
183
|
+
'regenerate-section': applyRegenerateSection,
|
|
184
|
+
'replace-anchor': applyReplaceAnchor,
|
|
92
185
|
};
|
|
93
186
|
|
|
94
187
|
export const MECHANICAL_FIX_TYPES = Object.keys(APPLIERS);
|
|
@@ -113,7 +206,22 @@ export function applyMechanicalFix(projectDir, fix, opts = {}) {
|
|
|
113
206
|
export function applyMechanicalFixes(projectDir, fixes, opts = {}) {
|
|
114
207
|
const applied = [];
|
|
115
208
|
const skipped = [];
|
|
209
|
+
|
|
116
210
|
for (const fix of fixes) {
|
|
211
|
+
// v0.14-P1: ping-pong suppression. If this same fingerprint has been
|
|
212
|
+
// applied >= N times before (default 2) and --force-redo isn't set,
|
|
213
|
+
// skip with a clear reason. Suppression is OFF when:
|
|
214
|
+
// - recordHistory === false (e.g. dry-run tests don't want this state)
|
|
215
|
+
// - forceRedo === true (user explicitly asked to re-apply)
|
|
216
|
+
if (opts.recordHistory !== false && !opts.forceRedo && _shouldSuppress) {
|
|
217
|
+
const decision = _shouldSuppress(projectDir, fix, {
|
|
218
|
+
pingPongThreshold: opts.pingPongThreshold,
|
|
219
|
+
});
|
|
220
|
+
if (decision.suppressed) {
|
|
221
|
+
skipped.push({ ...fix, reason: `suppressed: ${decision.reason}` });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
117
225
|
const r = applyMechanicalFix(projectDir, fix, opts);
|
|
118
226
|
if (r.applied) applied.push({ ...fix, detail: r.detail });
|
|
119
227
|
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.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.14.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.14.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.14.1
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.14.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.
|
|
9
|
+
version: 0.14.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.14.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.14.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.14.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.14.1
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
10
|
|
package/package.json
CHANGED