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.
- package/cli/commands/score.mjs +82 -18
- package/cli/docguard.mjs +9 -0
- package/cli/shared-ignore.mjs +119 -0
- package/cli/validators/architecture.mjs +21 -6
- package/cli/validators/docs-diff.mjs +71 -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;
|
|
@@ -476,8 +494,30 @@ function calcTestingScore(dir, config) {
|
|
|
476
494
|
}
|
|
477
495
|
|
|
478
496
|
// ── Check 4: CI test step (15 pts) ──
|
|
479
|
-
|
|
480
|
-
const
|
|
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
|
-
|
|
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 +=
|
|
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 +=
|
|
584
|
+
score += 15;
|
|
544
585
|
const content = readFileSync(gitignorePath, 'utf-8');
|
|
545
|
-
if (content.includes('.env')) score +=
|
|
586
|
+
if (content.includes('.env')) score += 15;
|
|
546
587
|
}
|
|
547
588
|
|
|
548
|
-
// No .env file committed (
|
|
549
|
-
if (!existsSync(resolve(dir, '.env')) || existsSync(gitignorePath)) score +=
|
|
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 +=
|
|
594
|
+
score += 10; // Full marks — project doesn't need env vars
|
|
554
595
|
} else if (existsSync(resolve(dir, '.env.example'))) {
|
|
555
|
-
score +=
|
|
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:
|
|
672
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
for (const f of
|
|
153
|
-
codeTests.add(f
|
|
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
|
-
|
|
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
|
|
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