docguard-cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Metrics Consistency Validator — Detects stale hardcoded numbers in docs.
3
+ *
4
+ * Scans all .md files for patterns like "N checks", "N validators", "N tests"
5
+ * and compares against actual values from guard results and package.json.
6
+ * Returns warnings for mismatches.
7
+ */
8
+
9
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
+ import { resolve, join, relative } from 'node:path';
11
+ import { loadIgnorePatterns } from '../shared.mjs';
12
+
13
+ const IGNORE_DIRS = new Set([
14
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
15
+ '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
16
+ ]);
17
+
18
+ /**
19
+ * Validate metrics consistency across documentation.
20
+ * @param {string} projectDir - Project root directory
21
+ * @param {object} config - DocGuard config
22
+ * @param {object} [guardResults] - Results from runGuardInternal (optional)
23
+ * @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
24
+ */
25
+ export function validateMetricsConsistency(projectDir, config, guardResults) {
26
+ const warnings = [];
27
+ let passed = 0;
28
+ let total = 0;
29
+
30
+ // ── Collect actual metrics ──
31
+ const actuals = {};
32
+
33
+ // Guard check count (from guard results if available)
34
+ if (guardResults && Array.isArray(guardResults)) {
35
+ const totalChecks = guardResults.reduce((sum, r) => {
36
+ if (r.status === 'skipped') return sum;
37
+ return sum + (r.total || 0);
38
+ }, 0);
39
+ const validatorCount = guardResults.filter(r => r.status !== 'skipped').length;
40
+
41
+ actuals.checks = totalChecks;
42
+ actuals.validators = validatorCount;
43
+ }
44
+
45
+ // Test count — count test files on disk
46
+ const testFiles = findTestFiles(projectDir);
47
+ if (testFiles.length > 0) {
48
+ actuals.tests = testFiles.length;
49
+ }
50
+
51
+ // If no actuals to compare, skip
52
+ if (Object.keys(actuals).length === 0) {
53
+ return { errors: [], warnings, passed: 0, total: 0 };
54
+ }
55
+
56
+ // ── Scan markdown files for hardcoded numbers ──
57
+ const isIgnored = loadIgnorePatterns(projectDir);
58
+ const mdFiles = findMarkdownFiles(projectDir);
59
+ // Patterns must match standalone number references, not ratio-style "8/8 checks"
60
+ const patterns = [
61
+ { key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks' },
62
+ { key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators' },
63
+ ];
64
+
65
+ for (const mdFile of mdFiles) {
66
+ const relPath = relative(projectDir, mdFile);
67
+ // Skip changelog (historical numbers are fine by definition)
68
+ if (relPath.toLowerCase().includes('changelog')) continue;
69
+ // Skip files matched by .docguardignore
70
+ if (isIgnored(relPath)) continue;
71
+
72
+ let content;
73
+ try { content = readFileSync(mdFile, 'utf-8'); } catch { continue; }
74
+
75
+ for (const { key, regex, label } of patterns) {
76
+ if (actuals[key] === undefined) continue;
77
+
78
+ regex.lastIndex = 0;
79
+ let match;
80
+ while ((match = regex.exec(content)) !== null) {
81
+ total++;
82
+ const found = parseInt(match[1], 10);
83
+ if (found !== actuals[key] && found > 0) {
84
+ warnings.push(
85
+ `${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Update the doc or run \`docguard generate --force\``
86
+ );
87
+ } else {
88
+ passed++;
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ return { errors: [], warnings, passed, total };
95
+ }
96
+
97
+ // ── Helpers ──────────────────────────────────────────────────────────────────
98
+
99
+ function findTestFiles(dir) {
100
+ const tests = [];
101
+ const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
102
+
103
+ // Top-level test dirs
104
+ for (const td of testDirs) {
105
+ const fullDir = resolve(dir, td);
106
+ if (existsSync(fullDir)) {
107
+ walkFiles(fullDir, (f) => {
108
+ if (/\.(test|spec)\.[^.]+$/.test(f)) tests.push(f);
109
+ });
110
+ }
111
+ }
112
+
113
+ // Co-located tests in src/
114
+ const srcDir = resolve(dir, 'src');
115
+ if (existsSync(srcDir)) {
116
+ walkFiles(srcDir, (f) => {
117
+ if (/\.(test|spec)\.[^.]+$/.test(f) || f.includes('__tests__')) {
118
+ if (!tests.includes(f)) tests.push(f);
119
+ }
120
+ });
121
+ }
122
+
123
+ return tests;
124
+ }
125
+
126
+ function findMarkdownFiles(dir) {
127
+ const seen = new Set();
128
+ const mdFiles = [];
129
+ // Check root, docs-canonical, and extensions
130
+ const searchDirs = [
131
+ dir,
132
+ resolve(dir, 'docs-canonical'),
133
+ resolve(dir, 'extensions'),
134
+ ];
135
+
136
+ for (const searchDir of searchDirs) {
137
+ if (!existsSync(searchDir)) continue;
138
+ walkFiles(searchDir, (f) => {
139
+ if (f.endsWith('.md') && !seen.has(f)) {
140
+ seen.add(f);
141
+ mdFiles.push(f);
142
+ }
143
+ });
144
+ }
145
+
146
+ return mdFiles;
147
+ }
148
+
149
+ function walkFiles(dir, callback) {
150
+ if (!existsSync(dir)) return;
151
+ let entries;
152
+ try { entries = readdirSync(dir); } catch { return; }
153
+
154
+ for (const entry of entries) {
155
+ if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
156
+ const fullPath = join(dir, entry);
157
+ try {
158
+ const stat = statSync(fullPath);
159
+ if (stat.isDirectory()) {
160
+ walkFiles(fullPath, callback);
161
+ } else if (stat.isFile()) {
162
+ callback(fullPath);
163
+ }
164
+ } catch { /* skip */ }
165
+ }
166
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Schema Sync Validator — Ensures database schemas are documented in DATA-MODEL.md
3
+ *
4
+ * Detects schema definition files from popular ORMs/frameworks and validates
5
+ * that table/model names appear in DATA-MODEL.md documentation.
6
+ *
7
+ * Supported: Prisma, Drizzle, Sequelize, TypeORM, Knex, Django, Rails
8
+ *
9
+ * Zero dependencies — pure Node.js built-ins only.
10
+ */
11
+
12
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
13
+ import { resolve, join, relative, extname, basename } from 'node:path';
14
+
15
+ const IGNORE_DIRS = new Set([
16
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
17
+ '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
18
+ '.amplify-hosting', '.serverless',
19
+ ]);
20
+
21
+ /**
22
+ * Schema detection configurations for each supported framework.
23
+ * Each entry has a file pattern to detect and a regex to extract model/table names.
24
+ */
25
+ const SCHEMA_DETECTORS = [
26
+ {
27
+ name: 'Prisma',
28
+ filePattern: /schema\.prisma$/,
29
+ searchDirs: ['prisma'],
30
+ // Matches: model User { ... }
31
+ modelPattern: /^\s*model\s+(\w+)\s*\{/gm,
32
+ },
33
+ {
34
+ name: 'Drizzle',
35
+ filePattern: /\.(ts|js|mjs)$/,
36
+ searchDirs: ['drizzle', 'src/db', 'src/schema', 'db'],
37
+ // Matches: export const users = pgTable('users', ...) or mysqlTable, sqliteTable
38
+ modelPattern: /(?:pg|mysql|sqlite)Table\s*\(\s*['"](\w+)['"]/g,
39
+ },
40
+ {
41
+ name: 'TypeORM',
42
+ filePattern: /\.entity\.(ts|js)$/,
43
+ searchDirs: ['src/entities', 'src/entity', 'entities'],
44
+ // Matches: @Entity('users') or @Entity() class User
45
+ modelPattern: /@Entity\s*\(\s*(?:['"](\w+)['"])?\s*\)\s*(?:export\s+)?class\s+(\w+)/g,
46
+ },
47
+ {
48
+ name: 'Sequelize',
49
+ filePattern: /\.(ts|js)$/,
50
+ searchDirs: ['models', 'src/models'],
51
+ // Matches: sequelize.define('User', ...) or Model.init(...)
52
+ modelPattern: /(?:sequelize\.define|\.init)\s*\(\s*['"](\w+)['"]/g,
53
+ },
54
+ {
55
+ name: 'Knex',
56
+ filePattern: /\.(ts|js)$/,
57
+ searchDirs: ['migrations', 'db/migrations'],
58
+ // Matches: knex.schema.createTable('users', ...)
59
+ modelPattern: /createTable\s*\(\s*['"](\w+)['"]/g,
60
+ },
61
+ {
62
+ name: 'Django',
63
+ filePattern: /models\.py$/,
64
+ searchDirs: ['', 'app', 'apps'],
65
+ // Matches: class User(models.Model):
66
+ modelPattern: /class\s+(\w+)\s*\(\s*(?:models\.)?Model\s*\)/g,
67
+ },
68
+ {
69
+ name: 'Rails',
70
+ filePattern: /\d+_\w+\.rb$/,
71
+ searchDirs: ['db/migrate'],
72
+ // Matches: create_table :users do
73
+ modelPattern: /create_table\s+:(\w+)/g,
74
+ },
75
+ ];
76
+
77
+ /**
78
+ * Main validator entry point.
79
+ */
80
+ export function validateSchemaSync(projectDir, config) {
81
+ const results = { errors: [], warnings: [], passed: 0, total: 0 };
82
+
83
+ // Check if DATA-MODEL.md exists
84
+ const dataModelPath = resolve(projectDir, 'docs-canonical', 'DATA-MODEL.md');
85
+ if (!existsSync(dataModelPath)) {
86
+ // No DATA-MODEL.md — nothing to sync against
87
+ // Only warn if we detect schema files
88
+ const detectedModels = detectAllModels(projectDir);
89
+ if (detectedModels.length > 0) {
90
+ results.total++;
91
+ results.warnings.push(
92
+ `Found ${detectedModels.length} database model(s) (${detectedModels.map(m => m.name).slice(0, 5).join(', ')}${detectedModels.length > 5 ? '...' : ''}) ` +
93
+ `but no DATA-MODEL.md exists. Run \`docguard init\` to create one, then document your schema`
94
+ );
95
+ }
96
+ return results;
97
+ }
98
+
99
+ const dataModelContent = readFileSync(dataModelPath, 'utf-8').toLowerCase();
100
+
101
+ // Detect all models/tables across schemas
102
+ const detectedModels = detectAllModels(projectDir);
103
+
104
+ if (detectedModels.length === 0) {
105
+ // No schema files found — silently pass
106
+ return results;
107
+ }
108
+
109
+ // Check each model appears in DATA-MODEL.md
110
+ for (const model of detectedModels) {
111
+ results.total++;
112
+
113
+ // Check if model name appears in DATA-MODEL.md (case-insensitive)
114
+ const modelLower = model.name.toLowerCase();
115
+ // Check both singular and pluralized forms
116
+ const found =
117
+ dataModelContent.includes(modelLower) ||
118
+ dataModelContent.includes(modelLower + 's') ||
119
+ (modelLower.endsWith('s') && dataModelContent.includes(modelLower.slice(0, -1)));
120
+
121
+ if (found) {
122
+ results.passed++;
123
+ } else {
124
+ results.warnings.push(
125
+ `${model.framework} model "${model.name}" (${model.file}) not documented in DATA-MODEL.md. ` +
126
+ `Add it to the Entity Definitions section`
127
+ );
128
+ }
129
+ }
130
+
131
+ return results;
132
+ }
133
+
134
+ // ──── Model Detection ──────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Detect all database models/tables across all supported frameworks.
138
+ */
139
+ function detectAllModels(projectDir) {
140
+ const models = [];
141
+
142
+ for (const detector of SCHEMA_DETECTORS) {
143
+ const files = findSchemaFiles(projectDir, detector);
144
+
145
+ for (const filePath of files) {
146
+ let content;
147
+ try { content = readFileSync(filePath, 'utf-8'); } catch { continue; }
148
+
149
+ const relPath = relative(projectDir, filePath);
150
+
151
+ // Reset regex and extract model names
152
+ detector.modelPattern.lastIndex = 0;
153
+ let match;
154
+ while ((match = detector.modelPattern.exec(content)) !== null) {
155
+ // Some patterns have the name in group 1 or 2
156
+ const name = match[1] || match[2];
157
+ if (name && !isCommonUtilityModel(name)) {
158
+ models.push({
159
+ name,
160
+ framework: detector.name,
161
+ file: relPath,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ return models;
169
+ }
170
+
171
+ /**
172
+ * Find schema files for a given detector configuration.
173
+ */
174
+ function findSchemaFiles(projectDir, detector) {
175
+ const files = [];
176
+
177
+ for (const searchDir of detector.searchDirs) {
178
+ const dir = resolve(projectDir, searchDir);
179
+ if (!existsSync(dir)) continue;
180
+
181
+ scanSchemaDir(dir, detector.filePattern, files);
182
+ }
183
+
184
+ return files;
185
+ }
186
+
187
+ function scanSchemaDir(dir, filePattern, files) {
188
+ let entries;
189
+ try { entries = readdirSync(dir); } catch { return; }
190
+
191
+ for (const entry of entries) {
192
+ if (IGNORE_DIRS.has(entry)) continue;
193
+ if (entry.startsWith('.')) continue;
194
+
195
+ const full = join(dir, entry);
196
+ let stat;
197
+ try { stat = statSync(full); } catch { continue; }
198
+
199
+ if (stat.isDirectory()) {
200
+ scanSchemaDir(full, filePattern, files);
201
+ } else if (filePattern.test(entry)) {
202
+ files.push(full);
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Filter out common utility models that don't need documentation.
209
+ */
210
+ function isCommonUtilityModel(name) {
211
+ const utilities = new Set([
212
+ 'migration', 'migrations', 'seed', 'seeds',
213
+ 'knex_migrations', 'knex_migrations_lock',
214
+ 'schema_migrations', 'ar_internal_metadata',
215
+ 'SequelizeMeta', 'typeorm_metadata',
216
+ '_prisma_migrations',
217
+ ]);
218
+ return utilities.has(name);
219
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * TODO/FIXME Tracking Validator — Ensures code annotations are documented
3
+ *
4
+ * Scans source files for TODO:, FIXME:, HACK:, XXX: annotations and checks
5
+ * if they are tracked in documentation (ROADMAP.md, CURRENT-STATE.md, etc.).
6
+ *
7
+ * Also detects skipped tests without explanation.
8
+ *
9
+ * Inspired by spec-kit-cleanup (github.com/dsrednicki/spec-kit-cleanup)
10
+ * which uses tiered issue classification for code hygiene.
11
+ *
12
+ * Zero dependencies — pure Node.js built-ins only.
13
+ */
14
+
15
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
+ import { resolve, join, relative, extname } from 'node:path';
17
+
18
+ const IGNORE_DIRS = new Set([
19
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
20
+ '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
21
+ '.amplify-hosting', '.serverless', 'Research',
22
+ ]);
23
+
24
+ const SOURCE_EXTENSIONS = new Set([
25
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
26
+ '.py', '.rb', '.go', '.rs', '.java', '.cs',
27
+ '.vue', '.svelte', '.astro',
28
+ ]);
29
+
30
+ const TEST_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
31
+
32
+ // ──── Patterns ────
33
+
34
+ const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX|TEMP(?!late|orar)|WORKAROUND)\s*[(:]/;
35
+ const TODO_EXTRACT = /\b(TODO|FIXME|HACK|XXX|TEMP(?!late|orar)|WORKAROUND)\s*[:(]?\s*(.+)/;
36
+
37
+ // Test skip patterns for common test frameworks
38
+ const SKIP_PATTERNS = [
39
+ /\btest\.skip\s*\(/,
40
+ /\bit\.skip\s*\(/,
41
+ /\bdescribe\.skip\s*\(/,
42
+ /\bxit\s*\(/,
43
+ /\bxdescribe\s*\(/,
44
+ /\bxtest\s*\(/,
45
+ /\.todo\s*\(/,
46
+ /\btest\.todo\s*\(/,
47
+ /\bit\.todo\s*\(/,
48
+ ];
49
+
50
+ // Skip explanation patterns (comments that justify the skip)
51
+ const SKIP_REASON_PATTERN = /\/\/\s*(REASON|SKIP|TODO|FIXME|NOTE|WHY)\s*:/i;
52
+
53
+ /**
54
+ * Main validator — checks for untracked TODOs and unexplained test skips.
55
+ */
56
+ export function validateTodoTracking(projectDir, config) {
57
+ const results = { errors: [], warnings: [], passed: 0, total: 0 };
58
+
59
+ // ── Part 1: Skipped Tests ──
60
+ const skipResults = checkSkippedTests(projectDir, config);
61
+ results.errors.push(...skipResults.errors);
62
+ results.warnings.push(...skipResults.warnings);
63
+ results.passed += skipResults.passed;
64
+ results.total += skipResults.total;
65
+
66
+ // ── Part 2: Untracked TODOs/FIXMEs ──
67
+ const todoResults = checkUntrackedTodos(projectDir, config);
68
+ results.errors.push(...todoResults.errors);
69
+ results.warnings.push(...todoResults.warnings);
70
+ results.passed += todoResults.passed;
71
+ results.total += todoResults.total;
72
+
73
+ return results;
74
+ }
75
+
76
+ // ──── Skipped Tests ────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Scan test files for skip/todo patterns without adjacent explanation comments.
80
+ */
81
+ function checkSkippedTests(projectDir) {
82
+ const errors = [];
83
+ const warnings = [];
84
+ let passed = 0;
85
+ let total = 0;
86
+
87
+ const testFiles = [];
88
+ findTestFiles(projectDir, projectDir, testFiles);
89
+
90
+ if (testFiles.length === 0) return { errors, warnings, passed, total };
91
+
92
+ // Check: "Project has test files" → pass
93
+ total++;
94
+ passed++;
95
+
96
+ let skippedWithoutReason = 0;
97
+ let skippedWithReason = 0;
98
+
99
+ for (const relPath of testFiles) {
100
+ const fullPath = resolve(projectDir, relPath);
101
+ let content;
102
+ try { content = readFileSync(fullPath, 'utf-8'); } catch { continue; }
103
+
104
+ const lines = content.split('\n');
105
+
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const line = lines[i];
108
+
109
+ // Check if this line has a test skip pattern
110
+ const isSkipped = SKIP_PATTERNS.some(p => p.test(line));
111
+ if (!isSkipped) continue;
112
+
113
+ // Check surrounding lines (1 above, 1 below, and inline) for explanation
114
+ const prevLine = i > 0 ? lines[i - 1] : '';
115
+ const nextLine = i < lines.length - 1 ? lines[i + 1] : '';
116
+
117
+ const hasReason =
118
+ SKIP_REASON_PATTERN.test(prevLine) ||
119
+ SKIP_REASON_PATTERN.test(line) ||
120
+ SKIP_REASON_PATTERN.test(nextLine);
121
+
122
+ if (hasReason) {
123
+ skippedWithReason++;
124
+ } else {
125
+ skippedWithoutReason++;
126
+ warnings.push(
127
+ `Skipped test without explanation at ${relPath}:${i + 1}. ` +
128
+ `Add a // REASON: comment explaining why the test is skipped`
129
+ );
130
+ }
131
+ }
132
+ }
133
+
134
+ // Check: "All skipped tests have explanations"
135
+ if (skippedWithoutReason > 0 || skippedWithReason > 0) {
136
+ total++;
137
+ if (skippedWithoutReason === 0) {
138
+ passed++;
139
+ }
140
+ }
141
+
142
+ return { errors, warnings, passed, total };
143
+ }
144
+
145
+ // ──── Untracked TODOs ──────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Scan source files for TODO/FIXME annotations and check if they appear
149
+ * in tracking documentation.
150
+ */
151
+ function checkUntrackedTodos(projectDir, config) {
152
+ const errors = [];
153
+ const warnings = [];
154
+ let passed = 0;
155
+ let total = 0;
156
+
157
+ // Collect all TODO/FIXME items from source
158
+ const todos = [];
159
+ findTodos(projectDir, projectDir, todos);
160
+
161
+ if (todos.length === 0) {
162
+ // No TODOs found — that's clean code
163
+ total++;
164
+ passed++;
165
+ return { errors, warnings, passed, total };
166
+ }
167
+
168
+ // Check if TODOs are tracked in documentation
169
+ const trackingContent = loadTrackingDocs(projectDir, config);
170
+
171
+ total++;
172
+ let untrackedCount = 0;
173
+
174
+ for (const todo of todos) {
175
+ // Check if the TODO text appears somewhere in tracking docs
176
+ const isTracked = trackingContent.some(doc =>
177
+ doc.content.includes(todo.keyword) ||
178
+ doc.content.toLowerCase().includes(todo.text.toLowerCase().trim().substring(0, 30))
179
+ );
180
+
181
+ if (!isTracked) {
182
+ untrackedCount++;
183
+ // Only report first 5 to avoid noise
184
+ if (untrackedCount <= 5) {
185
+ warnings.push(
186
+ `Untracked ${todo.keyword} at ${todo.file}:${todo.line}: "${todo.text.substring(0, 60)}". ` +
187
+ `Add to ROADMAP.md, CURRENT-STATE.md, or a GitHub issue`
188
+ );
189
+ }
190
+ }
191
+ }
192
+
193
+ if (untrackedCount > 5) {
194
+ warnings.push(`...and ${untrackedCount - 5} more untracked TODO/FIXME items`);
195
+ }
196
+
197
+ if (untrackedCount === 0) {
198
+ passed++;
199
+ }
200
+
201
+ return { errors, warnings, passed, total };
202
+ }
203
+
204
+ /**
205
+ * Load doc files where TODOs should be tracked.
206
+ */
207
+ function loadTrackingDocs(projectDir, config) {
208
+ const docs = [];
209
+ const trackingFiles = [
210
+ 'ROADMAP.md', 'CURRENT-STATE.md', 'TODO.md', 'BACKLOG.md',
211
+ 'docs-canonical/ARCHITECTURE.md', 'CHANGELOG.md',
212
+ ...(config.todoTracking?.trackingFiles || []),
213
+ ];
214
+
215
+ for (const file of trackingFiles) {
216
+ const fullPath = resolve(projectDir, file);
217
+ if (existsSync(fullPath)) {
218
+ try {
219
+ docs.push({ file, content: readFileSync(fullPath, 'utf-8') });
220
+ } catch { /* ignore */ }
221
+ }
222
+ }
223
+
224
+ return docs;
225
+ }
226
+
227
+ // ──── File Scanners ────────────────────────────────────────────────────────
228
+
229
+ function findTestFiles(rootDir, dir, files) {
230
+ let entries;
231
+ try { entries = readdirSync(dir); } catch { return; }
232
+
233
+ for (const entry of entries) {
234
+ if (IGNORE_DIRS.has(entry)) continue;
235
+ if (entry.startsWith('.')) continue;
236
+
237
+ const full = join(dir, entry);
238
+ let stat;
239
+ try { stat = statSync(full); } catch { continue; }
240
+
241
+ if (stat.isDirectory()) {
242
+ findTestFiles(rootDir, full, files);
243
+ } else {
244
+ const ext = extname(entry).toLowerCase();
245
+ if (!TEST_EXTENSIONS.has(ext)) continue;
246
+
247
+ // Match test file patterns
248
+ if (/\.(test|spec)\.(mjs|cjs|[jt]sx?)$/.test(entry) ||
249
+ /__(tests|test)__/.test(relative(rootDir, full))) {
250
+ files.push(relative(rootDir, full));
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ function findTodos(rootDir, dir, todos) {
257
+ let entries;
258
+ try { entries = readdirSync(dir); } catch { return; }
259
+
260
+ for (const entry of entries) {
261
+ if (IGNORE_DIRS.has(entry)) continue;
262
+ if (entry.startsWith('.')) continue;
263
+
264
+ const full = join(dir, entry);
265
+ let stat;
266
+ try { stat = statSync(full); } catch { continue; }
267
+
268
+ if (stat.isDirectory()) {
269
+ findTodos(rootDir, full, todos);
270
+ } else {
271
+ const ext = extname(entry).toLowerCase();
272
+ if (!SOURCE_EXTENSIONS.has(ext)) continue;
273
+
274
+ let content;
275
+ try { content = readFileSync(full, 'utf-8'); } catch { continue; }
276
+
277
+ const lines = content.split('\n');
278
+ const relPath = relative(rootDir, full);
279
+
280
+ for (let i = 0; i < lines.length; i++) {
281
+ if (TODO_PATTERN.test(lines[i])) {
282
+ const match = lines[i].match(TODO_EXTRACT);
283
+ if (match) {
284
+ todos.push({
285
+ keyword: match[1].toUpperCase(),
286
+ text: match[2].trim(),
287
+ file: relPath,
288
+ line: i + 1,
289
+ });
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }