docguard-cli 0.9.9 → 0.9.11

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;
@@ -476,8 +494,30 @@ function calcTestingScore(dir, config) {
476
494
  }
477
495
 
478
496
  // ── Check 4: CI test step (15 pts) ──
479
- const ciFiles = ['.github/workflows/ci.yml', '.github/workflows/test.yml'];
480
- const hasCITest = ciFiles.some(f => existsSync(resolve(dir, f)));
497
+ // Support multiple CI systems — not just GitHub Actions
498
+ const ciFiles = [
499
+ '.github/workflows/ci.yml', '.github/workflows/test.yml',
500
+ '.github/workflows/ci.yaml', '.github/workflows/test.yaml',
501
+ 'buildspec.yml', 'buildspec.test.yml', // AWS CodeBuild
502
+ 'amplify.yml', // AWS Amplify
503
+ 'Jenkinsfile', // Jenkins
504
+ '.circleci/config.yml', // CircleCI
505
+ '.gitlab-ci.yml', // GitLab CI
506
+ '.travis.yml', // Travis CI
507
+ ];
508
+ let hasCITest = ciFiles.some(f => existsSync(resolve(dir, f)));
509
+
510
+ // Also check turbo.json for "test" pipeline task
511
+ if (!hasCITest) {
512
+ const turboPath = resolve(dir, 'turbo.json');
513
+ if (existsSync(turboPath)) {
514
+ try {
515
+ const turboContent = readFileSync(turboPath, 'utf-8');
516
+ if (/"test"/.test(turboContent)) hasCITest = true;
517
+ } catch { /* skip */ }
518
+ }
519
+ }
520
+
481
521
  if (hasCITest) score += 15;
482
522
 
483
523
  return Math.min(100, score);
@@ -490,7 +530,8 @@ function calcTestingScore(dir, config) {
490
530
  */
491
531
  function findColocatedTests(dir) {
492
532
  // Scan these common source roots for co-located tests
493
- const sourceRoots = ['src', 'app', 'lib', 'packages', 'modules'];
533
+ // Includes backend/, server/ for monorepo-style projects (e.g., backend/src/__tests__/)
534
+ const sourceRoots = ['src', 'app', 'lib', 'packages', 'modules', 'backend', 'server'];
494
535
  const ignoreSet = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.cache']);
495
536
 
496
537
  for (const root of sourceRoots) {
@@ -534,25 +575,44 @@ function calcSecurityScore(dir, config) {
534
575
  let score = 0;
535
576
  const ptc = config.projectTypeConfig || {};
536
577
 
537
- // SECURITY.md exists
538
- if (existsSync(resolve(dir, 'docs-canonical/SECURITY.md'))) score += 30;
578
+ // SECURITY.md exists (25 pts)
579
+ if (existsSync(resolve(dir, 'docs-canonical/SECURITY.md'))) score += 25;
539
580
 
540
- // .gitignore exists and includes .env
581
+ // .gitignore exists and includes .env (15 + 15 pts)
541
582
  const gitignorePath = resolve(dir, '.gitignore');
542
583
  if (existsSync(gitignorePath)) {
543
- score += 20;
584
+ score += 15;
544
585
  const content = readFileSync(gitignorePath, 'utf-8');
545
- if (content.includes('.env')) score += 20;
586
+ if (content.includes('.env')) score += 15;
546
587
  }
547
588
 
548
- // No .env file committed (check if .env exists but .gitignore covers it)
549
- if (!existsSync(resolve(dir, '.env')) || existsSync(gitignorePath)) score += 15;
589
+ // No .env file committed (10 pts)
590
+ if (!existsSync(resolve(dir, '.env')) || existsSync(gitignorePath)) score += 10;
550
591
 
551
- // .env.example exists (safe template) — only check if project needs env vars
592
+ // .env.example exists (safe template) — only check if project needs env vars (10 pts)
552
593
  if (ptc.needsEnvExample === false) {
553
- score += 15; // Full marks — project doesn't need env vars
594
+ score += 10; // Full marks — project doesn't need env vars
554
595
  } else if (existsSync(resolve(dir, '.env.example'))) {
555
- score += 15;
596
+ score += 10;
597
+ }
598
+
599
+ // No hardcoded secrets found by security validator (25 pts)
600
+ // Commands MAY compose validator results (Constitution IV, v1.1.0)
601
+ try {
602
+ const secResults = validateSecurity(dir, config);
603
+ if (secResults.errors.length === 0) {
604
+ score += 25;
605
+ } else {
606
+ // Partial credit: deduct proportionally, but give at least some credit
607
+ // if there are few findings relative to project size
608
+ const findingCount = secResults.errors.length;
609
+ if (findingCount <= 2) score += 15;
610
+ else if (findingCount <= 5) score += 5;
611
+ // 6+ findings = 0 pts for this check
612
+ }
613
+ } catch {
614
+ // If validator fails to run, give benefit of the doubt
615
+ score += 25;
556
616
  }
557
617
 
558
618
  return Math.min(100, score);
@@ -668,8 +728,12 @@ function getSuggestion(category, score, details) {
668
728
  const suggestions = {
669
729
  structure: 'Run `docguard init` to create missing documentation',
670
730
  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`',
731
+ testing: score < 40
732
+ ? 'Add test files (tests/, src/**/__tests__/, or configure testPatterns in .docguard.json) and create TEST-SPEC.md'
733
+ : 'Configure TEST-SPEC.md and add CI test step → Run `docguard fix --doc test-spec`',
734
+ security: score < 50
735
+ ? 'Create SECURITY.md and add .env to .gitignore → Run `docguard fix --doc security`'
736
+ : 'Review security findings with `docguard guard --verbose` — configure securityIgnore for false positives',
673
737
  environment: 'Document env variables and create .env.example → Run `docguard fix --doc environment`',
674
738
  drift: 'Create DRIFT-LOG.md and log any code deviations',
675
739
  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,119 @@
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
+ }
77
+
78
+ /**
79
+ * Convert a glob pattern to a RegExp for POSITIVE matching.
80
+ * Unlike globToRegex (used for ignore filtering), this anchors the match
81
+ * to the full relative path from the project root.
82
+ *
83
+ * Supports: * (any chars except /), ** (any path segments), . (literal dot).
84
+ *
85
+ * @param {string} pattern - Glob pattern (e.g., "backend/**\/__tests__/**\/*.test.ts")
86
+ * @returns {RegExp}
87
+ */
88
+ function globToMatchRegex(pattern) {
89
+ // Normalize: replace **/ with a placeholder that means "zero or more path segments"
90
+ let escaped = pattern
91
+ .replace(/\./g, '\\.')
92
+ .replace(/\*\*\//g, '§STARSTAR§') // **/ → zero-or-more segments
93
+ .replace(/\*\*/g, '.*') // standalone ** → any chars
94
+ .replace(/\*/g, '[^/]*') // single * → any chars except /
95
+ .replace(/§STARSTAR§/g, '(.*/)?'); // **/ → optional path prefix
96
+ return new RegExp(`^${escaped}$`);
97
+ }
98
+
99
+ /**
100
+ * Check if a relative path matches ANY of the given glob patterns.
101
+ * Purpose-built for POSITIVE matching (e.g., "is this a test file?").
102
+ *
103
+ * ALWAYS rejects paths containing node_modules at any depth.
104
+ * This is the correct function for test file discovery — do NOT use
105
+ * buildIgnoreFilter() for this purpose.
106
+ *
107
+ * @param {string} relPath - Relative path from project root
108
+ * @param {string[]} patterns - Array of glob patterns to match against
109
+ * @returns {boolean} - true if path matches a pattern AND is not in node_modules
110
+ */
111
+ export function globMatch(relPath, patterns) {
112
+ if (!relPath || !patterns || patterns.length === 0) return false;
113
+
114
+ // Always reject paths containing node_modules at any depth
115
+ if (/(?:^|[/\\])node_modules(?:[/\\]|$)/.test(relPath)) return false;
116
+
117
+ const regexes = patterns.map(p => globToMatchRegex(p));
118
+ return regexes.some(r => r.test(relPath));
119
+ }
@@ -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, globMatch } 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 globMatch().
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
 
@@ -143,14 +153,26 @@ function diffTests(dir) {
143
153
  docTests.add(match[1]);
144
154
  }
145
155
 
156
+ // Collect test files from disk using globMatch (always excludes node_modules)
146
157
  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 + '/', ''));
158
+ const testPatterns = config?.testPatterns || [];
159
+
160
+ if (testPatterns.length > 0) {
161
+ // Use configured patterns — globMatch handles node_modules exclusion
162
+ const allTestFiles = getTestFilesFromPatterns(dir, testPatterns, config);
163
+ for (const f of allTestFiles) {
164
+ codeTests.add(f);
165
+ }
166
+ } else {
167
+ // Fall back to standard test directories
168
+ const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
169
+ for (const td of testDirs) {
170
+ const testDir = resolve(dir, td);
171
+ if (!existsSync(testDir)) continue;
172
+ const files = getFilesRecursive(testDir, config);
173
+ for (const f of files) {
174
+ codeTests.add(f.replace(dir + '/', ''));
175
+ }
154
176
  }
155
177
  }
156
178
 
@@ -163,7 +185,44 @@ function diffTests(dir) {
163
185
  };
164
186
  }
165
187
 
166
- function getFilesRecursive(dir) {
188
+ /**
189
+ * Find test files matching configured testPatterns.
190
+ * Uses globMatch() for pattern matching — always excludes node_modules.
191
+ * Results are deduplicated via Set (handles overlapping patterns).
192
+ */
193
+ function getTestFilesFromPatterns(dir, patterns, config) {
194
+ const results = new Set();
195
+
196
+ function walk(currentDir) {
197
+ let entries;
198
+ try { entries = readdirSync(currentDir); } catch { return; }
199
+
200
+ for (const entry of entries) {
201
+ // Skip node_modules and other ignored dirs at directory level (fast path)
202
+ if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
203
+ const fullPath = join(currentDir, entry);
204
+ try {
205
+ const stat = statSync(fullPath);
206
+ if (stat.isDirectory()) {
207
+ walk(fullPath);
208
+ } else if (stat.isFile()) {
209
+ const relPath = relative(dir, fullPath);
210
+ // Skip files in globally ignored paths
211
+ if (config && shouldIgnore(relPath, config)) continue;
212
+ // Use globMatch for positive pattern matching (rejects node_modules internally)
213
+ if (globMatch(relPath, patterns)) {
214
+ results.add(relPath);
215
+ }
216
+ }
217
+ } catch { /* skip */ }
218
+ }
219
+ }
220
+
221
+ walk(dir);
222
+ return [...results];
223
+ }
224
+
225
+ function getFilesRecursive(dir, config) {
167
226
  const results = [];
168
227
  if (!existsSync(dir)) return results;
169
228
  let entries;
@@ -175,7 +234,7 @@ function getFilesRecursive(dir) {
175
234
  try {
176
235
  const stat = statSync(fullPath);
177
236
  if (stat.isDirectory()) {
178
- results.push(...getFilesRecursive(fullPath));
237
+ results.push(...getFilesRecursive(fullPath, config));
179
238
  } else if (stat.isFile() && CODE_EXTENSIONS.has(extname(fullPath))) {
180
239
  results.push(fullPath);
181
240
  }
@@ -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.11",
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": {