docguard-cli 0.13.1 → 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.
@@ -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
@@ -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).
@@ -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) ──
@@ -56,7 +56,11 @@ function extractDocStatus(content) {
56
56
  }
57
57
 
58
58
  export function validateGeneratedStaleness(projectDir, config = {}) {
59
- 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: [] };
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
 
@@ -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.1"
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.1
9
+ version: 0.14.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.13.1 -->
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.1
10
+ version: 0.14.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.13.1 -->
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.1
9
+ version: 0.14.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.13.1 -->
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.1
9
+ version: 0.14.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.13.1 -->
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.1
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.1",
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": {