docguard-cli 0.11.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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * CDK Detector — Re-export shim.
3
+ *
4
+ * The CDK-specific detector has been generalized into a multi-tool IaC
5
+ * detector at cli/scanners/iac.mjs covering CDK, Terraform, Pulumi, SAM,
6
+ * and Serverless Framework. This module re-exports the CDK-only API for
7
+ * backward compatibility. New code should import from iac.mjs directly.
8
+ */
9
+
10
+ export { detectCDK, hasInfrastructureHeading } from './iac.mjs';
@@ -0,0 +1,235 @@
1
+ /**
2
+ * IaC Detector — Identifies Infrastructure-as-Code projects.
3
+ *
4
+ * IaC code is real production source that defines cloud infrastructure.
5
+ * It MUST be documented in ARCHITECTURE.md, not silently ignored. This
6
+ * detector identifies which IaC tool the project uses so docs-coverage
7
+ * can emit ONE consolidated actionable warning naming the actual layout
8
+ * (instead of multiple generic per-directory warnings).
9
+ *
10
+ * Supported tools:
11
+ * - AWS CDK → cdk.json marker file
12
+ * - Terraform → *.tf files in any non-ignored directory
13
+ * - Pulumi → Pulumi.yaml marker file
14
+ * - AWS SAM → template.yaml/yml with "AWS::Serverless::"
15
+ * - Serverless Fmw → serverless.yml/serverless.yaml/serverless.ts
16
+ *
17
+ * Zero NPM dependencies — pure Node.js built-ins only.
18
+ */
19
+
20
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
21
+ import { join, relative } from 'node:path';
22
+ import { DEFAULT_IGNORE_DIRS } from '../shared-ignore.mjs';
23
+
24
+ const MAX_DEPTH = 6;
25
+
26
+ /**
27
+ * Per-tool conventions: marker file/pattern + the directories that hold the
28
+ * actual IaC source. Used to construct the consolidated warning text.
29
+ */
30
+ const TOOL_PROFILES = {
31
+ cdk: {
32
+ label: 'AWS CDK',
33
+ markerFile: 'cdk.json',
34
+ sourceDirs: ['bin/ (app entrypoint)', 'lib/stacks/', 'lib/constructs/'],
35
+ headingPattern: /^#+\s+(infrastructure|cdk|iac)\b/im,
36
+ },
37
+ terraform: {
38
+ label: 'Terraform',
39
+ markerFile: null, // any *.tf file
40
+ sourceDirs: ['*.tf (root module)', 'modules/ (reusable modules)', 'environments/ (per-env tfvars)'],
41
+ headingPattern: /^#+\s+(infrastructure|terraform|iac)\b/im,
42
+ },
43
+ pulumi: {
44
+ label: 'Pulumi',
45
+ markerFile: 'Pulumi.yaml',
46
+ sourceDirs: ['index.ts (main program)', 'stacks/', 'config/'],
47
+ headingPattern: /^#+\s+(infrastructure|pulumi|iac)\b/im,
48
+ },
49
+ sam: {
50
+ label: 'AWS SAM',
51
+ markerFile: 'template.yaml', // also template.yml — checked below
52
+ sourceDirs: ['template.yaml (SAM manifest)', 'src/ (Lambda handlers)', 'events/'],
53
+ headingPattern: /^#+\s+(infrastructure|sam|serverless|iac)\b/im,
54
+ },
55
+ serverless: {
56
+ label: 'Serverless Framework',
57
+ markerFile: 'serverless.yml', // also .yaml, .ts — checked below
58
+ sourceDirs: ['serverless.yml (manifest)', 'handlers/', 'src/'],
59
+ headingPattern: /^#+\s+(infrastructure|serverless|iac)\b/im,
60
+ },
61
+ };
62
+
63
+ /**
64
+ * Detect every IaC tool used in the project. Walks the tree from projectDir
65
+ * looking for marker files, respecting DEFAULT_IGNORE_DIRS.
66
+ *
67
+ * @param {string} projectDir - Absolute path to project root
68
+ * @returns {{
69
+ * isIaC: boolean,
70
+ * tools: Array<{
71
+ * tool: string, // 'cdk' | 'terraform' | 'pulumi' | 'sam' | 'serverless'
72
+ * label: string, // 'AWS CDK' etc.
73
+ * markerPaths: string[], // relative paths to detected marker files
74
+ * packageDirs: string[], // relative dirs containing the markers
75
+ * sourceDirs: string[], // expected source layout per tool convention
76
+ * }>
77
+ * }}
78
+ */
79
+ export function detectIaC(projectDir) {
80
+ const findings = {
81
+ cdk: { markerPaths: [], packageDirs: [] },
82
+ terraform: { markerPaths: [], packageDirs: [] },
83
+ pulumi: { markerPaths: [], packageDirs: [] },
84
+ sam: { markerPaths: [], packageDirs: [] },
85
+ serverless: { markerPaths: [], packageDirs: [] },
86
+ };
87
+
88
+ const recordFinding = (tool, fullPath) => {
89
+ const relPath = relative(projectDir, fullPath);
90
+ findings[tool].markerPaths.push(relPath);
91
+ const pkgDir = relative(projectDir, dirnameOf(fullPath)) || '.';
92
+ if (!findings[tool].packageDirs.includes(pkgDir)) {
93
+ findings[tool].packageDirs.push(pkgDir);
94
+ }
95
+ };
96
+
97
+ const walk = (dir, depth) => {
98
+ if (depth > MAX_DEPTH) return;
99
+ let entries;
100
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
101
+
102
+ for (const e of entries) {
103
+ if (!e.isFile()) continue;
104
+ const full = join(dir, e.name);
105
+
106
+ // CDK
107
+ if (e.name === 'cdk.json') recordFinding('cdk', full);
108
+
109
+ // Terraform — any .tf file (we record one per directory, not per file)
110
+ if (e.name.endsWith('.tf')) {
111
+ const pkgDir = relative(projectDir, dir) || '.';
112
+ if (!findings.terraform.packageDirs.includes(pkgDir)) {
113
+ findings.terraform.markerPaths.push(relative(projectDir, full));
114
+ findings.terraform.packageDirs.push(pkgDir);
115
+ }
116
+ }
117
+
118
+ // Pulumi
119
+ if (e.name === 'Pulumi.yaml' || e.name === 'Pulumi.yml') {
120
+ recordFinding('pulumi', full);
121
+ }
122
+
123
+ // SAM — template.yaml/yml WITH AWS::Serverless::
124
+ if (e.name === 'template.yaml' || e.name === 'template.yml') {
125
+ if (fileContains(full, 'AWS::Serverless::')) recordFinding('sam', full);
126
+ }
127
+
128
+ // Serverless Framework
129
+ if (
130
+ e.name === 'serverless.yml' ||
131
+ e.name === 'serverless.yaml' ||
132
+ e.name === 'serverless.ts' ||
133
+ e.name === 'serverless.js'
134
+ ) {
135
+ recordFinding('serverless', full);
136
+ }
137
+ }
138
+
139
+ for (const e of entries) {
140
+ if (!e.isDirectory()) continue;
141
+ if (DEFAULT_IGNORE_DIRS.has(e.name)) continue;
142
+ if (e.name.startsWith('.')) continue;
143
+ walk(join(dir, e.name), depth + 1);
144
+ }
145
+ };
146
+
147
+ if (existsSync(projectDir)) {
148
+ try {
149
+ if (statSync(projectDir).isDirectory()) walk(projectDir, 0);
150
+ } catch { /* skip */ }
151
+ }
152
+
153
+ const tools = [];
154
+ for (const [tool, data] of Object.entries(findings)) {
155
+ if (data.markerPaths.length > 0) {
156
+ tools.push({
157
+ tool,
158
+ label: TOOL_PROFILES[tool].label,
159
+ markerPaths: data.markerPaths,
160
+ packageDirs: data.packageDirs,
161
+ sourceDirs: TOOL_PROFILES[tool].sourceDirs,
162
+ });
163
+ }
164
+ }
165
+
166
+ return { isIaC: tools.length > 0, tools };
167
+ }
168
+
169
+ /**
170
+ * Check whether ARCHITECTURE.md content includes an Infrastructure/CDK/IaC/
171
+ * Terraform/Pulumi/SAM heading at any level. Case-insensitive.
172
+ *
173
+ * @param {string} archContent - Full ARCHITECTURE.md content
174
+ * @returns {boolean}
175
+ */
176
+ export function hasInfrastructureHeading(archContent) {
177
+ if (!archContent) return false;
178
+ return /^#+\s+(infrastructure|cdk|iac|terraform|pulumi|sam|serverless)\b/im.test(archContent);
179
+ }
180
+
181
+ /**
182
+ * Build the consolidated warning text for a detected IaC tool.
183
+ * One warning per tool — names the marker location and required content.
184
+ */
185
+ export function buildIaCWarning(toolFinding) {
186
+ const primary = toolFinding.markerPaths[0];
187
+ const pkgDir = toolFinding.packageDirs[0];
188
+ const where = pkgDir === '.' ? '' : pkgDir + '/';
189
+ const sourceList = toolFinding.sourceDirs
190
+ .map(s => s.startsWith('*.') ? `${where}${s}` : `${where}${s}`)
191
+ .join(', ');
192
+ return `${toolFinding.label} detected at ${primary} — add an "Infrastructure" section to ` +
193
+ `ARCHITECTURE.md covering ${sourceList}`;
194
+ }
195
+
196
+ // ── Helpers ─────────────────────────────────────────────────────────────────
197
+
198
+ function dirnameOf(p) {
199
+ const i = p.lastIndexOf('/');
200
+ if (i < 0) {
201
+ const j = p.lastIndexOf('\\');
202
+ return j < 0 ? p : p.slice(0, j);
203
+ }
204
+ return p.slice(0, i);
205
+ }
206
+
207
+ function fileContains(filePath, needle) {
208
+ try {
209
+ const content = readFileSync(filePath, 'utf-8');
210
+ return content.includes(needle);
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ // ── Backwards-compatibility shim ────────────────────────────────────────────
217
+
218
+ /**
219
+ * Legacy CDK-only API kept for callers that don't need multi-tool detection.
220
+ * Delegates to detectIaC and projects the CDK slice into the old shape.
221
+ *
222
+ * @deprecated Use detectIaC for new code.
223
+ */
224
+ export function detectCDK(projectDir) {
225
+ const result = detectIaC(projectDir);
226
+ const cdk = result.tools.find(t => t.tool === 'cdk');
227
+ if (!cdk) {
228
+ return { isCDK: false, cdkJsonPaths: [], cdkPackageDirs: [] };
229
+ }
230
+ return {
231
+ isCDK: true,
232
+ cdkJsonPaths: cdk.markerPaths,
233
+ cdkPackageDirs: cdk.packageDirs,
234
+ };
235
+ }
@@ -15,6 +15,31 @@
15
15
  * Zero NPM dependencies — pure Node.js built-ins only.
16
16
  */
17
17
 
18
+ /**
19
+ * Canonical set of directory names that should never be scanned, regardless
20
+ * of validator. Build outputs, VCS internals, package caches, framework synth
21
+ * outputs. Validators MAY extend this with their own additions but SHOULD
22
+ * start from this base so behavior is consistent across the tool.
23
+ */
24
+ export const DEFAULT_IGNORE_DIRS = new Set([
25
+ // Package managers
26
+ 'node_modules', 'vendor', '.venv', '__pycache__',
27
+ // VCS
28
+ '.git', '.jj', '.hg', '.svn',
29
+ // Build outputs — JS/TS, Rust/Java, generic
30
+ 'dist', 'build', 'out', 'coverage', 'target', '.gradle',
31
+ // Framework synth/cache
32
+ '.next', '.nuxt', '.turbo', '.vercel', '.cache', '.svelte-kit', 'cdk.out',
33
+ // OS
34
+ '.DS_Store',
35
+ ]);
36
+
37
+ // Regex for paths that must always be rejected at any depth, regardless of
38
+ // the glob pattern matching them. These are duplicate file trees (worktrees)
39
+ // or runtime caches that should NEVER be treated as primary source.
40
+ const ALWAYS_REJECT_PATH_RE =
41
+ /(?:^|[/\\])(?:node_modules|\.claude[/\\]worktrees|\.git[/\\]worktrees|\.jj)(?:[/\\]|$)/;
42
+
18
43
  /**
19
44
  * Convert a glob pattern to a RegExp.
20
45
  * Supports: * (any chars except /), ** (any path segments), . (literal dot).
@@ -111,8 +136,10 @@ function globToMatchRegex(pattern) {
111
136
  export function globMatch(relPath, patterns) {
112
137
  if (!relPath || !patterns || patterns.length === 0) return false;
113
138
 
114
- // Always reject paths containing node_modules at any depth
115
- if (/(?:^|[/\\])node_modules(?:[/\\]|$)/.test(relPath)) return false;
139
+ // Always reject paths inside node_modules / worktree copies / .jj at any
140
+ // depth. A user's testPatterns like "**/*.test.ts" would otherwise match
141
+ // duplicate trees under .claude/worktrees and inflate test counts.
142
+ if (ALWAYS_REJECT_PATH_RE.test(relPath)) return false;
116
143
 
117
144
  const regexes = patterns.map(p => globToMatchRegex(p));
118
145
  return regexes.some(r => r.test(relPath));
@@ -18,8 +18,9 @@ import { resolve, join, dirname, relative, extname } from 'node:path';
18
18
  import { shouldIgnore } from './shared-ignore.mjs';
19
19
 
20
20
  const IGNORE_DIRS = new Set([
21
- 'node_modules', '.git', '.next', 'dist', 'build',
21
+ 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
22
22
  'coverage', '.cache', '__pycache__', '.venv', 'vendor', '.turbo',
23
+ 'cdk.out',
23
24
  ]);
24
25
 
25
26
  const CODE_EXTENSIONS = new Set([
@@ -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();
@@ -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(),
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.9.9"
6
+ version: "0.11.1"
7
7
  description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with 19 automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. Zero NPM runtime dependencies."
8
8
  author: "Ricardo Accioly"
9
9
  repository: "https://github.com/raccioly/docguard"
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.9.9
9
+ version: 0.11.1
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.9.9 -->
12
+ <!-- docguard:version: 0.11.1 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15
 
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
7
7
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
8
8
  metadata:
9
9
  author: docguard
10
- version: 0.9.9
10
+ version: 0.11.1
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.9.9 -->
13
+ <!-- docguard:version: 0.11.1 -->
14
14
 
15
15
  # DocGuard Guard Skill
16
16
 
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.9.9
9
+ version: 0.11.1
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.9.9 -->
12
+ <!-- docguard:version: 0.11.1 -->
13
13
 
14
14
  # DocGuard Review Skill
15
15
 
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.9.9
9
+ version: 0.11.1
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.9.9 -->
12
+ <!-- docguard:version: 0.11.1 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,7 +4,7 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
4
4
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
5
5
  metadata:
6
6
  author: docguard
7
- version: 0.10.0
7
+ version: 0.11.1
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,6 +58,58 @@
58
58
  |---------|---------|-----|----------|
59
59
  | <!-- e.g. Stripe --> | <!-- e.g. Payments --> | <!-- e.g. 99.99% --> | <!-- e.g. Queue + retry --> |
60
60
 
61
+ ## Infrastructure (IaC)
62
+
63
+ <!--
64
+ Skip this section if the project does not use Infrastructure-as-Code.
65
+ DocGuard auto-detects AWS CDK (cdk.json), Terraform (*.tf), Pulumi
66
+ (Pulumi.yaml), AWS SAM (template.yaml with AWS::Serverless::), and
67
+ Serverless Framework (serverless.yml). When any are detected and this
68
+ section is missing, DocGuard emits ONE consolidated reminder per tool.
69
+
70
+ Document the layout for YOUR IaC tool below. Remove the blocks that
71
+ don't apply.
72
+ -->
73
+
74
+ ### AWS CDK
75
+
76
+ <!-- Remove this block if you don't use CDK -->
77
+
78
+ | Artifact | Location | Purpose |
79
+ |----------|----------|---------|
80
+ | App entrypoint | <!-- e.g. packages/cdk/bin/app.ts --> | Instantiates stacks per environment |
81
+ | Stacks | <!-- e.g. packages/cdk/lib/stacks/ --> | One file per CloudFormation stack |
82
+ | Constructs | <!-- e.g. packages/cdk/lib/constructs/ --> | Reusable infrastructure components |
83
+ | Synth config | <!-- e.g. packages/cdk/cdk.json --> | CDK CLI configuration |
84
+ | Context cache | <!-- e.g. packages/cdk/cdk.context.json --> | Environment lookup results (committed) |
85
+ | Synth output | <!-- e.g. packages/cdk/cdk.out/ --> | Generated CloudFormation (gitignored) |
86
+
87
+ ### Terraform
88
+
89
+ <!-- Remove this block if you don't use Terraform -->
90
+
91
+ | Artifact | Location | Purpose |
92
+ |----------|----------|---------|
93
+ | Root module | <!-- e.g. infra/*.tf --> | Top-level resources |
94
+ | Reusable modules | <!-- e.g. infra/modules/ --> | Composable infrastructure blocks |
95
+ | Environment configs | <!-- e.g. infra/environments/ --> | Per-env tfvars (dev, staging, prod) |
96
+ | State backend | <!-- e.g. S3 bucket + DynamoDB lock table --> | Remote state storage |
97
+
98
+ ### Pulumi / SAM / Serverless Framework
99
+
100
+ <!--
101
+ Document app program (index.ts), stack config, or manifest location.
102
+ Remove this block if you don't use these tools.
103
+ -->
104
+
105
+ ### Deployment Pipeline
106
+
107
+ <!-- How does IaC code reach the cloud? -->
108
+
109
+ - <!-- e.g. PR → CI workflow → terraform plan + manual approval → terraform apply -->
110
+ - <!-- e.g. PR → CodePipeline → cdk deploy → dev/staging/prod -->
111
+
112
+
61
113
  ## Diagrams
62
114
 
63
115
  <!-- Architecture diagrams using Mermaid, ASCII art, or linked images -->