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.
@@ -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, { force = false } = {}) {
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
- const { applied, skipped } = applyMechanicalFixes(projectDir, fixes, { force });
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, { force: flags.force });
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({
@@ -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
- results.push({ ...result, name, key, ...classifyResult(result) });
124
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
125
+ results.push({ ...result, name, key, durationMs, ...classifyResult(result) });
121
126
  } catch (err) {
122
- results.push({ name, key, status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1 });
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
- results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', ...classifyResult(result) });
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
- results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1 });
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: ![CDD Guard](${badgeUrl})${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
+ }
@@ -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
- writeFileSync(cfgPath, JSON.stringify(newConfig, null, 2) + '\n', 'utf-8');
226
- console.log(` ${c.green}✓ Schema migrated ${projectSchema} ${newConfig.version}.${c.reset}`);
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.`);
@@ -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
- // Try relative to source's directory first
161
- const fromSource = resolve(dirname(sourcePath), targetRel);
162
- if (existsSync(fromSource)) return fromSource;
163
- // Also try from project root (some authors write `docs-canonical/X.md`)
164
- const fromRoot = resolve(projectDir, targetRel);
165
- if (existsSync(fromRoot)) return fromRoot;
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
- import { getLastCommitDate } from '../shared-git.mjs';
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
- // Delegate to shared-git so rename history (--follow) is preserved.
26
- // Without --follow, a `git mv` resets the file's "last commit date" and
27
- // the Freshness counter silently misses drift introduced by the rename.
28
- return getLastCommitDate(dir, filePath);
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
- const result = { errors: [], warnings: [], passed: 0, total: 0 };
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
- if (!anySourceCodeSection) {
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
- results.total++;
24
- const agentFileFound = config.requiredFiles.agentFile.some(f =>
25
- existsSync(resolve(projectDir, f))
26
- );
27
- if (agentFileFound) {
28
- results.passed++;
29
- } else {
30
- results.errors.push(`Missing agent file: ${config.requiredFiles.agentFile.join(' or ')}`);
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
- results.total++;
35
- if (existsSync(resolve(projectDir, config.requiredFiles.changelog))) {
36
- results.passed++;
37
- } else {
38
- results.errors.push(`Missing required file: ${config.requiredFiles.changelog}`);
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 → updates appliedAt
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.13.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.13.0
9
+ version: 0.14.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.13.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.13.0
10
+ version: 0.14.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.13.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.13.0
9
+ version: 0.14.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.13.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.13.0
9
+ version: 0.14.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.13.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.13.0
7
+ version: 0.14.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {