docguard-cli 0.23.0 → 0.25.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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/cli/commands/diff.mjs +1 -1
  3. package/cli/commands/explain.mjs +178 -17
  4. package/cli/commands/fix.mjs +17 -2
  5. package/cli/commands/generate.mjs +69 -3
  6. package/cli/commands/guard.mjs +86 -11
  7. package/cli/commands/hooks.mjs +12 -7
  8. package/cli/commands/init.mjs +24 -8
  9. package/cli/commands/score.mjs +147 -61
  10. package/cli/commands/setup.mjs +2 -2
  11. package/cli/commands/sync.mjs +6 -0
  12. package/cli/commands/trace.mjs +3 -3
  13. package/cli/commands/upgrade.mjs +61 -13
  14. package/cli/config.mjs +18 -1
  15. package/cli/docguard.mjs +156 -2
  16. package/cli/ensure-skills.mjs +24 -26
  17. package/cli/scanners/api-doc.mjs +17 -3
  18. package/cli/scanners/doc-tools.mjs +32 -15
  19. package/cli/scanners/frontend.mjs +24 -8
  20. package/cli/scanners/js-ast.mjs +432 -0
  21. package/cli/scanners/memory-plan.mjs +1 -1
  22. package/cli/scanners/project-type.mjs +11 -4
  23. package/cli/scanners/py-ast.mjs +213 -0
  24. package/cli/scanners/routes.mjs +194 -69
  25. package/cli/scanners/schemas.mjs +97 -51
  26. package/cli/shared-git.mjs +0 -0
  27. package/cli/shared-ignore.mjs +23 -2
  28. package/cli/shared-source.mjs +59 -2
  29. package/cli/shared-trace-patterns.mjs +13 -0
  30. package/cli/shared.mjs +92 -1
  31. package/cli/validator-markers.mjs +91 -0
  32. package/cli/validators/api-surface.mjs +37 -3
  33. package/cli/validators/canonical-sync.mjs +22 -19
  34. package/cli/validators/doc-quality.mjs +2 -42
  35. package/cli/validators/docs-coverage.mjs +13 -0
  36. package/cli/validators/docs-sync.mjs +4 -3
  37. package/cli/validators/drift.mjs +3 -2
  38. package/cli/validators/freshness.mjs +47 -15
  39. package/cli/validators/generated-staleness.mjs +16 -1
  40. package/cli/validators/metadata-sync.mjs +21 -11
  41. package/cli/validators/metrics-consistency.mjs +45 -17
  42. package/cli/validators/security.mjs +13 -5
  43. package/cli/validators/structure.mjs +6 -5
  44. package/cli/validators/surface-sync.mjs +7 -5
  45. package/cli/validators/test-spec.mjs +76 -51
  46. package/cli/validators/todo-tracking.mjs +4 -2
  47. package/cli/validators/traceability.mjs +11 -3
  48. package/cli/writers/sections.mjs +32 -19
  49. package/docs/commands.md +1 -1
  50. package/docs/configuration.md +11 -0
  51. package/docs/faq.md +1 -1
  52. package/extensions/spec-kit-docguard/README.md +1 -1
  53. package/extensions/spec-kit-docguard/extension.yml +2 -2
  54. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  55. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  56. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  57. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
  59. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
  60. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
  61. package/package.json +5 -3
@@ -124,37 +124,40 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
124
124
  actualValidatorNames = guardResults.map(r => r.name).filter(Boolean);
125
125
  }
126
126
 
127
- // ── Read README ─────────────────────────────────────────────────────
128
- const readmePath = resolve(projectDir, 'README.md');
129
- if (!existsSync(readmePath)) {
130
- result.warnings.push('canonical-sync: README.md not foundcannot check surface claims');
131
- result.total = 1;
132
- return result;
127
+ // ── Read surface docs (README.md + AGENTS.md) ──────────────────────
128
+ // Both carry "N commands / N validators" surface claims. Scanning only the
129
+ // README is why AGENTS.md's counts ("Commands (15 total)", "24 validators")
130
+ // drifted unchecked for releases close that gap by checking both.
131
+ const surfaceFiles = ['README.md', 'AGENTS.md'];
132
+ let readme = '';
133
+ let readAny = false;
134
+ for (const f of surfaceFiles) {
135
+ const p = resolve(projectDir, f);
136
+ if (!existsSync(p)) continue;
137
+ try { readme += readFileSync(p, 'utf-8') + '\n'; readAny = true; } catch { /* skip unreadable */ }
133
138
  }
134
-
135
- let readme;
136
- try {
137
- readme = readFileSync(readmePath, 'utf-8');
138
- } catch {
139
- result.warnings.push('canonical-sync: README.md unreadable');
139
+ if (!readAny) {
140
+ result.warnings.push('canonical-sync: no README.md or AGENTS.md found — cannot check surface claims');
140
141
  result.total = 1;
141
142
  return result;
142
143
  }
143
144
 
144
145
  // ── Check 1: "ships N commands" ─────────────────────────────────────
146
+ // Check ALL claims (matchAll), not just the first: with README + AGENTS.md
147
+ // concatenated, a correct claim in one file must not mask a stale claim in
148
+ // the other (the same first-match-masking trap the secret scanner had).
145
149
  result.total++;
146
- const shipsCommandsRe = /ships\s+\*{0,2}(\d+)\s+commands?\*{0,2}/i;
147
- const m1 = readme.match(shipsCommandsRe);
148
- if (m1) {
149
- const claimed = Number(m1[1]);
150
- if (claimed === actualCommandCount) {
150
+ const cmdMatches = [...readme.matchAll(/ships\s+\*{0,2}(\d+)\s+commands?\*{0,2}/gi)];
151
+ if (cmdMatches.length > 0) {
152
+ const wrong = [...new Set(cmdMatches.map(m => Number(m[1])).filter(n => n !== actualCommandCount))];
153
+ if (wrong.length === 0) {
151
154
  result.passed++;
152
155
  } else {
153
156
  const detail = actualUserFacingCount !== actualCommandFileCount
154
157
  ? `${actualCommandCount} user-facing commands in --help (${actualCommandFileCount} files including deprecation aliases)`
155
158
  : `${actualCommandCount} command file(s)`;
156
159
  result.warnings.push(
157
- `README.md claims "ships ${claimed} commands" but the real count is ${detail}. Update the README.`
160
+ `A surface doc (README.md/AGENTS.md) claims ${wrong.map(n => `"ships ${n} commands"`).join(' / ')} but the real count is ${detail}. Update it.`
158
161
  );
159
162
  }
160
163
  } else {
@@ -177,7 +180,7 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
177
180
  } else {
178
181
  const uniqueWrong = [...new Set(wrongClaims)];
179
182
  result.warnings.push(
180
- `README.md claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update the README.`
183
+ `A surface doc (README.md/AGENTS.md) claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update it.`
181
184
  );
182
185
  }
183
186
  } else {
@@ -16,14 +16,12 @@
16
16
  * cells as "long sentences"), this version extracts ONLY actual prose
17
17
  * paragraphs. Docs that are mostly tables/code skip readability scoring.
18
18
  *
19
- * Optional: If `understanding` CLI is installed, runs a full 31-metric deep scan.
20
- *
21
- * Zero NPM runtime dependencies — pure Node.js built-ins only.
19
+ * Zero NPM runtime dependencies, and zero process execution pure Node.js
20
+ * built-ins reading files only.
22
21
  */
23
22
 
24
23
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
24
  import { resolve, join, extname } from 'node:path';
26
- import { execSync, execFileSync } from 'node:child_process';
27
25
 
28
26
  // ──── Metric Thresholds ────
29
27
  // These define "good" vs "warning" boundaries for each metric.
@@ -441,40 +439,6 @@ function getGradeLabel(grade) {
441
439
  return 'graduate+';
442
440
  }
443
441
 
444
- // ──── Understanding CLI Integration ────
445
-
446
- /**
447
- * Check if the `understanding` CLI is available on the system.
448
- */
449
- function findUnderstandingCli() {
450
- try {
451
- const cmd = process.platform === 'win32' ? 'where understanding' : 'which understanding';
452
- const result = execSync(`${cmd} 2>/dev/null`, {
453
- encoding: 'utf-8',
454
- timeout: 3000,
455
- }).trim();
456
- return result || null;
457
- } catch {
458
- return null;
459
- }
460
- }
461
-
462
- /**
463
- * Run the `understanding` CLI on a file and parse results.
464
- */
465
- function runUnderstandingDeepScan(filePath) {
466
- try {
467
- const result = execFileSync('understanding', ['analyze', filePath, '--enhanced', '--json'], {
468
- encoding: 'utf-8',
469
- timeout: 10000,
470
- stdio: ['pipe', 'pipe', 'ignore'],
471
- });
472
- return JSON.parse(result);
473
- } catch {
474
- return null;
475
- }
476
- }
477
-
478
442
  // ──── Main Validator ────
479
443
 
480
444
  /**
@@ -587,10 +551,6 @@ export function validateDocQuality(projectDir, config) {
587
551
  return results;
588
552
  }
589
553
 
590
- // Check for optional understanding CLI
591
- const understandingCli = findUnderstandingCli();
592
- const useDeepScan = config.docQuality?.deepScan !== false && understandingCli;
593
-
594
554
  for (const doc of docs) {
595
555
  if (!existsSync(doc.path)) continue;
596
556
 
@@ -35,8 +35,20 @@ const COMMON_DOTFILES = new Set([
35
35
  '.env', '.env.local', '.env.development', '.env.production',
36
36
  '.vscode', '.idea', '.github', '.husky',
37
37
  '.babelrc', '.browserslistrc', '.stylelintrc',
38
+ '.dockerignore', '.python-version', '.tool-versions', '.ruby-version',
39
+ '.gitkeep', '.keep',
38
40
  ]);
39
41
 
42
+ // Generated tool artifacts (caches, coverage data, lock-data) that land at the
43
+ // repo root but are NOT configuration a human authors or documents. Treating
44
+ // them as "undocumented config files" is a false positive (field test:
45
+ // quick-recon-tool flagged pytest's `.coverage` SQLite data file). Matched by
46
+ // exact name OR prefix (`.coverage.<host>.<pid>` is coverage.py's parallel form).
47
+ const GENERATED_DOTFILE_PREFIXES = ['.coverage', '.eslintcache', '.stylelintcache', '.tsbuildinfo'];
48
+ function isGeneratedArtifact(name) {
49
+ return GENERATED_DOTFILE_PREFIXES.some(p => name === p || name.startsWith(p + '.'));
50
+ }
51
+
40
52
  /**
41
53
  * Validate that code artifacts are referenced in documentation.
42
54
  * @param {string} projectDir - Project root directory
@@ -125,6 +137,7 @@ function checkConfigFiles(projectDir, allDocContent, config = {}) {
125
137
 
126
138
  if (!isDotFile && !isProjectConfig) continue;
127
139
  if (COMMON_DOTFILES.has(entry)) continue;
140
+ if (isGeneratedArtifact(entry)) continue;
128
141
  if (entry === 'tsconfig.json' || entry === 'package-lock.json') continue;
129
142
 
130
143
  // Skip directories — this check is for configuration FILES, not dirs.
@@ -5,6 +5,7 @@
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
6
  import { resolve, join, extname, basename } from 'node:path';
7
7
  import { resolveSourceRoots } from '../shared-source.mjs';
8
+ import { relPosix } from '../shared-ignore.mjs';
8
9
 
9
10
  const IGNORE_DIRS = new Set([
10
11
  'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
@@ -103,7 +104,7 @@ export function validateDocsSync(projectDir, config) {
103
104
  const ext = extname(file);
104
105
  if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
105
106
 
106
- const relPath = file.replace(projectDir + '/', '');
107
+ const relPath = relPosix(projectDir, file);
107
108
  if (isTestFile(relPath)) continue;
108
109
  if (!isValidRouteFile(relPath)) continue;
109
110
  // N-1: skip files outside the --changed-only scope.
@@ -129,7 +130,7 @@ export function validateDocsSync(projectDir, config) {
129
130
  const ext = extname(file);
130
131
  if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
131
132
 
132
- const relPath = file.replace(projectDir + '/', '');
133
+ const relPath = relPosix(projectDir, file);
133
134
  if (isTestFile(relPath)) continue;
134
135
  // N-1: skip files outside the --changed-only scope.
135
136
  if (!inScope(relPath)) continue;
@@ -175,7 +176,7 @@ export function validateDocsSync(projectDir, config) {
175
176
  const ext = extname(file);
176
177
  if (!['.ts', '.tsx', '.js', '.jsx', '.mjs'].includes(ext)) continue;
177
178
 
178
- const relPathForFilter = file.replace(projectDir + '/', '');
179
+ const relPathForFilter = relPosix(projectDir, file);
179
180
  if (isTestFile(relPathForFilter)) continue;
180
181
  if (!isValidRouteFile(relPathForFilter)) continue;
181
182
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
6
  import { resolve, join, extname } from 'node:path';
7
+ import { relPosix } from '../shared-ignore.mjs';
7
8
 
8
9
  const CODE_EXTENSIONS = new Set([
9
10
  '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
@@ -31,7 +32,7 @@ export function validateDrift(projectDir, config) {
31
32
  // string fixtures (e.g. `'// DRIFT: a-drift\n'`). Reading the test as
32
33
  // source would treat the string as a real drift comment. Skip test
33
34
  // files unless the user opts in — same pattern TODO-Tracking uses.
34
- const rel = filePath.replace(projectDir + '/', '');
35
+ const rel = relPosix(projectDir, filePath);
35
36
  const includeTests = config?.drift?.includeTestFiles === true;
36
37
  if (!includeTests && /(^|\/)(__tests__|tests?|spec)\/|\.(test|spec)\.[^.]+$/.test(rel)) {
37
38
  return;
@@ -44,7 +45,7 @@ export function validateDrift(projectDir, config) {
44
45
  const match = line.match(/(?:\/\/|#|\/\*|\-\-)\s*DRIFT:\s*(.+)/i);
45
46
  if (match) {
46
47
  driftComments.push({
47
- file: filePath.replace(projectDir + '/', ''),
48
+ file: relPosix(projectDir, filePath),
48
49
  line: i + 1,
49
50
  comment: match[1].trim(),
50
51
  });
@@ -42,13 +42,20 @@ const IGNORE_DIRS = new Set([
42
42
  * (e.g., the reviewer read the file, confirmed it still matches reality, and
43
43
  * stamped the header without touching content, so there is no commit to find).
44
44
  */
45
- function readLastReviewedDate(absPath) {
45
+ export function readLastReviewedDate(absPath) {
46
46
  try {
47
47
  const content = readFileSync(absPath, 'utf-8');
48
48
  const m = content.match(/<!--\s*docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})\s*-->/);
49
49
  if (!m) return null;
50
50
  const d = new Date(m[1] + 'T00:00:00Z');
51
- return isNaN(d.getTime()) ? null : d;
51
+ if (isNaN(d.getTime())) return null;
52
+ // Reject future-dated headers. A typo'd or copy-pasted future date (e.g.
53
+ // 2030-01-01) would otherwise make a genuinely stale doc look "fresh"
54
+ // forever — its age goes negative and "commits since" rounds to zero. A
55
+ // review can't legitimately have happened in the future, so we ignore the
56
+ // header and fall back to the real git date. (1-day grace for timezones.)
57
+ if (d.getTime() > Date.now() + 24 * 60 * 60 * 1000) return null;
58
+ return d;
52
59
  } catch {
53
60
  return null;
54
61
  }
@@ -90,11 +97,15 @@ function getLastGitDate(filePath, dir) {
90
97
  function getCodeCommitsSince(date, dir) {
91
98
  try {
92
99
  const isoDate = date.toISOString();
93
- const result = execSync(
94
- `git log --since="${isoDate}" --oneline --diff-filter=M -- "*.js" "*.mjs" "*.ts" "*.tsx" "*.py" "*.java" "*.go" | wc -l`,
100
+ // execFileSync (argv array) + count in JS — no shell `| wc -l` pipe, which
101
+ // isn't portable (Windows) and made the count depend on an external binary.
102
+ const out = execFileSync(
103
+ 'git',
104
+ ['log', `--since=${isoDate}`, '--oneline', '--diff-filter=M', '--',
105
+ '*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.java', '*.go'],
95
106
  { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
96
107
  ).trim();
97
- return parseInt(result) || 0;
108
+ return out ? out.split('\n').filter(Boolean).length : 0;
98
109
  } catch {
99
110
  return 0;
100
111
  }
@@ -132,11 +143,13 @@ function getTotalCommits(dir) {
132
143
  */
133
144
  function getRecentCodeCommits(dir, count = 5) {
134
145
  try {
135
- const result = execSync(
136
- `git log -${count} --format="%h %aI %s" -- "*.js" "*.mjs" "*.ts" "*.tsx" "*.py" "*.java"`,
146
+ const out = execFileSync(
147
+ 'git',
148
+ ['log', `-${count}`, '--format=%h %aI %s', '--',
149
+ '*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.java'],
137
150
  { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
138
151
  ).trim();
139
- return result ? result.split('\n') : [];
152
+ return out ? out.split('\n') : [];
140
153
  } catch {
141
154
  return [];
142
155
  }
@@ -208,8 +221,18 @@ export function validateFreshness(dir, config) {
208
221
  continue;
209
222
  }
210
223
 
211
- // Check how many code commits happened since this doc was last updated
212
- const codeCommitsSince = getCodeCommitsSince(docDate, dir);
224
+ // Check how many code commits happened since this doc was last updated.
225
+ // A `last-reviewed` HEADER is day-granular and signals "I reviewed this ON
226
+ // this day" — so it covers commits made that same day. Counting from
227
+ // midnight would flag a doc as stale on the very day it was genuinely
228
+ // reviewed whenever >10 code commits also landed that day (a heavy-dev day),
229
+ // undermining the explicit-review signal this validator otherwise honors.
230
+ // Advance a header date to end-of-day so only commits on LATER days count.
231
+ // Git fallback dates are real timestamps and are used as-is.
232
+ const sinceDate = reviewedDate
233
+ ? new Date(reviewedDate.getTime() + 24 * 60 * 60 * 1000 - 1000)
234
+ : docDate;
235
+ const codeCommitsSince = getCodeCommitsSince(sinceDate, dir);
213
236
 
214
237
  if (codeCommitsSince >= WARNING_THRESHOLD_COMMITS) {
215
238
  results.push({
@@ -263,13 +286,22 @@ export function validateFreshness(dir, config) {
263
286
  const driftPath = resolve(dir, config.requiredFiles?.driftLog || 'DRIFT-LOG.md');
264
287
  if (existsSync(driftPath)) {
265
288
  const driftDate = getLastGitDate(config.requiredFiles?.driftLog || 'DRIFT-LOG.md', dir);
266
- // Check for recent DRIFT comments added to code
289
+ // Check for recent DRIFT comments ADDED to code. The old approach piped
290
+ // `git log --all -p | grep -c DRIFT:`, which counted DRIFT: on removed
291
+ // lines, unchanged context, and every branch (`--all`) — wildly inflating
292
+ // the count and depending on `grep`. Here we read the last-5-commits diff
293
+ // for the current branch and count only ADDED lines (`+`, not the `+++`
294
+ // file header) that introduce a DRIFT comment.
267
295
  try {
268
- const recentDrifts = execSync(
269
- `git log -5 --all -p -- "*.js" "*.mjs" "*.ts" "*.tsx" "*.py" | grep -c "DRIFT:" || true`,
296
+ const diff = execFileSync(
297
+ 'git',
298
+ ['log', '-5', '-p', '--', '*.js', '*.mjs', '*.ts', '*.tsx', '*.py'],
270
299
  { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
271
- ).trim();
272
- const driftCount = parseInt(recentDrifts) || 0;
300
+ );
301
+ const driftCount = diff
302
+ .split('\n')
303
+ .filter(l => /^\+(?!\+\+)/.test(l) && l.includes('DRIFT:'))
304
+ .length;
273
305
  if (driftCount > 0 && driftDate) {
274
306
  const codeCommitsSince = getCodeCommitsSince(driftDate, dir);
275
307
  if (codeCommitsSince > 3) {
@@ -173,6 +173,19 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
173
173
  if (!onDisk) continue;
174
174
 
175
175
  result.total++;
176
+
177
+ // B5 (field report): a `pinned` attribute on the section's open marker
178
+ // <!-- docguard:section id=… source=code pinned="reason" -->
179
+ // marks the section as intentionally hand-maintained — the scanner
180
+ // mislabeled this surface (e.g. a scanner/tool repo whose source contains
181
+ // framework-like strings; see F1). Exempt it from staleness and count it
182
+ // as a pass, mirroring the docguard:quality opt-out marker. This is the
183
+ // escape hatch from the "stale forever / sync --write reverts it" trap.
184
+ if (onDisk.attrs?.pinned !== undefined) {
185
+ result.passed++;
186
+ continue;
187
+ }
188
+
176
189
  const expected = String(sec.body || '').trim();
177
190
  const actual = String(onDisk.body || '').trim();
178
191
 
@@ -194,7 +207,9 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
194
207
  : '';
195
208
 
196
209
  result.warnings.push(
197
- `${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections.`
210
+ `${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections. ` +
211
+ `If this section is intentionally hand-maintained (the scanner mislabeled it), pin it: ` +
212
+ `add \`pinned="reason"\` to its \`<!-- docguard:section id=${sec.id} … -->\` marker.`
198
213
  );
199
214
  // v0.14-P3: structured fix so `docguard fix --write` can fix this
200
215
  // mechanically (no AI needed — scanner already produced the right body).
@@ -32,21 +32,28 @@ export function validateMetadataSync(projectDir, config) {
32
32
  // workspace manifest with no version, fall back to a source-root package.
33
33
  const pkgPath = resolve(projectDir, 'package.json');
34
34
  let currentVersion = null;
35
+ let currentName = null;
35
36
  if (existsSync(pkgPath)) {
36
- try { currentVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version || null; } catch { /* ignore */ }
37
+ try {
38
+ const pj = JSON.parse(readFileSync(pkgPath, 'utf-8'));
39
+ currentVersion = pj.version || null;
40
+ currentName = pj.name || null;
41
+ } catch { /* ignore */ }
37
42
  }
38
43
  if (!currentVersion) {
39
44
  for (const { pkg } of collectPackageJsons(projectDir, config)) {
40
- if (pkg.version) { currentVersion = pkg.version; break; }
45
+ if (pkg.version) { currentVersion = pkg.version; currentName = currentName || pkg.name || null; break; }
41
46
  }
42
47
  }
43
48
  if (!currentVersion) return { errors: [], warnings, passed: 0, total: 0 };
44
49
 
45
- // Parse into components for smart comparison
50
+ // Parse into components for smart comparison. `|| 0` guards two-part versions
51
+ // (e.g. "1.2"): without it vParts[2] is undefined → parseInt → NaN, and every
52
+ // `fPatch < patch` comparison silently becomes false, disabling the check.
46
53
  const vParts = currentVersion.split('.');
47
- const major = parseInt(vParts[0], 10);
48
- const minor = parseInt(vParts[1], 10);
49
- const patch = parseInt(vParts[2], 10);
54
+ const major = parseInt(vParts[0], 10) || 0;
55
+ const minor = parseInt(vParts[1], 10) || 0;
56
+ const patch = parseInt(vParts[2], 10) || 0;
50
57
 
51
58
  // ── Check 1: extension.yml version sync ──
52
59
  const extFiles = findExtensionYmls(projectDir);
@@ -72,8 +79,6 @@ export function validateMetadataSync(projectDir, config) {
72
79
  // ── Check 2: Version references in markdown files ──
73
80
  const isIgnored = loadIgnorePatterns(projectDir);
74
81
  const mdFiles = findMarkdownFiles(projectDir);
75
- // Version patterns to find: v0.7.2, @0.7.2, /v0.7.2/, docguard-cli@0.7.2
76
- const versionRegex = /(?:v|@|\/v?)(\d+\.\d+\.\d+)/g;
77
82
 
78
83
  for (const mdFile of mdFiles) {
79
84
  const relPath = relative(projectDir, mdFile);
@@ -93,13 +98,18 @@ export function validateMetadataSync(projectDir, config) {
93
98
  // - Badge URLs
94
99
  // NOT in prose text like "In v0.2.0 we added..." or roadmap discussions
95
100
  const actionablePatterns = [
96
- // URLs with version: /v0.7.2/, /tags/v0.7.2, @0.7.2
101
+ // URLs with version: /v0.7.2/, /tags/v0.7.2, /releases/0.7.2
97
102
  /(?:archive|tags|releases|download)\/v?(\d+\.\d+\.\d+)/g,
98
- // npm install/npx commands: docguard-cli@0.7.2
99
- /@(\d+\.\d+\.\d+)/g,
100
103
  // YAML-style: version: "0.7.2" or version: 0.7.2
101
104
  /version:\s*["']?(\d+\.\d+\.\d+)["']?/g,
102
105
  ];
106
+ // npm/npx refs to THIS package only (e.g. docguard-cli@0.7.2), anchored to
107
+ // the package name. A bare /@(\d+\.\d+\.\d+)/ used to over-match unrelated
108
+ // versions — node@18.2.0, @types/node@1.2.3, or "@1.2.3" in prose.
109
+ if (currentName) {
110
+ const escaped = currentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
111
+ actionablePatterns.push(new RegExp(`${escaped}@v?(\\d+\\.\\d+\\.\\d+)`, 'g'));
112
+ }
103
113
 
104
114
  for (const pattern of actionablePatterns) {
105
115
  pattern.lastIndex = 0;
@@ -57,7 +57,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
57
57
 
58
58
  // ── Scan markdown files for hardcoded numbers ──
59
59
  const isIgnored = loadIgnorePatterns(projectDir);
60
- const mdFiles = findMarkdownFiles(projectDir);
60
+ const mdFiles = findMarkdownFiles(projectDir, config);
61
61
  // Patterns must match standalone number references, not ratio-style "8/8 checks"
62
62
  const patterns = [
63
63
  { key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks' },
@@ -151,24 +151,52 @@ function findTestFiles(dir) {
151
151
  return tests;
152
152
  }
153
153
 
154
- function findMarkdownFiles(dir) {
154
+ // DocGuard's OWN installed slash-command docs (commands/docguard.*.md, and the
155
+ // .agent/commands/ variant). These are tool-managed, not the project's docs —
156
+ // scanning them flags DocGuard's own (sometimes stale) shipped "N validators"
157
+ // count as the USER's drift, which they can't meaningfully act on. (.agent/ and
158
+ // .specify/ are already dot-skipped by walkFiles; this catches the legacy ROOT
159
+ // commands/ install location. A user's own commands/<name>.md is NOT excluded.)
160
+ const DOCGUARD_OWN_DOC_RE = /[\\/](?:\.agent[\\/])?commands[\\/]docguard\.[a-z-]+\.md$/i;
161
+
162
+ function findMarkdownFiles(dir, config = {}) {
155
163
  const seen = new Set();
156
164
  const mdFiles = [];
157
- // Check root, docs-canonical, and extensions
158
- const searchDirs = [
159
- dir,
160
- resolve(dir, 'docs-canonical'),
161
- resolve(dir, 'extensions'),
162
- ];
163
-
164
- for (const searchDir of searchDirs) {
165
- if (!existsSync(searchDir)) continue;
166
- walkFiles(searchDir, (f) => {
167
- if (f.endsWith('.md') && !seen.has(f)) {
168
- seen.add(f);
169
- mdFiles.push(f);
170
- }
171
- });
165
+ const add = (f) => {
166
+ if (f.endsWith('.md') && !seen.has(f) && !DOCGUARD_OWN_DOC_RE.test(f)) {
167
+ seen.add(f);
168
+ mdFiles.push(f);
169
+ }
170
+ };
171
+
172
+ // Root LEVEL ONLY (non-recursive): README and other top-level docs. A
173
+ // "N validators / N checks" claim that refers to DocGuard lives in the README
174
+ // or the canonical docs — not five levels deep under security/ or backend/.
175
+ // The old code recursively walked the WHOLE repo from the root, so it swept in
176
+ // OpenWolf session archives (security/wolf-archive/**/memory.md) and vendored
177
+ // toolkit READMEs whose unrelated "N checks" prose was then reported as the
178
+ // USER's drift (field test: wu-whatsappinbox, ~39 false warnings the author
179
+ // could not act on). Scoping to the docs DocGuard actually governs fixes it.
180
+ try {
181
+ for (const entry of readdirSync(dir)) {
182
+ const full = join(dir, entry);
183
+ try { if (statSync(full).isFile()) add(full); } catch { /* unreadable entry */ }
184
+ }
185
+ } catch { /* unreadable root */ }
186
+
187
+ // Configured canonical docs (wherever they live), plus the conventional
188
+ // doc homes (docs/, docs-canonical/, extensions/) — scanned in full
189
+ // (recursive). Code/tooling dirs (security/, backend/, src/, …) are NOT doc
190
+ // homes and are deliberately excluded.
191
+ const canonical = config && config.requiredFiles && Array.isArray(config.requiredFiles.canonical)
192
+ ? config.requiredFiles.canonical : [];
193
+ for (const rel of canonical) {
194
+ const full = resolve(dir, rel);
195
+ if (existsSync(full)) { try { if (statSync(full).isFile()) add(full); } catch { /* skip */ } }
196
+ }
197
+ for (const sub of ['docs', 'docs-canonical', 'extensions']) {
198
+ const searchDir = resolve(dir, sub);
199
+ if (existsSync(searchDir)) walkFiles(searchDir, add);
172
200
  }
173
201
 
174
202
  return mdFiles;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
9
  import { resolve, join, extname } from 'node:path';
10
- import { shouldIgnore } from '../shared-ignore.mjs';
10
+ import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
11
11
 
12
12
  const CODE_EXTENSIONS = new Set([
13
13
  '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
@@ -69,7 +69,7 @@ export function validateSecurity(projectDir, config) {
69
69
  // Skip .env.example — it should have placeholder values
70
70
  if (filePath.endsWith('.env.example')) return;
71
71
 
72
- const relPath = filePath.replace(projectDir + '/', '');
72
+ const relPath = relPosix(projectDir, filePath);
73
73
 
74
74
  // Apply config ignore patterns (securityIgnore + global ignore)
75
75
  if (shouldIgnore(relPath, config, 'securityIgnore')) return;
@@ -80,8 +80,12 @@ export function validateSecurity(projectDir, config) {
80
80
 
81
81
  for (const { pattern, label } of SECRET_PATTERNS) {
82
82
  pattern.lastIndex = 0;
83
- const match = pattern.exec(content);
84
- if (match) {
83
+ let match;
84
+ // Scan ALL matches for this pattern, not just the first. A real secret
85
+ // can sit BELOW a safe placeholder of the same kind (e.g. an
86
+ // `apiKey = "EXAMPLE..."` line above a hardcoded real key). Bailing on
87
+ // the first match — as this loop used to — silently missed the real one.
88
+ while ((match = pattern.exec(content)) !== null) {
85
89
  // Lazily initialize lines only when a match is found
86
90
  if (!lines) lines = content.split('\n');
87
91
 
@@ -97,10 +101,14 @@ export function validateSecurity(projectDir, config) {
97
101
  }
98
102
  }
99
103
 
100
- // Skip known-safe placeholder/example values
104
+ // Skip known-safe placeholder/example values, but keep scanning for a
105
+ // real one further down the file.
101
106
  if (isSafePlaceholder(matchLine, match[0])) continue;
102
107
 
103
108
  findings.push({ file: relPath, label, match: match[0].substring(0, 30) + '...' });
109
+ // One finding per (file, label) is enough — the reported message is
110
+ // identical for repeats and we've already proven a real secret exists.
111
+ break;
104
112
  }
105
113
  }
106
114
  });
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { resolve } from 'node:path';
7
+ import { docHasSection } from '../shared.mjs';
7
8
 
8
9
  export function validateStructure(projectDir, config) {
9
10
  const results = { name: 'structure', errors: [], warnings: [], passed: 0, total: 0 };
@@ -86,11 +87,11 @@ export function validateDocSections(projectDir, config) {
86
87
 
87
88
  for (const section of sections) {
88
89
  results.total++;
89
- // Match an actual heading at line start (any level), not a substring that
90
- // could appear in a table-of-contents link or a code block.
90
+ // Match a real heading (H2–H6), not a substring in a TOC link or code
91
+ // block. v0.24: synonym- and section-number-tolerant via docHasSection, so
92
+ // arc42/C4 docs ("## 5.4 Layer boundaries", "## Building Block View")
93
+ // count instead of being told to add a section they already have.
91
94
  const headingText = section.replace(/^#+\s*/, '');
92
- const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
- const headingRe = new RegExp('^#{2,6}\\s+' + escapedHeading + '\\b', 'm');
94
95
  // v0.16-P7: N/A marker. A project can declare a required section as
95
96
  // "not applicable" via an HTML comment instead of writing boilerplate
96
97
  // "Absent by design" prose. Format:
@@ -108,7 +109,7 @@ export function validateDocSections(projectDir, config) {
108
109
  '<!--\\s*docguard:section\\s+' + slug.replace(/-/g, '[-_]') + '\\s+n/a\\s*[—-]+\\s*[A-Za-z0-9]',
109
110
  'i'
110
111
  );
111
- if (headingRe.test(content)) {
112
+ if (docHasSection(content, section)) {
112
113
  results.passed++;
113
114
  } else if (naRe.test(content)) {
114
115
  // v0.16-P7: explicit N/A — counts as passed (the project has owned
@@ -206,11 +206,13 @@ function extractDocumentedTokens(content) {
206
206
  if (t) tokens.add(t);
207
207
  }
208
208
 
209
- // Pattern B: bolded token in a table row. Matches the validators-style
210
- // tables that use `| N | **Name** | description |` — backticks alone
211
- // miss every entry in those tables. Restricted to lines starting with
212
- // `|` so prose-level **bold** is not pulled in.
213
- const boldRowRe = /^\s*\|.*?\*\*([^*\n]+)\*\*/gim;
209
+ // Pattern B: bolded NAME token in a table row. Matches the validators-style
210
+ // tables that use `| N | **Name** | description |` — backticks alone miss
211
+ // every entry in those tables. The bold must be the FIRST cell, or the second
212
+ // cell after a numeric first cell — NOT any bold further right. The old
213
+ // `\|.*?\*\*` grabbed the first bold ANYWHERE in the row, so a bold status
214
+ // column like `| guard | **High** |` polluted the surface set with "High".
215
+ const boldRowRe = /^\s*\|\s*(?:\d+\s*\|\s*)?\*\*([^*\n]+)\*\*/gim;
214
216
  while ((m = boldRowRe.exec(stripped)) !== null) {
215
217
  const t = normalize(m[1]);
216
218
  if (t) tokens.add(t);