docguard-cli 0.41.2 → 0.41.4

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/CHANGELOG.md CHANGED
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.41.4] - 2026-09-17
11
+
12
+ Automated weekly release — batches everything merged since `v0.41.3`.
13
+
14
+ ### Changed
15
+
16
+ - fix: correct the validator-surface claim and document DOCGUARD_API_KEY (#408)
17
+ - fix: harden adoption precision boundaries (#406)
18
+
19
+
20
+ ### Fixed
21
+
22
+ - Document `DOCGUARD_API_KEY` in the canonical environment doc. `ENVIRONMENT.md`
23
+ claimed DocGuard uses "no API keys" and needs no environment variables, which
24
+ contradicted `SECURITY.md` and the HTTP MCP server, where the variable is
25
+ required to bind a non-loopback host. Core CLI commands still need no credential.
26
+ - Enforce the published validator count in `npm test`. Canonical-Sync and
27
+ Metrics-Consistency already detect a stale "N validators" claim, but they emit
28
+ warnings and the CI self-scan deliberately tolerates guard's warnings-only exit,
29
+ so a wrong count could reach `main` with every check green. The count is now
30
+ asserted against the shipped `cli/validators/*.mjs` modules, and README explains
31
+ why guard prints 30 result rows for 29 validators.
32
+ - Count DocGuard validator claims from one shared source of truth: the
33
+ validator modules shipped in the installed package. A consumer's intentionally
34
+ disabled validators and extra guard checks cannot create a false MET001 or
35
+ CSY003 finding, or an unsafe count rewrite.
36
+ - Keep instruction-pointer basename resolution out of Git-ignored directories
37
+ and nested Git checkouts, including linked worktree copies.
38
+ - Keep planned lifecycle registries that are new, removed from the Git index,
39
+ or modified pending commit non-authoritative for TRC004 while explaining how
40
+ to restore or commit the registry and current spec artifacts; the remediation
41
+ no longer suggests adding an artificial `@req` marker.
42
+
43
+ ## [0.41.3] - 2026-09-15
44
+
45
+ Automated weekly release — batches everything merged since `v0.41.2`.
46
+
47
+ ### Changed
48
+
49
+ - fix: preserve hook composition and evidence gates (#404)
50
+
51
+
52
+ ### Fixed
53
+
54
+ - Increased the bounded subprocess deadlines in the hook and Spec Kit helper contract suites so parallel CI load does not misreport healthy integrations as `ETIMEDOUT` failures.
55
+ - Make managed Git hook reinstall idempotent, repair nested marker pairs written
56
+ by affected releases, and allow successful DocGuard blocks to continue into
57
+ user-owned postlude commands.
58
+ - Make direct `verify --evidence` suitable for CI by exiting 1 for contradictions
59
+ and invalid manifests, 2 for unresolved evidence, and 0 for verified or
60
+ unconfigured evidence.
61
+ - Explain deterministic spec-registry drift with bounded field paths and identify
62
+ order-only canonicalization instead of returning an unexplained `STALE` state.
63
+ - Replace the README's release-line label with version-independent wording so it
64
+ cannot become stale on the next automated release.
65
+
10
66
  ## [0.41.2] - 2026-09-15
11
67
 
12
68
  Automated weekly release — batches everything merged since `v0.41.1`.
package/README.md CHANGED
@@ -77,7 +77,7 @@ graph TD
77
77
  Commands --> setup["setup wizard"]
78
78
  Commands --> other["diff · init · fix · trace · impact · sync · reconcile · retire · specs<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
79
79
 
80
- guard --> Validators["Validators (30)"]
80
+ guard --> Validators["Validators (29)"]
81
81
  generate --> Scanners["Scanners (4)<br/>routes · schemas · doc-tools · speckit"]
82
82
  score --> Scoring["Weighted Scoring<br/>8 categories"]
83
83
  diagnose --> Validators
@@ -275,7 +275,7 @@ DocGuard ships **23 commands** (the "Daily 5" + 18 situational tools, including
275
275
  | Command | What It Does |
276
276
  |:--------|:-------------|
277
277
  | `init` | Bootstrap a project (`--wizard` for interactive · `--with <name>` for scaffolders) |
278
- | `guard` | Validate against canonical docs — 30 validators |
278
+ | `guard` | Validate against canonical docs — 29 validators |
279
279
  | `diff` | Show gaps between docs and code (`--since <ref>` for impact mode) |
280
280
  | `sync` | Refresh code-truth doc sections — keeps memory always up to date |
281
281
  | `score` | Structural CDD maturity score (0-100; not a guard verdict; `--diff` for delta between refs) |
@@ -411,7 +411,13 @@ $ npx docguard-cli generate
411
411
 
412
412
  ## 🔍 Validators
413
413
 
414
- DocGuard runs **30 automated validators** on every `guard` check. Source-facing validators are language-aware where their evidence model applies; repository and document validators operate independently of source language.
414
+ DocGuard runs **29 automated validators** on every `guard` check. Source-facing validators are language-aware where their evidence model applies; repository and document validators operate independently of source language.
415
+
416
+ > **Counting note:** `guard` prints 30 result rows, not 29. `Structure` emits a
417
+ > second check result (`Doc Sections`) under the same validator key, so rows are
418
+ > checks, not validators. The published number is the count of shipped
419
+ > `cli/validators/*.mjs` modules and is enforced by tests — don't derive it by
420
+ > counting output rows.
415
421
 
416
422
  | # | Validator | What It Checks | Default |
417
423
  |:--|:----------|:--------------|:--------|
@@ -544,7 +550,7 @@ DocGuard provides AI agent slash commands for integrated workflows. Installed au
544
550
  | Command | What It Does |
545
551
  |:--------|:-------------|
546
552
  | `/docguard.init` | Initialize Canonical-Driven Development in a new or existing project |
547
- | `/docguard.guard` | Run quality validation — check all 30 validators |
553
+ | `/docguard.guard` | Run quality validation — check all 29 validators |
548
554
  | `/docguard.review` | Analyze doc quality and suggest improvements |
549
555
  | `/docguard.fix` | Generate targeted fix prompts for specific issues |
550
556
  | `/docguard.update` | Update canonical docs after code changes — detect drift and sync documentation |
@@ -714,7 +720,7 @@ Two ready-to-use templates ship with the Spec Kit extension and as standalone fi
714
720
 
715
721
  ## ✨ What's New
716
722
 
717
- Highlights through the current v0.40 release line:
723
+ Highlights from recent releases:
718
724
 
719
725
  - **Adoption baseline** — `guard --update-baseline` freezes a legacy repo's existing findings
720
726
  into a committed `.docguard.baseline.json`; guard/ci then gate only NEW drift, with suppression
@@ -24,6 +24,19 @@ import { existsSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node
24
24
  // pre-existing hooks), behavior falls back to the existing --force flow.
25
25
  const BEGIN_MARKER = '# BEGIN DOCGUARD MANAGED — do not edit between these markers';
26
26
  const END_MARKER = '# END DOCGUARD MANAGED';
27
+ const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
+
29
+ function managedBounds(content) {
30
+ const starts = [...content.matchAll(new RegExp(`^${escapeRegExp(BEGIN_MARKER)}\\r?$`, 'gm'))]
31
+ .map(match => match.index);
32
+ const ends = [...content.matchAll(new RegExp(`^${escapeRegExp(END_MARKER)}\\r?$`, 'gm'))]
33
+ .map(match => match.index);
34
+ const start = starts[0];
35
+ const end = ends.at(-1);
36
+ return Number.isInteger(start) && Number.isInteger(end) && end > start
37
+ ? { start, end }
38
+ : null;
39
+ }
27
40
 
28
41
  /**
29
42
  * Wrap a hook body in BEGIN/END markers so future re-installs can splice
@@ -44,15 +57,18 @@ function wrapManaged(body) {
44
57
  * the BEGIN/END markers. Returns the new file content (string) or null
45
58
  * when the markers aren't found (caller falls back to legacy behavior).
46
59
  */
47
- function spliceManagedBlock(existing, newBody) {
48
- const startIdx = existing.indexOf(BEGIN_MARKER);
49
- const endIdx = existing.indexOf(END_MARKER);
50
- if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return null;
60
+ function spliceManagedBlock(existing, rawBody) {
61
+ const bounds = managedBounds(existing);
62
+ if (!bounds) return null;
63
+ const startIdx = bounds.start;
64
+ // Use the outermost end marker so one reinstall also repairs hooks written
65
+ // by v0.40.5-v0.41.2, which accidentally nested a second managed block.
66
+ const endIdx = bounds.end;
51
67
  const before = existing.slice(0, startIdx);
52
68
  const after = existing.slice(endIdx + END_MARKER.length);
53
- // newBody has its own shebang — strip it since we're splicing into the
69
+ // rawBody has its own shebang — strip it since we're splicing into the
54
70
  // middle of an existing file (which already has one).
55
- const bodyNoShebang = newBody.replace(/^#!.*\n/, '');
71
+ const bodyNoShebang = rawBody.replace(/^#!.*\n/, '');
56
72
  return `${before}${BEGIN_MARKER}\n${bodyNoShebang.replace(/\n+$/, '')}\n${END_MARKER}${after}`;
57
73
  }
58
74
 
@@ -62,18 +78,21 @@ function hookState(name, hooksDir) {
62
78
  let content;
63
79
  try { content = readFileSync(path, 'utf-8'); }
64
80
  catch { return { kind: 'unreadable', path, content: '' }; }
65
- if (content.includes(BEGIN_MARKER) && content.includes(END_MARKER)) {
81
+ if (managedBounds(content)) {
66
82
  return { kind: 'managed', path, content };
67
83
  }
68
- const legacySignature = new RegExp(`^# DocGuard ${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} hook(?: \\(auto-fix mode\\))?$`, 'm');
84
+ const legacySignature = new RegExp(`^# DocGuard ${escapeRegExp(name)} hook(?: \\(auto-fix mode\\))?$`, 'm');
69
85
  if (legacySignature.test(content)) return { kind: 'legacy', path, content };
70
86
  return { kind: 'foreign', path, content };
71
87
  }
72
88
 
73
89
  function removeManagedBlock(content) {
74
- const start = content.indexOf(BEGIN_MARKER);
75
- const end = content.indexOf(END_MARKER);
76
- if (start === -1 || end === -1 || end < start) return null;
90
+ const bounds = managedBounds(content);
91
+ if (!bounds) return null;
92
+ const start = bounds.start;
93
+ // Match spliceManagedBlock's outermost recovery boundary so removal also
94
+ // cleans up already-nested blocks from affected releases.
95
+ const end = bounds.end;
77
96
  const remaining = `${content.slice(0, start)}${content.slice(end + END_MARKER.length)}`
78
97
  .replace(/\n{3,}/g, '\n\n');
79
98
  return remaining;
@@ -128,7 +147,7 @@ elif [ $EXIT_CODE -eq 2 ]; then
128
147
  echo "⚠️ DocGuard guard found warnings — commit allowed"
129
148
  fi
130
149
 
131
- exit 0
150
+ :
132
151
  `,
133
152
  },
134
153
 
@@ -179,7 +198,7 @@ if [ "$SCORE" -lt "$MIN_SCORE" ]; then
179
198
  fi
180
199
 
181
200
  echo " ✅ Score meets minimum threshold"
182
- exit 0
201
+ :
183
202
  `,
184
203
  },
185
204
 
@@ -215,7 +234,7 @@ if ! echo "$COMMIT_MSG" | head -1 | grep -qE "$PATTERN"; then
215
234
  exit 1
216
235
  fi
217
236
 
218
- exit 0
237
+ :
219
238
  `,
220
239
  },
221
240
  };
@@ -256,7 +275,7 @@ if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 2 ]; then
256
275
  elif [ $EXIT_CODE -eq 2 ]; then
257
276
  echo "⚠️ DocGuard guard found warnings — commit allowed"
258
277
  fi
259
- exit 0
278
+ :
260
279
  `;
261
280
 
262
281
  export function runHooks(projectDir, config, flags) {
@@ -356,7 +375,8 @@ export function runHooks(projectDir, config, flags) {
356
375
  for (const name of hookTypes) {
357
376
  const hookPath = resolve(hooksDir, name);
358
377
  const useAutofix = name === 'pre-commit' && flags.autoFix;
359
- const newContent = wrapManaged(useAutofix ? PRE_COMMIT_AUTOFIX : HOOKS[name].content);
378
+ const rawContent = useAutofix ? PRE_COMMIT_AUTOFIX : HOOKS[name].content;
379
+ const newContent = wrapManaged(rawContent);
360
380
  const desc = useAutofix ? 'Apply mechanical fixes (fix --write) then guard' : HOOKS[name].description;
361
381
 
362
382
  if (existsSync(hookPath)) {
@@ -366,7 +386,7 @@ export function runHooks(projectDir, config, flags) {
366
386
  // preserve everything outside it. The user can extend the hook with
367
387
  // their own commands above/below the markers without losing them on
368
388
  // re-install.
369
- const spliced = spliceManagedBlock(existing, newContent);
389
+ const spliced = spliceManagedBlock(existing, rawContent);
370
390
  if (spliced !== null) {
371
391
  safeWrite(hookPath, spliced);
372
392
  chmodSync(hookPath, 0o755);
@@ -226,6 +226,12 @@ function printResult(result) {
226
226
  console.log(`Spec registry: ${result.status}`);
227
227
  console.log(`Registry: ${SPEC_REGISTRY_PATH}`);
228
228
  console.log(`Specs: ${result.specs}; tombstones: ${result.tombstones}`);
229
+ if (result.differences?.length) {
230
+ console.log('Differences:');
231
+ for (const difference of result.differences) {
232
+ console.log(` ${difference.path}: ${difference.message}`);
233
+ }
234
+ }
229
235
  if (result.issues.length) printIssues(result.issues);
230
236
  }
231
237
 
@@ -268,6 +274,7 @@ export function runSpecs(projectDir, config, flags = {}) {
268
274
  specs: projection.registry.specs.length,
269
275
  tombstones: projection.registry.tombstones.length,
270
276
  issues: projection.issues,
277
+ differences: projection.differences,
271
278
  };
272
279
  if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
273
280
  else printResult(result);
@@ -169,6 +169,14 @@ export function runVerify(projectDir, config, flags) {
169
169
 
170
170
  function runEvidenceVerification(projectDir, config, flags) {
171
171
  const evaluation = evaluateEvidence(projectDir, config);
172
+ // Match guard's severity contract so this command is safe as a direct CI
173
+ // gate: contradictions and invalid manifests fail, while unresolved evidence
174
+ // uses the warning-only status shared by the rest of the CLI.
175
+ process.exitCode = evaluation.status === 'contradicted' || evaluation.status === 'invalid'
176
+ ? 1
177
+ : evaluation.status === 'attention-required'
178
+ ? 2
179
+ : 0;
172
180
  if (flags.format === 'json') {
173
181
  console.log(JSON.stringify(evaluation, null, 2));
174
182
  return;
package/cli/docguard.mjs CHANGED
@@ -341,7 +341,7 @@ const COMMAND_HELP = {
341
341
  summary: 'Maintain the deterministic spec lifecycle and evidence registry.',
342
342
  usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] | docguard specs complete --id <spec-id> [--since <ref>] [--write --reason <text>]',
343
343
  flags: [
344
- ['--check', 'Exit 2 when the committed registry is missing, stale, or inconsistent'],
344
+ ['--check', 'Exit 2 when the committed registry is missing, stale, or inconsistent; planned lifecycle deferral requires a clean tracked registry'],
345
345
  ['--write', 'Refresh observed evidence while preserving reviewed lifecycle fields'],
346
346
  ['preflight', 'Brief prior specs, or gate a generated draft with --path'],
347
347
  ['complete', 'Plan or apply the implemented→verified evidence transaction'],
package/cli/findings.mjs CHANGED
@@ -425,7 +425,7 @@ export const CODES = {
425
425
  CSY003: {
426
426
  validator: 'canonicalSync',
427
427
  title: 'Stale "N validators" claim',
428
- help: "A surface doc (README.md/AGENTS.md) states a validator count that does not match guard's actual count (validator files + the inlined Doc Sections validator). Update the claim.",
428
+ help: 'A surface doc (README.md/AGENTS.md) states a validator count that does not match the validator modules shipped in the package. Guard can emit multiple check results from one module; those do not increase the public validator count. Update the claim.',
429
429
  suppress: null,
430
430
  },
431
431
  CSY004: {
@@ -34,7 +34,13 @@
34
34
  import { readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
35
35
  import { resolve, join, dirname, relative, isAbsolute, sep } from 'node:path';
36
36
  import { fileURLToPath } from 'node:url';
37
- import { buildIgnoreFilter, loadDocguardIgnore, DEFAULT_IGNORE_DIRS, relPosix } from '../shared-ignore.mjs';
37
+ import {
38
+ buildIgnoreFilter,
39
+ loadDocguardIgnore,
40
+ loadGitIgnoredFilter,
41
+ DEFAULT_IGNORE_DIRS,
42
+ relPosix,
43
+ } from '../shared-ignore.mjs';
38
44
 
39
45
  const __dirname = dirname(fileURLToPath(import.meta.url));
40
46
 
@@ -165,6 +171,7 @@ function basenameIndex(projectDir, wanted, config = {}) {
165
171
  let root;
166
172
  try { root = realpathSync(projectDir); }
167
173
  catch { return { matches, complete: false, visited: 0, reason: 'repository-unavailable' }; }
174
+ const gitIgnored = loadGitIgnoredFilter(root);
168
175
  let visited = 0;
169
176
  let complete = true;
170
177
  let reason = null;
@@ -177,12 +184,22 @@ function basenameIndex(projectDir, wanted, config = {}) {
177
184
  if (DEFAULT_IGNORE_DIRS.has(entry.name) || entry.name === '.local' || /^\.env(?:\.|$)/i.test(entry.name)) continue;
178
185
  const full = resolve(dir, entry.name);
179
186
  const rel = relPosix(root, full);
180
- if (ignored(rel)) continue;
187
+ if (ignored(rel) || (gitIgnored && gitIgnored(rel))) continue;
181
188
  let stat;
182
189
  try { stat = lstatSync(full); }
183
190
  catch { complete = false; reason ||= 'unreadable-entry'; continue; }
184
191
  if (stat.isSymbolicLink()) continue;
185
- if (stat.isDirectory()) { walk(full); if (reason === 'entry-budget') return; }
192
+ if (stat.isDirectory()) {
193
+ // A linked Git worktree has a `.git` FILE; a nested checkout has a
194
+ // `.git` directory. Neither is part of this repository's primary
195
+ // instruction-pointer namespace, even when its parent was not added
196
+ // to .gitignore. Stop before reading duplicate checkout contents.
197
+ try {
198
+ if (lstatSync(join(full, '.git')).isFile() || lstatSync(join(full, '.git')).isDirectory()) continue;
199
+ } catch { /* ordinary directory: keep walking */ }
200
+ walk(full);
201
+ if (reason === 'entry-budget') return;
202
+ }
186
203
  else if (stat.isFile() && matches.has(entry.name)) matches.get(entry.name).push(rel);
187
204
  }
188
205
  };
@@ -35,6 +35,51 @@ const posix = path => path.split(sep).join('/').replace(/^\.\//, '');
35
35
  const digest = content => `sha256:${createHash('sha256').update(content).digest('hex')}`;
36
36
  const sortedUnique = values => [...new Set(values)].sort((a, b) => a.localeCompare(b));
37
37
 
38
+ function registryDifferences(left, right) {
39
+ const differences = [];
40
+ let omitted = false;
41
+ const add = difference => {
42
+ if (differences.length < 25) differences.push(difference);
43
+ else omitted = true;
44
+ };
45
+ const memberKeys = values => values.map(value => JSON.stringify(value)).sort();
46
+ const visit = (actual, projected, path) => {
47
+ if (omitted) return;
48
+ if (Object.is(actual, projected)) return;
49
+ if (Array.isArray(actual) && Array.isArray(projected)) {
50
+ if (JSON.stringify(actual) === JSON.stringify(projected)) return;
51
+ const actualMembers = memberKeys(actual);
52
+ const projectedMembers = memberKeys(projected);
53
+ if (actualMembers.length === projectedMembers.length
54
+ && actualMembers.every((value, index) => value === projectedMembers[index])) {
55
+ add({ path, kind: 'order', message: 'Unordered values are not in canonical sort order.' });
56
+ return;
57
+ }
58
+ const length = Math.max(actual.length, projected.length);
59
+ for (let index = 0; index < length; index++) visit(actual[index], projected[index], `${path}[${index}]`);
60
+ return;
61
+ }
62
+ const actualObject = actual !== null && typeof actual === 'object' && !Array.isArray(actual);
63
+ const projectedObject = projected !== null && typeof projected === 'object' && !Array.isArray(projected);
64
+ if (actualObject && projectedObject) {
65
+ for (const key of [...new Set([...Object.keys(actual), ...Object.keys(projected)])].sort()) {
66
+ visit(actual[key], projected[key], `${path}.${key}`);
67
+ }
68
+ return;
69
+ }
70
+ const kind = actual === undefined ? 'missing'
71
+ : projected === undefined ? 'unexpected'
72
+ : 'value';
73
+ const message = kind === 'missing' ? 'Field is missing from the registry.'
74
+ : kind === 'unexpected' ? 'Field is not part of the deterministic projection.'
75
+ : 'Field value differs from the deterministic projection.';
76
+ add({ path, kind, message });
77
+ };
78
+ visit(left, right, '$');
79
+ if (omitted) differences.push({ path: '$', kind: 'truncated', message: 'Additional differences were omitted.' });
80
+ return differences;
81
+ }
82
+
38
83
  function isSafeFile(projectDir, path) {
39
84
  try {
40
85
  const root = realpathSync(projectDir);
@@ -243,6 +288,32 @@ export function trustedSpecLifecycleIndex(projectDir) {
243
288
  return trusted;
244
289
  }
245
290
 
291
+ /**
292
+ * Planned lifecycle entries that are structurally current but cannot defer
293
+ * traceability because the registry is not a clean, tracked Git artifact.
294
+ * This includes a new registry, a registry removed from the index, and a
295
+ * modified registry awaiting a commit. It is advisory-only: callers may
296
+ * explain the state, never use it as evidence.
297
+ */
298
+ export function uncommittedPlannedSpecLifecycleIndex(projectDir) {
299
+ const candidates = new Map();
300
+ const loaded = readSpecRegistry(projectDir);
301
+ if (loaded.error || loaded.value?.schemaVersion !== SPEC_REGISTRY_SCHEMA_VERSION) return candidates;
302
+ if (trackedAndClean(projectDir, SPEC_REGISTRY_PATH)) return candidates;
303
+ for (const entry of loaded.value.specs) {
304
+ if (entry?.reviewed?.lifecycle?.delivery !== 'planned' || !entry.specId || !entry.path) continue;
305
+ if (!trackedAndClean(projectDir, entry.path)) continue;
306
+ let content;
307
+ try { content = readFileSync(resolve(projectDir, entry.path), 'utf8'); } catch { continue; }
308
+ if (parseSpecId(content) !== entry.specId) continue;
309
+ const artifact = entry.observed?.artifacts?.find(item => item.path === entry.path);
310
+ if (!artifact || artifact.digest !== digest(content)) continue;
311
+ if (entry.reviewed.lifecycle.context !== 'current' || entry.reviewed.lifecycle.storage !== 'working_tree') continue;
312
+ candidates.set(`${entry.specId}\0${entry.path}`, entry.reviewed.lifecycle);
313
+ }
314
+ return candidates;
315
+ }
316
+
246
317
  function taskCompletion(path) {
247
318
  if (!path || !existsSync(path)) return { checked: 0, total: 0 };
248
319
  const content = readFileSync(path, 'utf8');
@@ -472,10 +543,14 @@ export function projectSpecRegistry(projectDir, config = {}, options = {}) {
472
543
  const current = existing.exists && !existing.error
473
544
  ? `${JSON.stringify(existing.value, null, 2)}\n` === serialized
474
545
  : false;
546
+ const differences = existing.exists && !existing.error
547
+ ? registryDifferences(existing.value, projected)
548
+ : [];
475
549
  return {
476
550
  registry: projected,
477
551
  serialized,
478
552
  current,
553
+ differences,
479
554
  exists: existing.exists,
480
555
  issues,
481
556
  detected: detected.specs.length,
@@ -91,6 +91,7 @@ export function isNonProductPath(relPath, config = {}) {
91
91
  * Returns [] if the file is missing or unreadable — never throws.
92
92
  */
93
93
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
94
+ import { spawnSync } from 'node:child_process';
94
95
  import { resolve as resolvePath, relative as relativePath, join as joinPath, sep } from 'node:path';
95
96
 
96
97
  /**
@@ -122,6 +123,54 @@ export function loadDocguardIgnore(projectDir) {
122
123
  }
123
124
  }
124
125
 
126
+ /**
127
+ * Return a predicate for paths Git currently classifies as ignored.
128
+ *
129
+ * This deliberately delegates pattern semantics (including nested
130
+ * `.gitignore` files and negation) to Git instead of maintaining a second,
131
+ * incomplete gitignore parser. `--directory` lets callers prune a wholly
132
+ * ignored directory before reading its descendants. A missing Git repository
133
+ * or an unreadable/overflowing result returns null so callers can retain their
134
+ * existing conservative traversal behavior.
135
+ *
136
+ * Only untracked ignored paths are returned by Git, which is intentional:
137
+ * tracked source remains part of the repository even if a later ignore rule
138
+ * happens to mention its name.
139
+ *
140
+ * @param {string} projectDir
141
+ * @returns {((relPath: string) => boolean)|null}
142
+ */
143
+ export function loadGitIgnoredFilter(projectDir) {
144
+ let result;
145
+ try {
146
+ result = spawnSync('git', [
147
+ 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z',
148
+ ], {
149
+ cwd: projectDir,
150
+ encoding: 'utf8',
151
+ maxBuffer: 8 * 1024 * 1024,
152
+ windowsHide: true,
153
+ });
154
+ } catch {
155
+ return null;
156
+ }
157
+ if (result.status !== 0 || result.error || typeof result.stdout !== 'string') return null;
158
+
159
+ const exact = new Set();
160
+ const directories = [];
161
+ for (const raw of result.stdout.split('\0')) {
162
+ const path = raw.replace(/\\/g, '/').replace(/^\.\//, '');
163
+ if (!path) continue;
164
+ if (path.endsWith('/')) directories.push(path);
165
+ else exact.add(path);
166
+ }
167
+ if (exact.size === 0 && directories.length === 0) return () => false;
168
+ return relPath => {
169
+ const path = String(relPath || '').replace(/\\/g, '/').replace(/^\.\//, '');
170
+ return exact.has(path) || directories.some(directory => path.startsWith(directory));
171
+ };
172
+ }
173
+
125
174
  /**
126
175
  * Merge `.docguardignore` patterns into a config object's `ignore` array.
127
176
  *
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Validator Surface — package capability facts shared by self-governance
3
+ * validators. A validator module is one shipped `cli/validators/*.mjs` file;
4
+ * guard may emit additional check results from a module, but those are not
5
+ * extra public validator modules.
6
+ */
7
+
8
+ import { readdirSync } from 'node:fs';
9
+
10
+ /** Return the number of validator modules in one package directory, or null. */
11
+ export function countValidatorModules(validatorsDir) {
12
+ try {
13
+ return readdirSync(validatorsDir).filter(name => name.endsWith('.mjs')).length;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
@@ -15,9 +15,9 @@
15
15
  *
16
16
  * What it checks:
17
17
  * 1. README "ships N commands" matches `cli/commands/*.mjs` file count
18
- * 2. README "N validators" matches `runGuardInternal()` output length
19
- * (or, if no guardResults passed, falls back to file count + the 2
20
- * inlined validators: Doc Sections in structure.mjs + Spec-Kit)
18
+ * 2. README "N validators" matches the shipped `cli/validators/*.mjs`
19
+ * module count. Guard may emit multiple check results from one module;
20
+ * those are not extra public validator modules.
21
21
  * 3. Validator names enumerated inline in README appear in guard output
22
22
  *
23
23
  * What it explicitly skips:
@@ -41,6 +41,7 @@
41
41
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
42
42
  import { resolve, join } from 'node:path';
43
43
  import { mkFinding, resultFromFindings } from '../findings.mjs';
44
+ import { countValidatorModules } from '../shared-validator-surface.mjs';
44
45
 
45
46
  /**
46
47
  * Validate that README count claims about DocGuard's surface match code-truth.
@@ -118,19 +119,13 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
118
119
  }
119
120
  const actualCommandCount = actualUserFacingCount;
120
121
 
121
- // Validator count: always use the file-count truth source. It's run-order
122
- // independent (canonical-sync runs BEFORE metrics-consistency at guard time,
123
- // so guardResults.length would undercount by 1). File count + 1 for the
124
- // single inlined validator (Doc Sections, exported alongside Structure from
125
- // structure.mjs).
126
- const validatorFiles = readdirSync(validatorsDir).filter(f => f.endsWith('.mjs'));
127
- const actualValidatorCount = validatorFiles.length + 1; // +1 for Doc Sections inlined in structure.mjs
128
-
129
- // Names list (currently unused for warnings, but kept for future Check 3
130
- // where the README enumerates validator names inline).
131
- let actualValidatorNames = [];
132
- if (Array.isArray(guardResults) && guardResults.length > 0) {
133
- actualValidatorNames = guardResults.map(r => r.name).filter(Boolean);
122
+ // Validator count is the package capability count used by
123
+ // Metrics-Consistency too. It is run-order independent and excludes extra
124
+ // sub-check results emitted by a validator module (for example, Doc Sections
125
+ // from structure.mjs), which are checks rather than shipped validators.
126
+ const actualValidatorCount = countValidatorModules(validatorsDir);
127
+ if (actualValidatorCount === null) {
128
+ return compose({ na: true, naReason: 'cli/validators could not be read' });
134
129
  }
135
130
 
136
131
  // ── Read surface docs (README.md + AGENTS.md) ──────────────────────
@@ -204,9 +199,9 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
204
199
  code: 'CSY003',
205
200
  validator: 'canonicalSync',
206
201
  severity: 'warn',
207
- message: `A surface doc (README.md/AGENTS.md) claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update it.`,
202
+ message: `A surface doc (README.md/AGENTS.md) claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but DocGuard ships ${actualValidatorCount} validator modules. Update it.`,
208
203
  location: null,
209
- suggestion: { kind: 'fix', text: 'Update the "N validators" claim in README.md/AGENTS.md to match guard\'s count' },
204
+ suggestion: { kind: 'fix', text: 'Update the "N validators" claim in README.md/AGENTS.md to match DocGuard\'s shipped validator modules' },
210
205
  }));
211
206
  }
212
207
  } else {
@@ -7,8 +7,10 @@
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
- import { resolve, join, relative } from 'node:path';
10
+ import { resolve, join, relative, dirname } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
11
12
  import { loadIgnorePatterns, resolveDocDirs } from '../shared.mjs';
13
+ import { countValidatorModules } from '../shared-validator-surface.mjs';
12
14
  // v0.29 consolidation: walker + glob counting live in shared-ignore.mjs (the
13
15
  // single implementations) — this file previously carried private copies.
14
16
  import { walkFiles, countGlobFiles } from '../shared-ignore.mjs';
@@ -33,18 +35,19 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
33
35
  // ── Collect actual metrics ──
34
36
  const actuals = {};
35
37
 
36
- // Guard check count (from guard results if available)
38
+ // Guard check count is configuration-dependent, so it comes from this run.
39
+ // Validator count is a package capability: it must not shrink when a project
40
+ // disables a validator. Resolve this module's installed directory rather
41
+ // than projectDir, which points at the consumer repository.
37
42
  if (guardResults && Array.isArray(guardResults)) {
38
43
  const totalChecks = guardResults.reduce((sum, r) => {
39
44
  if (r.status === 'skipped') return sum;
40
45
  return sum + (r.total || 0);
41
46
  }, 0);
42
- // +1 because Metrics-Consistency itself hasn't been added to results yet
43
- const validatorCount = guardResults.filter(r => r.status !== 'skipped').length + 1;
44
-
45
47
  actuals.checks = totalChecks;
46
- actuals.validators = validatorCount;
47
48
  }
49
+ const shippedValidatorCount = countShippedValidators();
50
+ if (shippedValidatorCount !== null) actuals.validators = shippedValidatorCount;
48
51
 
49
52
  // Test count — count test files on disk
50
53
  const testFiles = findTestFiles(projectDir);
@@ -68,7 +71,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
68
71
  // subject before overwriting.
69
72
  const patterns = [
70
73
  { key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks', requireBind: true, subject: "DocGuard's own", actualSource: 'docguard.guard.checks' },
71
- { key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators', requireBind: true, subject: "DocGuard's own", actualSource: 'docguard.guard.validators' },
74
+ { key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators', requireBind: true, subject: "DocGuard's shipped", actualSource: 'docguard.package.validators' },
72
75
  ];
73
76
 
74
77
  // v0.29 (field report #6): project-declared collections. `config.collections`
@@ -245,6 +248,15 @@ function isHistoricalMetricContext(content, index) {
245
248
  return /<!--\s*docguard:status\s+(?:historical|superseded|deprecated|archived)\s*-->/i.test(prefix);
246
249
  }
247
250
 
251
+ /**
252
+ * Count validator modules shipped beside this file. This is deliberately
253
+ * package-local: a consumer's disabled validators or repository layout cannot
254
+ * alter a statement about DocGuard's own capability surface.
255
+ */
256
+ function countShippedValidators() {
257
+ return countValidatorModules(dirname(fileURLToPath(import.meta.url)));
258
+ }
259
+
248
260
  function findTestFiles(dir) {
249
261
  const tests = [];
250
262
  const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
@@ -25,13 +25,16 @@ export function validateSpecRegistry(projectDir, config = {}) {
25
25
  },
26
26
  }));
27
27
  if (!projection.current && projection.issues.length === 0) {
28
+ const detail = projection.differences.slice(0, 3)
29
+ .map(difference => `${difference.path}: ${difference.message}`)
30
+ .join(' ');
28
31
  findings.push(mkFinding({
29
32
  code: 'SPR001',
30
33
  validator: 'specRegistry',
31
34
  severity: 'warn',
32
35
  confidence: 'high',
33
36
  message: projection.exists
34
- ? `${SPEC_REGISTRY_PATH} does not match the current deterministic spec evidence projection.`
37
+ ? `${SPEC_REGISTRY_PATH} does not match the current deterministic spec evidence projection.${detail ? ` ${detail}` : ''}`
35
38
  : `${SPEC_REGISTRY_PATH} is missing while active specifications exist.`,
36
39
  location: SPEC_REGISTRY_PATH,
37
40
  suggestion: {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * TODO/FIXME Tracking Validator — Ensures code annotations are documented
3
3
  *
4
- * Scans source files for TODO:, FIXME:, HACK:, XXX: annotations and checks
5
- * if they are tracked in documentation (ROADMAP.md, CURRENT-STATE.md, etc.).
4
+ * Scans source files for debt tags (todo / fixme / hack / xxx, with a
5
+ * trailing colon or paren) and checks if they are tracked in documentation
6
+ * (ROADMAP.md, CURRENT-STATE.md, etc.).
6
7
  *
7
8
  * Also detects skipped tests without explanation.
8
9
  *
@@ -33,7 +33,11 @@ import {
33
33
  requirementPatterns,
34
34
  } from '../shared-requirements.mjs';
35
35
  import { readRetirementManifest } from '../scanners/retirement-manifest.mjs';
36
- import { parseSpecId, trustedSpecLifecycleIndex } from '../scanners/spec-registry.mjs';
36
+ import {
37
+ parseSpecId,
38
+ trustedSpecLifecycleIndex,
39
+ uncommittedPlannedSpecLifecycleIndex,
40
+ } from '../scanners/spec-registry.mjs';
37
41
 
38
42
  /**
39
43
  * Optional graphify interop (github.com/Graphify-Labs/graphify, MIT).
@@ -278,6 +282,7 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
278
282
  const reqIds = collectRequirementIds(projectDir, config, patterns);
279
283
  const retiredReqIds = loadRetiredRequirementIds(projectDir);
280
284
  const lifecycleIndex = trustedSpecLifecycleIndex(projectDir);
285
+ const uncommittedPlannedIndex = uncommittedPlannedSpecLifecycleIndex(projectDir);
281
286
 
282
287
  // ── Step 2: Scan test files for requirement ID references ──
283
288
  const testRefs = scanTestFilesForReferences(projectDir, projectFiles, patterns);
@@ -305,11 +310,13 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
305
310
  // digest-current reviewed lifecycle can defer their test linkage.
306
311
  for (const [key, location] of reqIds) {
307
312
  const reqId = location.id;
308
- const lifecycle = location.specId ? lifecycleIndex.get(`${location.specId}\0${location.file}`) : null;
313
+ const lifecycleKey = location.specId ? `${location.specId}\0${location.file}` : null;
314
+ const lifecycle = lifecycleKey ? lifecycleIndex.get(lifecycleKey) : null;
309
315
  if (lifecycle?.delivery === 'planned') {
310
316
  deferred++;
311
317
  continue;
312
318
  }
319
+ const uncommittedPlanned = lifecycleKey && uncommittedPlannedIndex.has(lifecycleKey);
313
320
  if (location.specId && !lifecycle) lifecycleUnknown++;
314
321
  total++;
315
322
  if (resolvedRefs.has(key)) {
@@ -317,10 +324,12 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
317
324
  } else {
318
325
  // Try to recover a likely-but-unannotated test via TF-IDF cosine.
319
326
  let softHint = '';
320
- let softText = `Review existing tests for this requirement. If a test verifies it, add an @req ${key} annotation or requirement ID test label; write a test only if behavioral coverage is actually missing.`;
327
+ let softText = uncommittedPlanned
328
+ ? 'The on-disk lifecycle registry says this requirement is planned, but .docguard-specs.json is not a clean tracked Git artifact. Restore it to its committed state, or commit the registry and its current spec artifacts, before treating the requirement as deferred; do not add an @req marker merely to silence this warning.'
329
+ : `Review existing tests for this requirement. If a test verifies it, add an @req ${key} annotation or requirement ID test label; write a test only if behavioral coverage is actually missing.`;
321
330
  const queryText = location.text && location.text.length > reqId.length ? location.text : reqId;
322
- if (testCorpus === null) testCorpus = buildTestCorpus(projectDir, projectFiles);
323
- if (testCorpus.length > 0) {
331
+ if (!uncommittedPlanned && testCorpus === null) testCorpus = buildTestCorpus(projectDir, projectFiles);
332
+ if (!uncommittedPlanned && testCorpus.length > 0) {
324
333
  const ranked = rankBySimilarity(tokenize(queryText), testCorpus);
325
334
  const top = ranked[0];
326
335
  if (top && top.score >= softThreshold) {
@@ -68,7 +68,7 @@ diagnose → AI reads prompts → AI fixes docs → guard verifies
68
68
  ## Verify
69
69
 
70
70
  ```bash
71
- npx docguard-cli guard # Pass/fail check (30 validators)
71
+ npx docguard-cli guard # Pass/fail check (29 validators)
72
72
  npx docguard-cli score # 0-100 maturity score
73
73
  ```
74
74
 
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.41.2"
6
+ version: "0.41.4"
7
7
  description: "Documentation integrity for AI-assisted repositories: lifecycle registry, drift validation, traceability, safe archival, SARIF/JUnit, MCP, GitHub Actions, and Spec Kit hooks."
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.41.2
9
+ version: 0.41.4
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.41.2 -->
12
+ <!-- docguard:version: 0.41.4 -->
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.41.2
10
+ version: 0.41.4
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.41.2 -->
13
+ <!-- docguard:version: 0.41.4 -->
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.41.2
9
+ version: 0.41.4
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.41.2 -->
12
+ <!-- docguard:version: 0.41.4 -->
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.41.2
9
+ version: 0.41.4
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.41.2 -->
12
+ <!-- docguard:version: 0.41.4 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,10 +4,10 @@ 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.41.2
7
+ version: 0.41.4
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.41.2 -->
10
+ <!-- docguard:version: 0.41.4 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
@@ -44,7 +44,7 @@ jobs:
44
44
  fetch-depth: 0
45
45
 
46
46
  - name: Run DocGuard fix --write + auto-commit + PR comment
47
- uses: raccioly/docguard@v0.41.2
47
+ uses: raccioly/docguard@v0.41.4
48
48
  with:
49
49
  command: fix
50
50
  auto-commit: 'true'
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
 
37
37
  - name: Install DocGuard
38
- run: npm install --global --ignore-scripts docguard-cli@0.41.2
38
+ run: npm install --global --ignore-scripts docguard-cli@0.41.4
39
39
 
40
40
  - name: Run DocGuard
41
41
  shell: bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.41.2",
3
+ "version": "0.41.4",
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": {
@@ -31,7 +31,7 @@ jobs:
31
31
  node-version: '20'
32
32
 
33
33
  - name: Install DocGuard
34
- run: npm install --global --ignore-scripts docguard-cli@0.41.2
34
+ run: npm install --global --ignore-scripts docguard-cli@0.41.4
35
35
 
36
36
  - name: Run DocGuard
37
37
  shell: bash