docguard-cli 0.22.1 → 0.24.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 +4 -4
  2. package/cli/commands/demo.mjs +1 -1
  3. package/cli/commands/diff.mjs +19 -8
  4. package/cli/commands/explain.mjs +178 -17
  5. package/cli/commands/fix.mjs +17 -2
  6. package/cli/commands/generate.mjs +2 -2
  7. package/cli/commands/guard.mjs +86 -11
  8. package/cli/commands/hooks.mjs +12 -7
  9. package/cli/commands/init.mjs +18 -6
  10. package/cli/commands/score.mjs +147 -61
  11. package/cli/commands/setup.mjs +2 -2
  12. package/cli/commands/trace.mjs +3 -101
  13. package/cli/commands/upgrade.mjs +61 -13
  14. package/cli/config.mjs +245 -0
  15. package/cli/docguard.mjs +21 -217
  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/py-ast.mjs +213 -0
  23. package/cli/scanners/routes.mjs +194 -69
  24. package/cli/scanners/schemas.mjs +97 -51
  25. package/cli/scanners/speckit.mjs +14 -0
  26. package/cli/shared-git.mjs +0 -0
  27. package/cli/shared-ignore.mjs +16 -1
  28. package/cli/shared-source.mjs +59 -2
  29. package/cli/shared-trace-patterns.mjs +118 -0
  30. package/cli/shared.mjs +60 -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 +27 -44
  35. package/cli/validators/docs-coverage.mjs +13 -0
  36. package/cli/validators/docs-diff.mjs +16 -6
  37. package/cli/validators/docs-sync.mjs +4 -3
  38. package/cli/validators/drift.mjs +3 -2
  39. package/cli/validators/freshness.mjs +47 -15
  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 +12 -54
  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
@@ -169,24 +169,34 @@ function diffTests(dir, config) {
169
169
  // match it against code test paths (or basenames when the entry has no slash).
170
170
  // Exact-string comparison produced the false "N documented but not found".
171
171
  const codeArr = [...codeTests];
172
- const docArr = [...docTests];
173
172
 
174
- const matches = (docEntry, codeRel) => {
173
+ // PERFORMANCE OPTIMIZATION: Pre-compile regular expressions to avoid O(N*M)
174
+ // instantiation bottlenecks inside the nested .filter and .some loops below.
175
+ const docMatchers = [...docTests].map(docEntry => {
175
176
  const entry = String(docEntry).trim();
176
177
  const hasSlash = entry.includes('/');
177
178
  const target = hasSlash ? entry : basename(entry);
178
- const subject = hasSlash ? codeRel : basename(codeRel);
179
179
  // Glob -> regex: escape regex specials, then any run of '*' becomes '.*'.
180
180
  const rx = new RegExp('^' + target
181
181
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
182
182
  .replace(/\*+/g, '.*') + '$');
183
- return rx.test(subject);
183
+
184
+ return {
185
+ original: docEntry,
186
+ hasSlash,
187
+ rx
188
+ };
189
+ });
190
+
191
+ const matches = (matcher, codeRel) => {
192
+ const subject = matcher.hasSlash ? codeRel : basename(codeRel);
193
+ return matcher.rx.test(subject);
184
194
  };
185
195
 
186
196
  return {
187
197
  title: 'Test Files',
188
- onlyInDocs: docArr.filter(d => !codeArr.some(c => matches(d, c))),
189
- onlyInCode: codeArr.filter(c => !docArr.some(d => matches(d, c))),
198
+ onlyInDocs: docMatchers.filter(m => !codeArr.some(c => matches(m, c))).map(m => m.original),
199
+ onlyInCode: codeArr.filter(c => !docMatchers.some(m => matches(m, c))),
190
200
  };
191
201
  }
192
202
 
@@ -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) {
@@ -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);
@@ -18,76 +18,97 @@ export function validateTestSpec(projectDir, config) {
18
18
  const content = readFileSync(testSpecPath, 'utf-8');
19
19
  const ptc = config.projectTypeConfig || {};
20
20
 
21
- // Parse the Source-to-Test Map table (new header) or Service-to-Test Map (old header)
22
- const serviceMapMatch = content.match(
23
- /## (?:Service-to-Test Map|Source-to-Test Map)[\s\S]*?\n\|.*\|.*\|.*\|([\s\S]*?)(?=\n##|\n$|$)/
24
- );
25
-
26
- if (serviceMapMatch) {
27
- const tableContent = serviceMapMatch[1];
28
- const rows = tableContent
21
+ // Parse the Source-to-Test Map (new header) / Service-to-Test Map (old).
22
+ //
23
+ // Column-HEADER-aware: read the header row to locate the source column, the
24
+ // status column, and EVERY test-file column (Unit Test, Integration Test, …),
25
+ // then map each data row by index WITHOUT discarding empty cells. The old
26
+ // parser filtered empty cells — which shifted every column rightward whenever
27
+ // a cell was blank (e.g. an empty Integration Test) — and only ever checked
28
+ // the 2nd column, so the generated 4-column table's Integration Test was
29
+ // never verified (#9). Splitting on the outer pipes and trimming preserves
30
+ // column alignment so a blank cell stays an empty string in its own slot.
31
+ const mapSection = content.match(/## (?:Service-to-Test Map|Source-to-Test Map)[\s\S]*?(?=\n## |$)/);
32
+ if (mapSection) {
33
+ const splitRow = (line) => {
34
+ const parts = line.split('|');
35
+ parts.shift(); // text before the first pipe
36
+ parts.pop(); // text after the last pipe
37
+ return parts.map(s => s.trim());
38
+ };
39
+ const pipeRows = mapSection[0]
29
40
  .split('\n')
30
- .filter(line => line.startsWith('|') && !line.includes('---'));
41
+ .filter(l => l.trim().startsWith('|') && !/^\s*\|[\s|:-]+\|\s*$/.test(l)); // drop the `---` separator
42
+ const headerCells = pipeRows.length ? splitRow(pipeRows[0]) : [];
43
+ const header = headerCells.map(h => h.toLowerCase());
44
+
45
+ // Classify columns by header name, with positional fallbacks.
46
+ let sourceIdx = header.findIndex(h => /\bsource\b/.test(h));
47
+ if (sourceIdx < 0) sourceIdx = 0;
48
+ let statusIdx = header.findIndex(h => /\bstatus\b/.test(h));
49
+ if (statusIdx < 0) statusIdx = header.length - 1;
50
+ let testIdxs = header
51
+ .map((h, i) => (/\btest\b|\be2e\b/.test(h) ? i : -1))
52
+ .filter(i => i >= 0 && i !== sourceIdx && i !== statusIdx);
53
+ if (testIdxs.length === 0) {
54
+ const fallback = sourceIdx === 1 ? 0 : 1; // the non-source early column
55
+ if (fallback !== statusIdx && fallback < header.length) testIdxs = [fallback];
56
+ }
31
57
 
32
- for (const row of rows) {
33
- const cells = row
34
- .split('|')
35
- .map(s => s.trim())
36
- .filter(s => s.length > 0);
58
+ const isPlaceholder = (v) =>
59
+ !v || v === '—' || v.includes('N/A') ||
60
+ ['source file', 'test file', 'unit test', 'integration test', 'e2e test'].includes(v.toLowerCase());
37
61
 
38
- if (cells.length < 3) continue;
62
+ // Only existence-check a cell that actually LOOKS like a file path: no
63
+ // internal spaces, and either a directory separator or a file extension.
64
+ // A `## Service-to-Test Map` section often holds several sub-tables of
65
+ // different shapes (Controllers, Services, an "Integration Tests" inventory
66
+ // like `| test-file | what it covers |`). Without this guard a prose
67
+ // "what it covers" cell — "Health endpoint with real dependencies" — gets
68
+ // checked as a missing test file (false positive; field test: wu-whatsappinbox).
69
+ const isPathLike = (v) => !!v && !/\s/.test(v) && (/[\\/]/.test(v) || /\.[A-Za-z0-9]{1,6}$/.test(v));
39
70
 
40
- const sourceFile = cells[0];
41
- const testFile = cells[1];
42
- const status = cells[cells.length - 1]; // Last column is always status
71
+ for (const row of pipeRows.slice(1)) { // skip the header row
72
+ const cells = splitRow(row);
73
+ const sourceFile = cells[sourceIdx] || '';
74
+ const status = cells[statusIdx] || '';
43
75
 
44
- // Skip template/example rows and italic placeholder rows
45
- if (sourceFile.startsWith('<!--') || sourceFile === 'Source File' || sourceFile.startsWith('*')) continue;
76
+ // Skip template/example rows and italic placeholder rows.
77
+ if (!sourceFile || sourceFile.startsWith('<!--') || sourceFile === 'Source File' || sourceFile.startsWith('*')) continue;
46
78
 
47
79
  // Author-declared gaps (❌/⚠️) are surfaced as warnings. A ✅ glyph is the
48
80
  // author's CLAIM, not proof — it is NOT counted as a pass. The real pass
49
81
  // comes from the file-existence checks below (code truth, not the glyph).
50
- if (status && status.includes('❌')) {
82
+ if (status.includes('❌')) {
51
83
  results.total++;
52
- results.warnings.push(
53
- `TEST-SPEC declares ${sourceFile} as missing tests`
54
- );
55
- } else if (status && status.includes('⚠️')) {
84
+ results.warnings.push(`TEST-SPEC declares ${sourceFile} as ❌ — missing tests`);
85
+ } else if (status.includes('⚠️')) {
56
86
  results.total++;
57
- results.warnings.push(
58
- `TEST-SPEC declares ${sourceFile} as ⚠️ — partial coverage`
59
- );
87
+ results.warnings.push(`TEST-SPEC declares ${sourceFile} as ⚠️ — partial coverage`);
60
88
  }
61
89
 
62
90
  // ── File existence checks ───────────────────────────────────────
63
- // Verify source file still exists (catch stale map entries)
91
+ // Verify source file still exists (catch stale map entries).
64
92
  const cleanSource = sourceFile.replace(/`/g, '').trim();
65
- if (cleanSource && cleanSource !== '—' && cleanSource !== 'Source File') {
66
- const sourcePath = resolve(projectDir, cleanSource);
67
- if (!existsSync(sourcePath)) {
68
- results.total++;
69
- results.warnings.push(
70
- `Source-to-Test Map: source file \`${cleanSource}\` not found on disk — stale entry?`
71
- );
72
- } else {
73
- results.total++;
93
+ if (cleanSource && cleanSource !== '—' && cleanSource !== 'Source File' && isPathLike(cleanSource)) {
94
+ results.total++;
95
+ if (existsSync(resolve(projectDir, cleanSource))) {
74
96
  results.passed++;
97
+ } else {
98
+ results.warnings.push(`Source-to-Test Map: source file \`${cleanSource}\` not found on disk — stale entry?`);
75
99
  }
76
100
  }
77
101
 
78
- // Verify test file exists (catch wrong/stale test references)
79
- const cleanTest = testFile ? testFile.replace(/`/g, '').trim() : '';
80
- if (cleanTest && cleanTest !== '—' && cleanTest !== 'Test File' &&
81
- cleanTest !== 'Unit Test' && !cleanTest.includes('N/A')) {
82
- const testPath = resolve(projectDir, cleanTest);
83
- if (!existsSync(testPath)) {
84
- results.total++;
85
- results.warnings.push(
86
- `Source-to-Test Map: test file \`${cleanTest}\` not found — referenced by ${cleanSource}`
87
- );
88
- } else {
89
- results.total++;
102
+ // Verify EVERY declared test file exists Unit Test AND Integration Test
103
+ // (the old parser only checked one column).
104
+ for (const ti of testIdxs) {
105
+ const cleanTest = (cells[ti] || '').replace(/`/g, '').trim();
106
+ if (isPlaceholder(cleanTest) || !isPathLike(cleanTest)) continue;
107
+ results.total++;
108
+ if (existsSync(resolve(projectDir, cleanTest))) {
90
109
  results.passed++;
110
+ } else {
111
+ results.warnings.push(`Source-to-Test Map: test file \`${cleanTest}\` not found — referenced by ${cleanSource}`);
91
112
  }
92
113
  }
93
114
  }
@@ -175,7 +196,11 @@ export function validateTestSpec(projectDir, config) {
175
196
 
176
197
  if (hasTestDir || hasColocated || hasConfigTests) {
177
198
  // Tests exist but the spec maps none of them → not applicable, not a pass.
178
- results.note = 'TEST-SPEC.md declares no service-to-test mappings. Add a "## Source-to-Test Map" table with `| Source | Test file | Status |` columns — run `docguard explain "no service-to-test mappings"` for the exact format.';
199
+ // v0.24: the validator reads column 1 as source, column 2 as the test
200
+ // file, and the last as status — so both the minimal 3-column shape and
201
+ // the 4-column table `docguard generate` emits are accepted. Say so, since
202
+ // the guidance previously contradicted the generated skeleton (field report).
203
+ results.note = 'TEST-SPEC.md declares no service-to-test mappings. Add a "## Source-to-Test Map" table — column 1 is the source, column 2 the test file, the last column the status. Both `| Source | Test file | Status |` and the generated `| Source File | Unit Test | Integration Test | Status |` shapes work. Run `docguard explain testSpec` for details.';
179
204
  } else {
180
205
  results.warnings.push(
181
206
  'No test directory or co-located test files found. ' +
@@ -35,8 +35,10 @@ const TEST_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
35
35
 
36
36
  // ──── Patterns ────
37
37
 
38
- const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX|TEMP(?!late|orar)|WORKAROUND)\s*[(:]/;
39
- const TODO_EXTRACT = /\b(TODO|FIXME|HACK|XXX|TEMP(?!late|orar)|WORKAROUND)\s*[:(]?\s*(.+)/;
38
+ // TEMP must be the standalone word — `(?![A-Za-z])` excludes TEMPLATE, TEMPORARY,
39
+ // TEMPO, TEMPEST, etc. (the old `(?!late|orar)` only caught the first two).
40
+ const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX|TEMP(?![A-Za-z])|WORKAROUND)\s*[(:]/;
41
+ const TODO_EXTRACT = /\b(TODO|FIXME|HACK|XXX|TEMP(?![A-Za-z])|WORKAROUND)\s*[:(]?\s*(.+)/;
40
42
 
41
43
  // Matches a comment-opening marker. Real TODOs live in comments — restricting
42
44
  // matches to text AFTER a comment marker prevents false positives from regex