docguard-cli 0.9.9 → 0.9.10

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.
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
7
  import { resolve, join, extname } from 'node:path';
8
8
  import { execSync } from 'node:child_process';
9
9
  import { c } from '../shared.mjs';
10
+ import { validateSecurity } from '../validators/security.mjs';
10
11
 
11
12
  const WEIGHTS = {
12
13
  structure: 25, // Required files exist
@@ -410,15 +411,32 @@ function calcTestingScore(dir, config) {
410
411
  const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
411
412
  const hasTopLevelTestDir = testDirs.some(d => existsSync(resolve(dir, d)));
412
413
 
413
- // Check co-located tests: src/**/__tests__/ and src/**/*.test.* / src/**/*.spec.*
414
+ // Check co-located tests: **/__tests__/ and **/*.test.* / **/*.spec.*
415
+ // Scan ALL common source roots — not just src/, also backend/, packages/, etc.
414
416
  let hasColocatedTests = false;
415
417
  if (!hasTopLevelTestDir) {
416
418
  hasColocatedTests = findColocatedTests(dir);
417
419
  }
418
420
 
421
+ // Check if testPatterns config points to existing test locations
422
+ let hasPatternTests = false;
423
+ if (!hasTopLevelTestDir && !hasColocatedTests) {
424
+ const patterns = config.testPatterns || [];
425
+ if (patterns.length > 0) {
426
+ for (const pattern of patterns) {
427
+ // Extract the root directory from the pattern
428
+ const rootDir = pattern.split('/')[0].split('*')[0];
429
+ if (rootDir && existsSync(resolve(dir, rootDir))) {
430
+ hasPatternTests = true;
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ }
436
+
419
437
  // Check vitest/jest config for custom test patterns
420
438
  let hasConfigTests = false;
421
- if (!hasTopLevelTestDir && !hasColocatedTests) {
439
+ if (!hasTopLevelTestDir && !hasColocatedTests && !hasPatternTests) {
422
440
  const testConfigs = ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mts', 'jest.config.ts', 'jest.config.js'];
423
441
  for (const cfgFile of testConfigs) {
424
442
  const cfgPath = resolve(dir, cfgFile);
@@ -448,7 +466,7 @@ function calcTestingScore(dir, config) {
448
466
  }
449
467
  }
450
468
 
451
- if (hasTopLevelTestDir || hasColocatedTests || hasConfigTests) score += 40;
469
+ if (hasTopLevelTestDir || hasColocatedTests || hasPatternTests || hasConfigTests) score += 40;
452
470
 
453
471
  // ── Check 2: TEST-SPEC.md exists (30 pts) ──
454
472
  if (existsSync(resolve(dir, 'docs-canonical/TEST-SPEC.md'))) score += 30;
@@ -490,7 +508,8 @@ function calcTestingScore(dir, config) {
490
508
  */
491
509
  function findColocatedTests(dir) {
492
510
  // Scan these common source roots for co-located tests
493
- const sourceRoots = ['src', 'app', 'lib', 'packages', 'modules'];
511
+ // Includes backend/, server/ for monorepo-style projects (e.g., backend/src/__tests__/)
512
+ const sourceRoots = ['src', 'app', 'lib', 'packages', 'modules', 'backend', 'server'];
494
513
  const ignoreSet = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.cache']);
495
514
 
496
515
  for (const root of sourceRoots) {
@@ -534,25 +553,44 @@ function calcSecurityScore(dir, config) {
534
553
  let score = 0;
535
554
  const ptc = config.projectTypeConfig || {};
536
555
 
537
- // SECURITY.md exists
538
- if (existsSync(resolve(dir, 'docs-canonical/SECURITY.md'))) score += 30;
556
+ // SECURITY.md exists (25 pts)
557
+ if (existsSync(resolve(dir, 'docs-canonical/SECURITY.md'))) score += 25;
539
558
 
540
- // .gitignore exists and includes .env
559
+ // .gitignore exists and includes .env (15 + 15 pts)
541
560
  const gitignorePath = resolve(dir, '.gitignore');
542
561
  if (existsSync(gitignorePath)) {
543
- score += 20;
562
+ score += 15;
544
563
  const content = readFileSync(gitignorePath, 'utf-8');
545
- if (content.includes('.env')) score += 20;
564
+ if (content.includes('.env')) score += 15;
546
565
  }
547
566
 
548
- // No .env file committed (check if .env exists but .gitignore covers it)
549
- if (!existsSync(resolve(dir, '.env')) || existsSync(gitignorePath)) score += 15;
567
+ // No .env file committed (10 pts)
568
+ if (!existsSync(resolve(dir, '.env')) || existsSync(gitignorePath)) score += 10;
550
569
 
551
- // .env.example exists (safe template) — only check if project needs env vars
570
+ // .env.example exists (safe template) — only check if project needs env vars (10 pts)
552
571
  if (ptc.needsEnvExample === false) {
553
- score += 15; // Full marks — project doesn't need env vars
572
+ score += 10; // Full marks — project doesn't need env vars
554
573
  } else if (existsSync(resolve(dir, '.env.example'))) {
555
- score += 15;
574
+ score += 10;
575
+ }
576
+
577
+ // No hardcoded secrets found by security validator (25 pts)
578
+ // Commands MAY compose validator results (Constitution IV, v1.1.0)
579
+ try {
580
+ const secResults = validateSecurity(dir, config);
581
+ if (secResults.errors.length === 0) {
582
+ score += 25;
583
+ } else {
584
+ // Partial credit: deduct proportionally, but give at least some credit
585
+ // if there are few findings relative to project size
586
+ const findingCount = secResults.errors.length;
587
+ if (findingCount <= 2) score += 15;
588
+ else if (findingCount <= 5) score += 5;
589
+ // 6+ findings = 0 pts for this check
590
+ }
591
+ } catch {
592
+ // If validator fails to run, give benefit of the doubt
593
+ score += 25;
556
594
  }
557
595
 
558
596
  return Math.min(100, score);
@@ -668,8 +706,12 @@ function getSuggestion(category, score, details) {
668
706
  const suggestions = {
669
707
  structure: 'Run `docguard init` to create missing documentation',
670
708
  docQuality: 'Run `docguard fix` to get AI prompts for each doc that needs content',
671
- testing: 'Add tests/ directory and configure TEST-SPEC.md',
672
- security: 'Create SECURITY.md and add .env to .gitignore Run `docguard fix --doc security`',
709
+ testing: score < 40
710
+ ? 'Add test files (tests/, src/**/__tests__/, or configure testPatterns in .docguard.json) and create TEST-SPEC.md'
711
+ : 'Configure TEST-SPEC.md and add CI test step → Run `docguard fix --doc test-spec`',
712
+ security: score < 50
713
+ ? 'Create SECURITY.md and add .env to .gitignore → Run `docguard fix --doc security`'
714
+ : 'Review security findings with `docguard guard --verbose` — configure securityIgnore for false positives',
673
715
  environment: 'Document env variables and create .env.example → Run `docguard fix --doc environment`',
674
716
  drift: 'Create DRIFT-LOG.md and log any code deviations',
675
717
  changelog: 'Maintain CHANGELOG.md with [Unreleased] section',
package/cli/docguard.mjs CHANGED
@@ -128,6 +128,15 @@ export function loadConfig(projectDir) {
128
128
  ...getProjectTypeDefaults(merged.projectType),
129
129
  ...(merged.projectTypeConfig || {}),
130
130
  };
131
+ // Normalize testPattern (string) → testPatterns (array) for backward compat
132
+ if (merged.testPattern && !merged.testPatterns) {
133
+ merged.testPatterns = [merged.testPattern];
134
+ } else if (merged.testPattern && merged.testPatterns) {
135
+ // Both set — merge, deduplicate
136
+ if (!merged.testPatterns.includes(merged.testPattern)) {
137
+ merged.testPatterns.push(merged.testPattern);
138
+ }
139
+ }
131
140
  return merged;
132
141
  } catch (e) {
133
142
  console.error(`${c.red}Error parsing .docguard.json: ${e.message}${c.reset}`);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared Ignore Utility — Unified file filtering for all validators.
3
+ *
4
+ * Provides consistent glob matching for config ignore arrays:
5
+ * - config.ignore (global — all validators)
6
+ * - config.securityIgnore (security validator only)
7
+ * - config.todoIgnore (TODO-tracking validator only)
8
+ *
9
+ * Supports exact paths AND glob patterns:
10
+ * - "src/foo.ts" → exact match
11
+ * - "packages/cdk/**" → match any file under packages/cdk/
12
+ * - "backend/src/__tests__/**" → match any file under that path
13
+ * - "*.test.ts" → match files ending in .test.ts
14
+ *
15
+ * Zero NPM dependencies — pure Node.js built-ins only.
16
+ */
17
+
18
+ /**
19
+ * Convert a glob pattern to a RegExp.
20
+ * Supports: * (any chars except /), ** (any path segments), . (literal dot).
21
+ *
22
+ * @param {string} pattern - Glob pattern
23
+ * @returns {RegExp}
24
+ */
25
+ function globToRegex(pattern) {
26
+ const escaped = pattern
27
+ .replace(/\./g, '\\.')
28
+ .replace(/\*\*/g, '§§') // temp placeholder for **
29
+ .replace(/\*/g, '[^/]*')
30
+ .replace(/§§/g, '.*');
31
+ // Match if the relative path:
32
+ // - equals the pattern exactly
33
+ // - ends with /pattern
34
+ // - starts with pattern/
35
+ // - contains /pattern/
36
+ return new RegExp(`^${escaped}$|/${escaped}$|^${escaped}/|/${escaped}/`);
37
+ }
38
+
39
+ /**
40
+ * Build a filter function from an array of glob patterns.
41
+ * Returns a function that returns true if a relative path should be SKIPPED.
42
+ *
43
+ * @param {string[]} patterns - Glob patterns (from config.ignore, config.securityIgnore, etc.)
44
+ * @returns {(relPath: string) => boolean} - true if file should be ignored
45
+ */
46
+ export function buildIgnoreFilter(patterns = []) {
47
+ if (!patterns || patterns.length === 0) return () => false;
48
+
49
+ const regexes = patterns.map(p => globToRegex(p));
50
+ return (relPath) => regexes.some(regex => regex.test(relPath));
51
+ }
52
+
53
+ /**
54
+ * Check if a relative path should be ignored by BOTH
55
+ * global ignore + validator-specific ignore.
56
+ *
57
+ * @param {string} relPath - Relative file path (e.g., "backend/src/__tests__/foo.test.ts")
58
+ * @param {object} config - DocGuard config object
59
+ * @param {string} [validatorKey] - Optional validator-specific key (e.g., 'securityIgnore', 'todoIgnore')
60
+ * @returns {boolean} - true if file should be skipped
61
+ */
62
+ export function shouldIgnore(relPath, config, validatorKey) {
63
+ // Check global ignore
64
+ if (config.ignore && config.ignore.length > 0) {
65
+ const globalFilter = buildIgnoreFilter(config.ignore);
66
+ if (globalFilter(relPath)) return true;
67
+ }
68
+
69
+ // Check validator-specific ignore
70
+ if (validatorKey && config[validatorKey] && config[validatorKey].length > 0) {
71
+ const validatorFilter = buildIgnoreFilter(config[validatorKey]);
72
+ if (validatorFilter(relPath)) return true;
73
+ }
74
+
75
+ return false;
76
+ }
@@ -10,10 +10,14 @@
10
10
  * - Circular dependencies (A → B → A)
11
11
  * - Layer boundary violations (routes importing from routes, etc.)
12
12
  * - Orphan modules (code files with 0 inbound imports)
13
+ *
14
+ * Respects config.ignore (global) for file filtering.
15
+ * Uses shared-ignore.mjs for consistent filtering (Constitution IV, v1.1.0).
13
16
  */
14
17
 
15
18
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
19
  import { resolve, join, extname, relative, dirname, basename } from 'node:path';
20
+ import { shouldIgnore } from '../shared-ignore.mjs';
17
21
 
18
22
  const IGNORE_DIRS = new Set([
19
23
  'node_modules', '.git', '.next', 'dist', 'build',
@@ -33,7 +37,7 @@ export function validateArchitecture(projectDir, config) {
33
37
  }
34
38
 
35
39
  // ── 2. Auto-detect import graph ──
36
- const importGraph = buildImportGraph(projectDir);
40
+ const importGraph = buildImportGraph(projectDir, config);
37
41
  if (importGraph.files.length === 0) return results;
38
42
 
39
43
  // ── 3. Detect circular dependencies ──
@@ -84,7 +88,7 @@ function validateConfigLayers(projectDir, config, layers, results) {
84
88
  const layerDir = resolve(projectDir, dir);
85
89
  if (!existsSync(layerDir)) continue;
86
90
 
87
- const files = getFilesRecursive(layerDir);
91
+ const files = getFilesRecursive(layerDir, config, projectDir);
88
92
  for (const file of files) {
89
93
  if (!CODE_EXTENSIONS.has(extname(file))) continue;
90
94
 
@@ -110,14 +114,18 @@ function validateConfigLayers(projectDir, config, layers, results) {
110
114
 
111
115
  // ── Import Graph Builder ────────────────────────────────────────────────────
112
116
 
113
- function buildImportGraph(projectDir) {
117
+ function buildImportGraph(projectDir, config) {
114
118
  const graph = { files: [], edges: [], fileMap: new Map() };
115
119
 
116
- const allFiles = getFilesRecursive(projectDir);
120
+ const allFiles = getFilesRecursive(projectDir, config, projectDir);
117
121
  const codeFiles = allFiles.filter(f => CODE_EXTENSIONS.has(extname(f)));
118
122
 
119
123
  for (const file of codeFiles) {
120
124
  const relPath = relative(projectDir, file);
125
+
126
+ // Skip files in ignored directories (config.ignore)
127
+ if (config && shouldIgnore(relPath, config)) continue;
128
+
121
129
  graph.files.push(relPath);
122
130
 
123
131
  try {
@@ -355,7 +363,7 @@ function getFileLayer(filePath, layerDirMap) {
355
363
 
356
364
  // ── Utilities ───────────────────────────────────────────────────────────────
357
365
 
358
- function getFilesRecursive(dir) {
366
+ function getFilesRecursive(dir, config, projectDir) {
359
367
  const results = [];
360
368
  if (!existsSync(dir)) return results;
361
369
 
@@ -366,11 +374,18 @@ function getFilesRecursive(dir) {
366
374
 
367
375
  for (const entry of entries) {
368
376
  if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
377
+
378
+ // Check config.ignore for this directory
379
+ if (config && projectDir) {
380
+ const relPath = relative(projectDir, join(dir, entry));
381
+ if (shouldIgnore(relPath, config)) continue;
382
+ }
383
+
369
384
  const fullPath = join(dir, entry);
370
385
  try {
371
386
  const stat = statSync(fullPath);
372
387
  if (stat.isDirectory()) {
373
- results.push(...getFilesRecursive(fullPath));
388
+ results.push(...getFilesRecursive(fullPath, config, projectDir));
374
389
  } else {
375
390
  results.push(fullPath);
376
391
  }
@@ -4,10 +4,14 @@
4
4
  * Runs as part of `docguard guard` on every invocation.
5
5
  * Detects undocumented code artifacts and documented items not found in code.
6
6
  * Returns warnings (not errors) since drift is a soft signal.
7
+ *
8
+ * Respects config.ignore and config.testPatterns for test file discovery.
9
+ * Uses shared-ignore.mjs for consistent filtering (Constitution IV, v1.1.0).
7
10
  */
8
11
 
9
12
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
- import { resolve, join, extname, basename } from 'node:path';
13
+ import { resolve, join, extname, basename, relative } from 'node:path';
14
+ import { shouldIgnore, buildIgnoreFilter } from '../shared-ignore.mjs';
11
15
 
12
16
  const IGNORE_DIRS = new Set([
13
17
  'node_modules', '.git', '.next', 'dist', 'build',
@@ -32,7 +36,7 @@ export function validateDocsDiff(projectDir, config) {
32
36
  const checks = [
33
37
  diffTechStack(projectDir),
34
38
  diffEnvVars(projectDir),
35
- diffTests(projectDir),
39
+ diffTests(projectDir, config),
36
40
  ];
37
41
 
38
42
  for (const result of checks) {
@@ -131,7 +135,13 @@ function diffEnvVars(dir) {
131
135
  };
132
136
  }
133
137
 
134
- function diffTests(dir) {
138
+ /**
139
+ * Diff test files between TEST-SPEC.md and actual code.
140
+ * Uses config.testPatterns if available, otherwise falls back to
141
+ * scanning standard test directories.
142
+ * Always ignores node_modules via shared ignore filter.
143
+ */
144
+ function diffTests(dir, config) {
135
145
  const testSpecPath = resolve(dir, 'docs-canonical/TEST-SPEC.md');
136
146
  if (!existsSync(testSpecPath)) return null;
137
147
 
@@ -144,13 +154,30 @@ function diffTests(dir) {
144
154
  }
145
155
 
146
156
  const codeTests = new Set();
147
- const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
148
- for (const td of testDirs) {
149
- const testDir = resolve(dir, td);
150
- if (!existsSync(testDir)) continue;
151
- const files = getFilesRecursive(testDir);
152
- for (const f of files) {
153
- codeTests.add(f.replace(dir + '/', ''));
157
+
158
+ // Use testPatterns from config if available
159
+ const testPatterns = config?.testPatterns || [];
160
+ if (testPatterns.length > 0) {
161
+ // Use configured patterns to find test files
162
+ const patternFilter = buildIgnoreFilter(testPatterns.map(p => {
163
+ // Invert the pattern: we WANT files matching these patterns
164
+ return p;
165
+ }));
166
+ // Walk the project and collect matching test files
167
+ const allTestFiles = getTestFilesFromPatterns(dir, testPatterns, config);
168
+ for (const f of allTestFiles) {
169
+ codeTests.add(f);
170
+ }
171
+ } else {
172
+ // Fall back to standard test directories
173
+ const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
174
+ for (const td of testDirs) {
175
+ const testDir = resolve(dir, td);
176
+ if (!existsSync(testDir)) continue;
177
+ const files = getFilesRecursive(testDir, config);
178
+ for (const f of files) {
179
+ codeTests.add(f.replace(dir + '/', ''));
180
+ }
154
181
  }
155
182
  }
156
183
 
@@ -163,7 +190,47 @@ function diffTests(dir) {
163
190
  };
164
191
  }
165
192
 
166
- function getFilesRecursive(dir) {
193
+ /**
194
+ * Find test files matching configured testPatterns.
195
+ * Walks the project tree, skipping node_modules and ignored dirs.
196
+ */
197
+ function getTestFilesFromPatterns(dir, patterns, config) {
198
+ const results = [];
199
+ const testFileRegex = /\.(test|spec)\.(mjs|cjs|[jt]sx?)$/;
200
+
201
+ function walk(currentDir) {
202
+ let entries;
203
+ try { entries = readdirSync(currentDir); } catch { return; }
204
+
205
+ for (const entry of entries) {
206
+ if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
207
+ const fullPath = join(currentDir, entry);
208
+ try {
209
+ const stat = statSync(fullPath);
210
+ if (stat.isDirectory()) {
211
+ walk(fullPath);
212
+ } else if (stat.isFile()) {
213
+ const relPath = relative(dir, fullPath);
214
+ // Skip files in ignored paths
215
+ if (config && shouldIgnore(relPath, config)) continue;
216
+ // Check if it matches test file naming patterns
217
+ if (testFileRegex.test(entry) || /__(tests|test)__/.test(relPath)) {
218
+ // Check if it matches any of the configured test patterns
219
+ const patternFilter = buildIgnoreFilter(patterns);
220
+ if (patternFilter(relPath)) {
221
+ results.push(relPath);
222
+ }
223
+ }
224
+ }
225
+ } catch { /* skip */ }
226
+ }
227
+ }
228
+
229
+ walk(dir);
230
+ return results;
231
+ }
232
+
233
+ function getFilesRecursive(dir, config) {
167
234
  const results = [];
168
235
  if (!existsSync(dir)) return results;
169
236
  let entries;
@@ -175,7 +242,7 @@ function getFilesRecursive(dir) {
175
242
  try {
176
243
  const stat = statSync(fullPath);
177
244
  if (stat.isDirectory()) {
178
- results.push(...getFilesRecursive(fullPath));
245
+ results.push(...getFilesRecursive(fullPath, config));
179
246
  } else if (stat.isFile() && CODE_EXTENSIONS.has(extname(fullPath))) {
180
247
  results.push(fullPath);
181
248
  }
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * Security Validator — Basic checks for secrets in code
3
+ *
4
+ * Respects config.securityIgnore (glob patterns) and config.ignore (global).
5
+ * Uses shared-ignore.mjs for consistent filtering (Constitution IV, v1.1.0).
3
6
  */
4
7
 
5
8
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
9
  import { resolve, join, extname } from 'node:path';
10
+ import { shouldIgnore } from '../shared-ignore.mjs';
7
11
 
8
12
  const CODE_EXTENSIONS = new Set([
9
13
  '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
@@ -26,6 +30,30 @@ const SECRET_PATTERNS = [
26
30
  { pattern: /(?:sk-|sk_live_|sk_test_)[a-zA-Z0-9]{20,}/g, label: 'API secret key (Stripe/OpenAI pattern)' },
27
31
  ];
28
32
 
33
+ // Known-safe placeholder/example values that should never be flagged
34
+ const SAFE_PATTERNS = [
35
+ /EXAMPLE/i, // AWS docs example keys contain "EXAMPLE"
36
+ /placeholder\s*=\s*["']/i, // HTML placeholder attributes
37
+ /example\s*:/i, // OpenAPI example: blocks
38
+ /['"]password123['"]/, // Common test fixture value
39
+ /\/\/\s*example/i, // Code comments with "example"
40
+ /<!--.*-->/, // HTML comments
41
+ ];
42
+
43
+ /**
44
+ * Check if a match line is a known-safe placeholder/example.
45
+ * @param {string} line - The full source line containing the match
46
+ * @param {string} matchStr - The matched string
47
+ * @returns {boolean} - true if this is a safe/placeholder value
48
+ */
49
+ function isSafePlaceholder(line, matchStr) {
50
+ // Check if the matched string itself contains "EXAMPLE"
51
+ if (/EXAMPLE/i.test(matchStr)) return true;
52
+
53
+ // Check if the source line matches any safe pattern
54
+ return SAFE_PATTERNS.some(p => p.test(line));
55
+ }
56
+
29
57
  export function validateSecurity(projectDir, config) {
30
58
  const results = { name: 'security', errors: [], warnings: [], passed: 0, total: 0 };
31
59
 
@@ -40,13 +68,33 @@ export function validateSecurity(projectDir, config) {
40
68
  // Skip .env.example — it should have placeholder values
41
69
  if (filePath.endsWith('.env.example')) return;
42
70
 
43
- const content = readFileSync(filePath, 'utf-8');
44
71
  const relPath = filePath.replace(projectDir + '/', '');
45
72
 
73
+ // Apply config ignore patterns (securityIgnore + global ignore)
74
+ if (shouldIgnore(relPath, config, 'securityIgnore')) return;
75
+
76
+ const content = readFileSync(filePath, 'utf-8');
77
+ const lines = content.split('\n');
78
+
46
79
  for (const { pattern, label } of SECRET_PATTERNS) {
47
80
  pattern.lastIndex = 0;
48
81
  const match = pattern.exec(content);
49
82
  if (match) {
83
+ // Find the line containing this match for context-aware filtering
84
+ const matchPos = match.index;
85
+ let charCount = 0;
86
+ let matchLine = '';
87
+ for (const line of lines) {
88
+ charCount += line.length + 1; // +1 for newline
89
+ if (charCount > matchPos) {
90
+ matchLine = line;
91
+ break;
92
+ }
93
+ }
94
+
95
+ // Skip known-safe placeholder/example values
96
+ if (isSafePlaceholder(matchLine, match[0])) continue;
97
+
50
98
  findings.push({ file: relPath, label, match: match[0].substring(0, 30) + '...' });
51
99
  }
52
100
  }
@@ -6,6 +6,9 @@
6
6
  *
7
7
  * Also detects skipped tests without explanation.
8
8
  *
9
+ * Respects config.todoIgnore (glob patterns) and config.ignore (global).
10
+ * Uses shared-ignore.mjs for consistent filtering (Constitution IV, v1.1.0).
11
+ *
9
12
  * Inspired by spec-kit-cleanup (github.com/dsrednicki/spec-kit-cleanup)
10
13
  * which uses tiered issue classification for code hygiene.
11
14
  *
@@ -14,6 +17,7 @@
14
17
 
15
18
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
19
  import { resolve, join, relative, extname } from 'node:path';
20
+ import { shouldIgnore } from '../shared-ignore.mjs';
17
21
 
18
22
  const IGNORE_DIRS = new Set([
19
23
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -78,14 +82,14 @@ export function validateTodoTracking(projectDir, config) {
78
82
  /**
79
83
  * Scan test files for skip/todo patterns without adjacent explanation comments.
80
84
  */
81
- function checkSkippedTests(projectDir) {
85
+ function checkSkippedTests(projectDir, config) {
82
86
  const errors = [];
83
87
  const warnings = [];
84
88
  let passed = 0;
85
89
  let total = 0;
86
90
 
87
91
  const testFiles = [];
88
- findTestFiles(projectDir, projectDir, testFiles);
92
+ findTestFiles(projectDir, projectDir, testFiles, config);
89
93
 
90
94
  if (testFiles.length === 0) return { errors, warnings, passed, total };
91
95
 
@@ -161,7 +165,7 @@ function checkUntrackedTodos(projectDir, config) {
161
165
 
162
166
  // Collect all TODO/FIXME items from source
163
167
  const todos = [];
164
- findTodos(projectDir, projectDir, todos);
168
+ findTodos(projectDir, projectDir, todos, config);
165
169
 
166
170
  if (todos.length === 0) {
167
171
  // No TODOs found — that's clean code
@@ -177,11 +181,26 @@ function checkUntrackedTodos(projectDir, config) {
177
181
  let untrackedCount = 0;
178
182
 
179
183
  for (const todo of todos) {
180
- // Check if the TODO text appears somewhere in tracking docs
181
- const isTracked = trackingContent.some(doc =>
182
- doc.content.includes(todo.keyword) ||
183
- doc.content.toLowerCase().includes(todo.text.toLowerCase().trim().substring(0, 30))
184
- );
184
+ // Check if the TODO is tracked in documentation
185
+ // Improved matching: check full text AND file location context
186
+ const isTracked = trackingContent.some(doc => {
187
+ const content = doc.content;
188
+ const contentLower = content.toLowerCase();
189
+ const todoTextLower = todo.text.toLowerCase().trim();
190
+
191
+ // Match 1: Full TODO text appears in the doc (at least 20 chars or full text)
192
+ const searchText = todoTextLower.length > 20
193
+ ? todoTextLower.substring(0, 40)
194
+ : todoTextLower;
195
+ const hasText = contentLower.includes(searchText);
196
+
197
+ // Match 2: File location appears nearby in the doc
198
+ const hasLocation = content.includes(todo.file) ||
199
+ content.includes(`${todo.file}:${todo.line}`);
200
+
201
+ // Either the full text matches, or the file location is referenced with partial text
202
+ return (hasText && hasLocation) || (hasText && todoTextLower.length > 30);
203
+ });
185
204
 
186
205
  if (!isTracked) {
187
206
  untrackedCount++;
@@ -231,7 +250,7 @@ function loadTrackingDocs(projectDir, config) {
231
250
 
232
251
  // ──── File Scanners ────────────────────────────────────────────────────────
233
252
 
234
- function findTestFiles(rootDir, dir, files) {
253
+ function findTestFiles(rootDir, dir, files, config) {
235
254
  let entries;
236
255
  try { entries = readdirSync(dir); } catch { return; }
237
256
 
@@ -244,7 +263,7 @@ function findTestFiles(rootDir, dir, files) {
244
263
  try { stat = statSync(full); } catch { continue; }
245
264
 
246
265
  if (stat.isDirectory()) {
247
- findTestFiles(rootDir, full, files);
266
+ findTestFiles(rootDir, full, files, config);
248
267
  } else {
249
268
  const ext = extname(entry).toLowerCase();
250
269
  if (!TEST_EXTENSIONS.has(ext)) continue;
@@ -252,13 +271,16 @@ function findTestFiles(rootDir, dir, files) {
252
271
  // Match test file patterns
253
272
  if (/\.(test|spec)\.(mjs|cjs|[jt]sx?)$/.test(entry) ||
254
273
  /__(tests|test)__/.test(relative(rootDir, full))) {
255
- files.push(relative(rootDir, full));
274
+ const relPath = relative(rootDir, full);
275
+ // Apply config ignore patterns (todoIgnore + global ignore)
276
+ if (config && shouldIgnore(relPath, config, 'todoIgnore')) continue;
277
+ files.push(relPath);
256
278
  }
257
279
  }
258
280
  }
259
281
  }
260
282
 
261
- function findTodos(rootDir, dir, todos) {
283
+ function findTodos(rootDir, dir, todos, config) {
262
284
  let entries;
263
285
  try { entries = readdirSync(dir); } catch { return; }
264
286
 
@@ -271,16 +293,20 @@ function findTodos(rootDir, dir, todos) {
271
293
  try { stat = statSync(full); } catch { continue; }
272
294
 
273
295
  if (stat.isDirectory()) {
274
- findTodos(rootDir, full, todos);
296
+ findTodos(rootDir, full, todos, config);
275
297
  } else {
276
298
  const ext = extname(entry).toLowerCase();
277
299
  if (!SOURCE_EXTENSIONS.has(ext)) continue;
278
300
 
301
+ const relPath = relative(rootDir, full);
302
+
303
+ // Apply config ignore patterns (todoIgnore + global ignore)
304
+ if (config && shouldIgnore(relPath, config, 'todoIgnore')) continue;
305
+
279
306
  let content;
280
307
  try { content = readFileSync(full, 'utf-8'); } catch { continue; }
281
308
 
282
309
  const lines = content.split('\n');
283
- const relPath = relative(rootDir, full);
284
310
 
285
311
  for (let i = 0; i < lines.length; i++) {
286
312
  if (TODO_PATTERN.test(lines[i])) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
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": {