docguard-cli 0.10.0 → 0.11.1

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 (44) hide show
  1. package/PHILOSOPHY.md +59 -106
  2. package/README.md +23 -1
  3. package/cli/commands/diagnose.mjs +157 -52
  4. package/cli/commands/fix.mjs +113 -1
  5. package/cli/commands/generate.mjs +91 -0
  6. package/cli/commands/hooks.mjs +40 -2
  7. package/cli/commands/score.mjs +22 -0
  8. package/cli/commands/sync.mjs +123 -0
  9. package/cli/docguard.mjs +22 -0
  10. package/cli/scanners/cdk.mjs +10 -0
  11. package/cli/scanners/frontend.mjs +438 -0
  12. package/cli/scanners/iac.mjs +235 -0
  13. package/cli/scanners/integrations.mjs +116 -0
  14. package/cli/scanners/memory-plan.mjs +242 -0
  15. package/cli/scanners/project-type.mjs +310 -0
  16. package/cli/scanners/routes.mjs +149 -0
  17. package/cli/scanners/schemas.mjs +174 -1
  18. package/cli/shared-ignore.mjs +29 -2
  19. package/cli/shared-source.mjs +2 -1
  20. package/cli/validators/api-surface.mjs +112 -37
  21. package/cli/validators/changelog.mjs +3 -2
  22. package/cli/validators/docs-coverage.mjs +125 -6
  23. package/cli/validators/docs-sync.mjs +49 -8
  24. package/cli/validators/metadata-sync.mjs +6 -1
  25. package/cli/validators/metrics-consistency.mjs +5 -2
  26. package/cli/validators/test-spec.mjs +129 -11
  27. package/cli/validators/todo-tracking.mjs +55 -2
  28. package/cli/writers/api-reference.mjs +101 -0
  29. package/cli/writers/mechanical.mjs +116 -0
  30. package/cli/writers/sections.mjs +148 -0
  31. package/commands/docguard.fix.md +19 -3
  32. package/docs/doc-sections.md +37 -0
  33. package/extensions/spec-kit-docguard/README.md +7 -4
  34. package/extensions/spec-kit-docguard/commands/fix.md +74 -0
  35. package/extensions/spec-kit-docguard/commands/generate.md +25 -2
  36. package/extensions/spec-kit-docguard/commands/sync.md +62 -0
  37. package/extensions/spec-kit-docguard/extension.yml +1 -1
  38. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +13 -3
  39. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  40. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  41. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  42. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
  43. package/package.json +1 -1
  44. package/templates/ARCHITECTURE.md.template +52 -0
@@ -16,10 +16,14 @@
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { resolve, join, relative, basename, extname } from 'node:path';
18
18
  import { resolveSourceRoots } from '../shared-source.mjs';
19
+ import { shouldIgnore } from '../shared-ignore.mjs';
20
+ import { detectIaC, hasInfrastructureHeading, buildIaCWarning } from '../scanners/iac.mjs';
19
21
 
20
22
  const IGNORE_DIRS = new Set([
21
- 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
22
- '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
23
+ 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
24
+ 'coverage', '.cache', '__pycache__', '.venv', 'vendor',
25
+ '.turbo', '.vercel', '.svelte-kit', 'cdk.out', '.claude',
26
+ 'target', '.gradle',
23
27
  ]);
24
28
 
25
29
  // Dotfiles that are universally common and don't need documentation
@@ -50,8 +54,12 @@ export function validateDocsCoverage(projectDir, config) {
50
54
  return { errors: [], warnings, passed: 0, total: 0 };
51
55
  }
52
56
 
57
+ // IaC detection runs once and informs both Check 3 (suppression) and
58
+ // Check 6 (consolidated warning). One scan, two consumers.
59
+ const iac = detectIaC(projectDir);
60
+
53
61
  // ── Check 1: Project-specific config/dotfiles referenced in docs ──
54
- const configChecks = checkConfigFiles(projectDir, allDocContent);
62
+ const configChecks = checkConfigFiles(projectDir, allDocContent, config);
55
63
  total += configChecks.total;
56
64
  passed += configChecks.passed;
57
65
  warnings.push(...configChecks.warnings);
@@ -63,7 +71,7 @@ export function validateDocsCoverage(projectDir, config) {
63
71
  warnings.push(...binChecks.warnings);
64
72
 
65
73
  // ── Check 3: Source directory structure matches ARCHITECTURE.md ──
66
- const dirChecks = checkSourceDirs(projectDir, allDocContent, config);
74
+ const dirChecks = checkSourceDirs(projectDir, allDocContent, config, iac);
67
75
  total += dirChecks.total;
68
76
  passed += dirChecks.passed;
69
77
  warnings.push(...dirChecks.warnings);
@@ -80,6 +88,12 @@ export function validateDocsCoverage(projectDir, config) {
80
88
  passed += readmeChecks.passed;
81
89
  warnings.push(...readmeChecks.warnings);
82
90
 
91
+ // ── Check 6: IaC-aware Infrastructure documentation ──
92
+ const iacChecks = checkIaCDocumentation(projectDir, iac);
93
+ total += iacChecks.total;
94
+ passed += iacChecks.passed;
95
+ warnings.push(...iacChecks.warnings);
96
+
83
97
  return { errors: [], warnings, passed, total };
84
98
  }
85
99
 
@@ -88,8 +102,10 @@ export function validateDocsCoverage(projectDir, config) {
88
102
  /**
89
103
  * Check 1: Project-specific config/dotfiles are mentioned in docs.
90
104
  * Skips universally common files (.gitignore, .eslintrc, etc.).
105
+ * Honors config.ignore (FR-015 — applies user-configured ignore patterns
106
+ * consistently across all docs-coverage checks).
91
107
  */
92
- function checkConfigFiles(projectDir, allDocContent) {
108
+ function checkConfigFiles(projectDir, allDocContent, config = {}) {
93
109
  const warnings = [];
94
110
  let passed = 0;
95
111
  let total = 0;
@@ -111,6 +127,17 @@ function checkConfigFiles(projectDir, allDocContent) {
111
127
  if (COMMON_DOTFILES.has(entry)) continue;
112
128
  if (entry === 'tsconfig.json' || entry === 'package-lock.json') continue;
113
129
 
130
+ // Skip directories — this check is for configuration FILES, not dirs.
131
+ // Build-cache dotdirs (.nuxt, .next, .turbo, etc.) are handled by IGNORE_DIRS.
132
+ try {
133
+ if (statSync(join(projectDir, entry)).isDirectory()) continue;
134
+ } catch { continue; }
135
+
136
+ // Honor user-configured ignore patterns (FR-015 / IR-5).
137
+ // Same dual-form check as checkSourceDirs: relative path and trailing-slash
138
+ // form so dotfile-style patterns and dir-style patterns both apply.
139
+ if (shouldIgnore(entry, config) || shouldIgnore(entry + '/', config)) continue;
140
+
114
141
  total++;
115
142
  if (lowerDocContent.includes(entry.toLowerCase())) {
116
143
  passed++;
@@ -160,8 +187,13 @@ function checkPackageBins(projectDir, allDocContent) {
160
187
 
161
188
  /**
162
189
  * Check 3: Source directories are referenced in ARCHITECTURE.md.
190
+ *
191
+ * Honors config.ignore (FR-006). When IaC is detected and the Infrastructure
192
+ * heading is missing, per-directory warnings inside the IaC package roots
193
+ * are suppressed — Check 6 emits one consolidated warning per IaC tool
194
+ * instead (FR-011).
163
195
  */
164
- function checkSourceDirs(projectDir, allDocContent, config = {}) {
196
+ function checkSourceDirs(projectDir, allDocContent, config = {}, iac = { isIaC: false, tools: [] }) {
165
197
  const warnings = [];
166
198
  let passed = 0;
167
199
  let total = 0;
@@ -173,6 +205,15 @@ function checkSourceDirs(projectDir, allDocContent, config = {}) {
173
205
  try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { warnings, passed, total }; }
174
206
 
175
207
  const lowerArchContent = archContent.toLowerCase();
208
+ const infraDocumented = hasInfrastructureHeading(archContent);
209
+
210
+ // Only suppress per-dir warnings when IaC exists AND no Infrastructure
211
+ // heading is present — Check 6 will fire the consolidated message instead.
212
+ const suppressIaCDirs = iac.isIaC && !infraDocumented;
213
+
214
+ // Flatten every IaC tool's package dirs into a single Set for fast lookup.
215
+ const iacPackageDirs = [];
216
+ for (const tool of iac.tools) iacPackageDirs.push(...tool.packageDirs);
176
217
 
177
218
  // Monorepo-aware: honor config.sourceRoot + workspaces instead of a hardcoded list.
178
219
  for (const rootDir of resolveSourceRoots(projectDir, config)) {
@@ -189,6 +230,22 @@ function checkSourceDirs(projectDir, allDocContent, config = {}) {
189
230
 
190
231
  if (IGNORE_DIRS.has(entry) || entry.startsWith('.') || entry === '__tests__' || entry === '__test__') continue;
191
232
 
233
+ const relPath = relative(projectDir, fullPath);
234
+
235
+ // Honor user-configured ignore patterns (FR-006 / IR-5).
236
+ // Patterns like `**/cdk.out/**` are written to match files INSIDE the
237
+ // directory; appending '/' lets us match the directory itself too.
238
+ if (shouldIgnore(relPath, config) || shouldIgnore(relPath + '/', config)) continue;
239
+
240
+ // Suppress per-dir warnings for IaC-relevant subdirs inside an IaC
241
+ // package — the consolidated Check 6 warning covers them. Includes CDK
242
+ // (bin/, lib/, stacks/, constructs/), Terraform (modules/, environments/),
243
+ // Pulumi (stacks/), SAM (events/, src/), Serverless (handlers/, src/).
244
+ if (suppressIaCDirs && isInsideIaCPackage(relPath, iacPackageDirs)
245
+ && IAC_SUBDIR_NAMES.has(entry)) {
246
+ continue;
247
+ }
248
+
192
249
  total++;
193
250
  const searchName = entry.toLowerCase();
194
251
  if (lowerArchContent.includes(searchName) || lowerArchContent.includes(root + '/' + entry)) {
@@ -204,6 +261,68 @@ function checkSourceDirs(projectDir, allDocContent, config = {}) {
204
261
  return { warnings, passed, total };
205
262
  }
206
263
 
264
+ /**
265
+ * Subdirectory names recognized as IaC-relevant across all supported tools.
266
+ * When IaC is detected and the Infrastructure heading is missing, these dirs
267
+ * inside the IaC package are suppressed from Check 3 to avoid double-warning.
268
+ */
269
+ const IAC_SUBDIR_NAMES = new Set([
270
+ // CDK
271
+ 'bin', 'lib', 'stacks', 'constructs',
272
+ // Terraform
273
+ 'modules', 'environments',
274
+ // SAM / Serverless / Pulumi
275
+ 'handlers', 'events', 'src',
276
+ ]);
277
+
278
+ /**
279
+ * True if `relPath` is inside any of the IaC package directories.
280
+ * Both inputs are project-relative POSIX paths.
281
+ */
282
+ function isInsideIaCPackage(relPath, packageDirs) {
283
+ if (!packageDirs || packageDirs.length === 0) return false;
284
+ const normalized = relPath.split('\\').join('/');
285
+ return packageDirs.some(pkgDir => {
286
+ const p = pkgDir === '.' ? '' : pkgDir.split('\\').join('/');
287
+ if (p === '') return true;
288
+ return normalized === p || normalized.startsWith(p + '/');
289
+ });
290
+ }
291
+
292
+ /**
293
+ * Check 6: IaC projects should document their Infrastructure layer.
294
+ *
295
+ * Emits ONE consolidated warning per detected IaC tool when ARCHITECTURE.md
296
+ * has no Infrastructure heading. Suppresses the generic per-directory
297
+ * warnings that would otherwise fire for bin/, lib/, modules/, handlers/, etc.
298
+ */
299
+ function checkIaCDocumentation(projectDir, iac) {
300
+ const warnings = [];
301
+ if (!iac || !iac.isIaC) return { warnings, passed: 0, total: 0 };
302
+
303
+ const archPath = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
304
+ if (!existsSync(archPath)) {
305
+ // No ARCHITECTURE.md at all — structure validator will catch that.
306
+ // Don't double-warn here.
307
+ return { warnings, passed: 0, total: 0 };
308
+ }
309
+
310
+ let archContent;
311
+ try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { warnings, passed: 0, total: 0 }; }
312
+
313
+ if (hasInfrastructureHeading(archContent)) {
314
+ // One pass per tool — counted as total per IaC tool present.
315
+ return { warnings, passed: iac.tools.length, total: iac.tools.length };
316
+ }
317
+
318
+ // One actionable warning per detected IaC tool. Most projects use one tool,
319
+ // but a multi-tool monorepo gets one targeted message each.
320
+ for (const tool of iac.tools) {
321
+ warnings.push(buildIaCWarning(tool));
322
+ }
323
+ return { warnings, passed: 0, total: iac.tools.length };
324
+ }
325
+
207
326
  /**
208
327
  * Check 4: Config files that code actually READS are documented.
209
328
  *
@@ -7,10 +7,38 @@ import { resolve, join, extname, basename } from 'node:path';
7
7
  import { resolveSourceRoots } from '../shared-source.mjs';
8
8
 
9
9
  const IGNORE_DIRS = new Set([
10
- 'node_modules', '.git', '.next', 'dist', 'build',
10
+ 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
11
11
  'coverage', '.cache', '__pycache__', '.venv', 'vendor',
12
+ // Co-located test dirs — these are not the source under documentation.
13
+ '__tests__', '__test__',
12
14
  ]);
13
15
 
16
+ // Files that are tests, not source. Matched against the relative path AND
17
+ // the basename. Covers Jest/Vitest/Mocha/Jasmine/pytest/Go/Java conventions.
18
+ const TEST_PATH_RE = /(^|\/)__tests?__\//;
19
+ const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|java|go)$/;
20
+
21
+ // Next.js App Router uses a strict filename convention for route handlers.
22
+ // Other files in the app/api/ tree (helpers, types) are NOT routes.
23
+ const NEXTJS_ROUTE_FILE_RE = /(^|\/)route\.(ts|tsx|js|jsx|mjs)$/;
24
+ const NEXTJS_API_DIR_RE = /(^|\/)app\/api(\/|$)/;
25
+
26
+ function isTestFile(relPath) {
27
+ return TEST_PATH_RE.test(relPath) || TEST_FILE_RE.test(relPath);
28
+ }
29
+
30
+ /**
31
+ * For Next.js App Router directories (app/api/...), only `route.{ts,js}` files
32
+ * are actual route handlers. Helpers and types in the same tree should not be
33
+ * treated as routes.
34
+ */
35
+ function isValidRouteFile(relPath) {
36
+ if (NEXTJS_API_DIR_RE.test(relPath)) {
37
+ return NEXTJS_ROUTE_FILE_RE.test(relPath);
38
+ }
39
+ return true;
40
+ }
41
+
14
42
  /**
15
43
  * Expand sub-path patterns (e.g. 'routes', 'src/routes') against the project
16
44
  * root AND every configured source root, returning de-duplicated existing dirs.
@@ -53,15 +81,22 @@ export function validateDocsSync(projectDir, config) {
53
81
  }
54
82
 
55
83
  // Find route/API files (monorepo-aware) and check they're mentioned in docs.
56
- const routeDirs = expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'api']);
84
+ // Note: bare 'api' is intentionally excluded — it collides with frontend
85
+ // API client conventions (src/api/client.ts). Backend routes use
86
+ // src/routes/ or routes/ (Express). Next.js App Router uses src/app/api/
87
+ // or app/api/ with strict route.{ts,js} filename matching applied below.
88
+ const routeDirs = expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'app/api']);
57
89
  for (const routeDir of routeDirs) {
58
90
  const files = getFilesRecursive(routeDir);
59
91
  for (const file of files) {
60
92
  const ext = extname(file);
61
- if (!['.ts', '.js', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
93
+ if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
62
94
 
63
- results.total++;
64
95
  const relPath = file.replace(projectDir + '/', '');
96
+ if (isTestFile(relPath)) continue;
97
+ if (!isValidRouteFile(relPath)) continue;
98
+
99
+ results.total++;
65
100
  const name = basename(file, ext);
66
101
 
67
102
  // Check if the file path or name is mentioned in any canonical doc
@@ -79,10 +114,12 @@ export function validateDocsSync(projectDir, config) {
79
114
  const files = getFilesRecursive(serviceDir);
80
115
  for (const file of files) {
81
116
  const ext = extname(file);
82
- if (!['.ts', '.js', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
117
+ if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
83
118
 
84
- results.total++;
85
119
  const relPath = file.replace(projectDir + '/', '');
120
+ if (isTestFile(relPath)) continue;
121
+
122
+ results.total++;
86
123
  const name = basename(file, ext);
87
124
 
88
125
  if (canonicalContent.includes(relPath) || canonicalContent.includes(name)) {
@@ -117,11 +154,15 @@ export function validateDocsSync(projectDir, config) {
117
154
 
118
155
  if (openapiContent && openapiFile) {
119
156
  // Check that route files have corresponding paths in OpenAPI spec (monorepo-aware)
120
- for (const routeDir of expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'api'])) {
157
+ for (const routeDir of expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'app/api'])) {
121
158
  const files = getFilesRecursive(routeDir);
122
159
  for (const file of files) {
123
160
  const ext = extname(file);
124
- if (!['.ts', '.js', '.mjs'].includes(ext)) continue;
161
+ if (!['.ts', '.tsx', '.js', '.jsx', '.mjs'].includes(ext)) continue;
162
+
163
+ const relPathForFilter = file.replace(projectDir + '/', '');
164
+ if (isTestFile(relPathForFilter)) continue;
165
+ if (!isValidRouteFile(relPathForFilter)) continue;
125
166
 
126
167
  // Skip index/middleware files
127
168
  const rawName = basename(file, ext).toLowerCase();
@@ -23,6 +23,7 @@ const IGNORE_DIRS = new Set([
23
23
  */
24
24
  export function validateMetadataSync(projectDir, config) {
25
25
  const warnings = [];
26
+ const fixes = [];
26
27
  let passed = 0;
27
28
  let total = 0;
28
29
 
@@ -60,6 +61,7 @@ export function validateMetadataSync(projectDir, config) {
60
61
  warnings.push(
61
62
  `${relPath} has version "${versionMatch[1]}" but package.json is "${currentVersion}"`
62
63
  );
64
+ fixes.push({ type: 'replace-version', file: relPath, found: versionMatch[1], actual: currentVersion });
63
65
  } else {
64
66
  passed++;
65
67
  }
@@ -120,6 +122,9 @@ export function validateMetadataSync(projectDir, config) {
120
122
  warnings.push(
121
123
  `${relPath} references "v${foundVersion}" in an actionable context (URL/install/declaration) but current version is "${currentVersion}"`
122
124
  );
125
+ if (!fixes.some(f => f.file === relPath && f.found === foundVersion)) {
126
+ fixes.push({ type: 'replace-version', file: relPath, found: foundVersion, actual: currentVersion });
127
+ }
123
128
  } else if (fMajor === major && fMinor === minor && foundVersion === currentVersion) {
124
129
  total++;
125
130
  passed++;
@@ -128,7 +133,7 @@ export function validateMetadataSync(projectDir, config) {
128
133
  }
129
134
  }
130
135
 
131
- return { errors: [], warnings, passed, total };
136
+ return { errors: [], warnings, passed, total, fixes };
132
137
  }
133
138
 
134
139
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -24,6 +24,7 @@ const IGNORE_DIRS = new Set([
24
24
  */
25
25
  export function validateMetricsConsistency(projectDir, config, guardResults) {
26
26
  const warnings = [];
27
+ const fixes = [];
27
28
  let passed = 0;
28
29
  let total = 0;
29
30
 
@@ -83,8 +84,10 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
83
84
  const found = parseInt(match[1], 10);
84
85
  if (found !== actuals[key] && found > 0) {
85
86
  warnings.push(
86
- `${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Update the doc or run \`docguard generate --force\``
87
+ `${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Fix with \`docguard fix --write\``
87
88
  );
89
+ // Deterministic, surgical token replacement — safe to auto-apply.
90
+ fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key] });
88
91
  } else {
89
92
  passed++;
90
93
  }
@@ -92,7 +95,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
92
95
  }
93
96
  }
94
97
 
95
- return { errors: [], warnings, passed, total };
98
+ return { errors: [], warnings, passed, total, fixes };
96
99
  }
97
100
 
98
101
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -126,17 +126,22 @@ export function validateTestSpec(projectDir, config) {
126
126
  continue;
127
127
  }
128
128
 
129
- // For a ✅ journey, verify the referenced test file actually exists
130
- // rather than trusting the glyph.
131
- const cleanTest = testFile ? testFile.replace(/`/g, '').trim() : '';
132
- if (cleanTest && cleanTest !== '—' && !cleanTest.includes('N/A')) {
133
- results.total++;
134
- if (existsSync(resolve(projectDir, cleanTest))) {
135
- results.passed++;
136
- } else {
137
- results.warnings.push(
138
- `E2E Journey #${num} (${journey}) marked ✅ but test file not found: ${cleanTest}`
139
- );
129
+ // For a ✅ journey, verify the referenced test file(s) actually exist
130
+ // rather than trusting the glyph. Cells may list multiple paths in
131
+ // backticks separated by commas (e.g. `a.test.ts`, `b.test.ts`) and
132
+ // may include "(N suites)" annotations or globs.
133
+ if (testFile && testFile.trim() !== '—' && !testFile.includes('N/A')) {
134
+ const paths = parseTestPathCell(testFile);
135
+ if (paths.length > 0) {
136
+ results.total++;
137
+ const anyExists = paths.some(p => testEvidenceExists(projectDir, p));
138
+ if (anyExists) {
139
+ results.passed++;
140
+ } else {
141
+ results.warnings.push(
142
+ `E2E Journey #${num} (${journey}) marked ✅ but test file not found: ${paths.join(', ')}`
143
+ );
144
+ }
140
145
  }
141
146
  }
142
147
  }
@@ -182,6 +187,119 @@ export function validateTestSpec(projectDir, config) {
182
187
  return results;
183
188
  }
184
189
 
190
+ /**
191
+ * Parse a TEST-SPEC.md table cell into a list of test path strings.
192
+ *
193
+ * Real-world Journey rows commonly list multiple test files in one cell:
194
+ * `path/a.test.ts`, `path/b.test.ts`
195
+ * `idor_*.test.ts (3 suites)`
196
+ *
197
+ * Strategy:
198
+ * 1. Split on commas that are OUTSIDE backticks.
199
+ * 2. For each segment: strip backticks, strip trailing "(N suites)" or
200
+ * "(N tests)" annotations, trim whitespace.
201
+ * 3. Drop empties.
202
+ *
203
+ * The "(N suites)" annotation is preserved as evidence — if a glob like
204
+ * `idor_*.test.ts` doesn't expand to a literal file, testEvidenceExists()
205
+ * accepts the annotation as the author's claim of coverage.
206
+ */
207
+ export function parseTestPathCell(cell) {
208
+ if (!cell) return [];
209
+ // Split on commas that are NOT inside backticks. Track backtick parity.
210
+ const segments = [];
211
+ let buf = '';
212
+ let inBackticks = false;
213
+ for (const ch of cell) {
214
+ if (ch === '`') { inBackticks = !inBackticks; buf += ch; continue; }
215
+ if (ch === ',' && !inBackticks) {
216
+ segments.push(buf);
217
+ buf = '';
218
+ continue;
219
+ }
220
+ buf += ch;
221
+ }
222
+ if (buf) segments.push(buf);
223
+
224
+ const result = [];
225
+ for (let seg of segments) {
226
+ seg = seg.replace(/`/g, '').trim();
227
+ if (!seg || seg === '—') continue;
228
+ result.push(seg);
229
+ }
230
+ return result;
231
+ }
232
+
233
+ /**
234
+ * True if a TEST-SPEC.md path segment has supporting evidence on disk.
235
+ *
236
+ * Accepts: exact file match, glob expansion (e.g. `foo_*.test.ts`), or an
237
+ * "(N suites)" / "(N tests)" annotation when the literal path doesn't exist.
238
+ * The annotation is the author's explicit claim of coverage — believe it
239
+ * rather than reject the row outright; the audit trail is in the markdown.
240
+ */
241
+ export function testEvidenceExists(projectDir, pathSegment) {
242
+ if (!pathSegment) return false;
243
+
244
+ // Strip a trailing "(N suites)" / "(N tests)" annotation for the file check.
245
+ const annotationMatch = pathSegment.match(/\s*\((\d+)\s+(?:suites?|tests?)\)\s*$/i);
246
+ const pathOnly = annotationMatch ? pathSegment.slice(0, annotationMatch.index).trim() : pathSegment;
247
+ const hasAnnotation = !!annotationMatch;
248
+
249
+ if (!pathOnly) return hasAnnotation;
250
+
251
+ // Glob support — if the segment contains *, ?, or [, walk the parent dir.
252
+ if (/[*?[]/.test(pathOnly)) {
253
+ const matches = expandGlob(projectDir, pathOnly);
254
+ if (matches.length > 0) return true;
255
+ // Glob with annotation but no expansion → trust the annotation.
256
+ return hasAnnotation;
257
+ }
258
+
259
+ // Plain path — must exist on disk.
260
+ if (existsSync(resolve(projectDir, pathOnly))) return true;
261
+ // Plain path with explicit annotation → still trust the author's claim.
262
+ return hasAnnotation;
263
+ }
264
+
265
+ /**
266
+ * Minimal glob expansion: only handles the `*` and `?` wildcards in a single
267
+ * path segment. e.g. `backend/src/test-helpers/security/idor_*.test.ts`.
268
+ * Pure Node.js built-ins; zero dependencies.
269
+ */
270
+ function expandGlob(projectDir, pattern) {
271
+ const parts = pattern.split('/');
272
+ const start = resolve(projectDir);
273
+ let candidates = [start];
274
+ for (const part of parts) {
275
+ if (!/[*?[]/.test(part)) {
276
+ candidates = candidates.map(c => resolve(c, part)).filter(c => existsSync(c));
277
+ continue;
278
+ }
279
+ const re = globPartToRegex(part);
280
+ const next = [];
281
+ for (const dir of candidates) {
282
+ let entries;
283
+ try { entries = readdirSync(dir); } catch { continue; }
284
+ for (const e of entries) {
285
+ if (re.test(e)) next.push(resolve(dir, e));
286
+ }
287
+ }
288
+ candidates = next;
289
+ if (candidates.length === 0) return [];
290
+ }
291
+ return candidates;
292
+ }
293
+
294
+ function globPartToRegex(part) {
295
+ const escaped = part
296
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
297
+ .replace(/\\\[/g, '[').replace(/\\\]/g, ']') // restore character classes
298
+ .replace(/\*/g, '.*')
299
+ .replace(/\?/g, '.');
300
+ return new RegExp(`^${escaped}$`);
301
+ }
302
+
185
303
  /** Recursively check if a directory contains test files */
186
304
  function hasTestFilesRecursive(dir) {
187
305
  const ignore = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
@@ -38,6 +38,25 @@ const TEST_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
38
38
  const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX|TEMP(?!late|orar)|WORKAROUND)\s*[(:]/;
39
39
  const TODO_EXTRACT = /\b(TODO|FIXME|HACK|XXX|TEMP(?!late|orar)|WORKAROUND)\s*[:(]?\s*(.+)/;
40
40
 
41
+ // Matches a comment-opening marker. Real TODOs live in comments — restricting
42
+ // matches to text AFTER a comment marker prevents false positives from regex
43
+ // literals or strings that happen to contain a TODO keyword.
44
+ // // — JS/TS/C/C++/Rust/Go/Java line comment
45
+ // # — Python/Ruby/shell/YAML
46
+ // /* — JS/C/C++ block comment open
47
+ // * — block comment continuation (when at start of line)
48
+ // <!-- — HTML/Markdown
49
+ const COMMENT_MARKER = /(?:\/\/|#|\/\*|<!--|^\s*\*\s)/;
50
+
51
+ /**
52
+ * Return the portion of a line after the first comment marker, or null if
53
+ * the line has no comment. Used to constrain TODO matching to comments.
54
+ */
55
+ function commentPortion(line) {
56
+ const m = line.match(COMMENT_MARKER);
57
+ return m ? line.slice(m.index + m[0].length) : null;
58
+ }
59
+
41
60
  // Test skip patterns for common test frameworks
42
61
  const SKIP_PATTERNS = [
43
62
  /\btest\.skip\s*\(/,
@@ -290,10 +309,31 @@ function findTestFiles(rootDir, dir, files, config) {
290
309
  }
291
310
  }
292
311
 
312
+ // Test-file path patterns — TODO scanning skips these by default to avoid
313
+ // false positives from test fixture strings (writeFileSync(..., '// xxxxx:')
314
+ // inside template literals is a comment marker for the regex but not a real
315
+ // annotation to track). Set config.todoTracking.includeTestFiles = true to override.
316
+ const TEST_FILE_RE = /(^|\/)__tests?__\//;
317
+ const TEST_NAME_RE = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|java|go)$/;
318
+
319
+ // The validator's own source file describes the keyword list in its docstring
320
+ // and code. Skipping itself avoids self-referential false positives.
321
+ const SELF_PATH = new URL(import.meta.url).pathname;
322
+
323
+ function isTestFilePath(relPath) {
324
+ return TEST_FILE_RE.test(relPath) || TEST_NAME_RE.test(relPath);
325
+ }
326
+
327
+ function isSelfPath(fullPath) {
328
+ return fullPath === SELF_PATH;
329
+ }
330
+
293
331
  function findTodos(rootDir, dir, todos, config) {
294
332
  let entries;
295
333
  try { entries = readdirSync(dir); } catch { return; }
296
334
 
335
+ const includeTests = config?.todoTracking?.includeTestFiles === true;
336
+
297
337
  for (const entry of entries) {
298
338
  if (IGNORE_DIRS.has(entry)) continue;
299
339
  if (entry.startsWith('.')) continue;
@@ -310,6 +350,15 @@ function findTodos(rootDir, dir, todos, config) {
310
350
 
311
351
  const relPath = relative(rootDir, full);
312
352
 
353
+ // Skip test files unless explicitly opted in — test fixture strings
354
+ // commonly contain comment markers inside template literals that the
355
+ // single-line heuristic can't distinguish from real comments.
356
+ if (!includeTests && isTestFilePath(relPath)) continue;
357
+
358
+ // Skip the validator's own source file — its docstring legitimately
359
+ // names the annotation keywords it scans for.
360
+ if (isSelfPath(full)) continue;
361
+
313
362
  // Apply config ignore patterns (todoIgnore + global ignore)
314
363
  if (config && shouldIgnore(relPath, config, 'todoIgnore')) continue;
315
364
 
@@ -322,8 +371,12 @@ function findTodos(rootDir, dir, todos, config) {
322
371
  const lines = content.split('\n');
323
372
 
324
373
  for (let i = 0; i < lines.length; i++) {
325
- if (TODO_PATTERN.test(lines[i])) {
326
- const match = lines[i].match(TODO_EXTRACT);
374
+ // Restrict scanning to text inside a comment — keeps the regex from
375
+ // matching its own keyword list when DocGuard reads its own source.
376
+ const commentText = commentPortion(lines[i]);
377
+ if (commentText === null) continue;
378
+ if (TODO_PATTERN.test(commentText)) {
379
+ const match = commentText.match(TODO_EXTRACT);
327
380
  if (match) {
328
381
  todos.push({
329
382
  keyword: match[1].toUpperCase(),