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.
- package/cli/commands/score.mjs +58 -16
- package/cli/docguard.mjs +9 -0
- package/cli/shared-ignore.mjs +76 -0
- package/cli/validators/architecture.mjs +21 -6
- package/cli/validators/docs-diff.mjs +79 -12
- package/cli/validators/security.mjs +49 -1
- package/cli/validators/todo-tracking.mjs +40 -14
- package/package.json +1 -1
package/cli/commands/score.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 +=
|
|
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 +=
|
|
562
|
+
score += 15;
|
|
544
563
|
const content = readFileSync(gitignorePath, 'utf-8');
|
|
545
|
-
if (content.includes('.env')) score +=
|
|
564
|
+
if (content.includes('.env')) score += 15;
|
|
546
565
|
}
|
|
547
566
|
|
|
548
|
-
// No .env file committed (
|
|
549
|
-
if (!existsSync(resolve(dir, '.env')) || existsSync(gitignorePath)) score +=
|
|
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 +=
|
|
572
|
+
score += 10; // Full marks — project doesn't need env vars
|
|
554
573
|
} else if (existsSync(resolve(dir, '.env.example'))) {
|
|
555
|
-
score +=
|
|
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:
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
doc.content
|
|
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
|
-
|
|
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