docguard-cli 0.8.2 → 0.9.1
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/README.md +13 -6
- package/cli/commands/guard.mjs +8 -0
- package/cli/commands/llms.mjs +159 -0
- package/cli/commands/score.mjs +259 -11
- package/cli/docguard.mjs +6 -0
- package/cli/scanners/speckit.mjs +234 -0
- package/cli/shared.mjs +35 -0
- package/cli/validators/doc-quality.mjs +629 -0
- package/cli/validators/docs-sync.mjs +53 -0
- package/cli/validators/schema-sync.mjs +219 -0
- package/cli/validators/test-spec.mjs +51 -4
- package/cli/validators/todo-tracking.mjs +295 -0
- package/cli/validators/traceability.mjs +194 -8
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Now respects projectTypeConfig (e.g., skip E2E for CLI tools)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
8
|
|
|
9
9
|
export function validateTestSpec(projectDir, config) {
|
|
@@ -130,19 +130,66 @@ export function validateTestSpec(projectDir, config) {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
// If no test spec entries parsed, check if
|
|
133
|
+
// If no test spec entries parsed, check if tests exist anywhere
|
|
134
134
|
if (results.total === 0) {
|
|
135
135
|
results.total = 1;
|
|
136
|
+
|
|
137
|
+
// 1. Check top-level test dirs
|
|
136
138
|
const commonTestDirs = ['tests', 'test', '__tests__', 'spec'];
|
|
137
139
|
const hasTestDir = commonTestDirs.some(d =>
|
|
138
140
|
existsSync(resolve(projectDir, d))
|
|
139
141
|
);
|
|
140
|
-
|
|
142
|
+
|
|
143
|
+
// 2. Check co-located tests (src/**/__tests__/, src/**/*.test.*)
|
|
144
|
+
let hasColocated = false;
|
|
145
|
+
if (!hasTestDir) {
|
|
146
|
+
const sourceRoots = ['src', 'app', 'lib', 'packages'];
|
|
147
|
+
for (const root of sourceRoots) {
|
|
148
|
+
const rootPath = resolve(projectDir, root);
|
|
149
|
+
if (existsSync(rootPath) && hasTestFilesRecursive(rootPath)) {
|
|
150
|
+
hasColocated = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 3. Check vitest/jest config for custom patterns
|
|
157
|
+
let hasConfigTests = false;
|
|
158
|
+
if (!hasTestDir && !hasColocated) {
|
|
159
|
+
const configs = ['vitest.config.ts', 'vitest.config.js', 'jest.config.ts', 'jest.config.js'];
|
|
160
|
+
hasConfigTests = configs.some(f => existsSync(resolve(projectDir, f)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (hasTestDir || hasColocated || hasConfigTests) {
|
|
141
164
|
results.passed = 1;
|
|
142
165
|
} else {
|
|
143
|
-
results.warnings.push(
|
|
166
|
+
results.warnings.push(
|
|
167
|
+
'No test directory or co-located test files found. ' +
|
|
168
|
+
'Expected: tests/, src/**/__tests__/, or src/**/*.test.* files'
|
|
169
|
+
);
|
|
144
170
|
}
|
|
145
171
|
}
|
|
146
172
|
|
|
147
173
|
return results;
|
|
148
174
|
}
|
|
175
|
+
|
|
176
|
+
/** Recursively check if a directory contains test files */
|
|
177
|
+
function hasTestFilesRecursive(dir) {
|
|
178
|
+
const ignore = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
|
|
179
|
+
let entries;
|
|
180
|
+
try { entries = readdirSync(dir); } catch { return false; }
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (ignore.has(entry) || entry.startsWith('.')) continue;
|
|
183
|
+
const full = resolve(dir, entry);
|
|
184
|
+
try {
|
|
185
|
+
const s = statSync(full);
|
|
186
|
+
if (s.isDirectory()) {
|
|
187
|
+
if (entry === '__tests__' || entry === '__test__') return true;
|
|
188
|
+
if (hasTestFilesRecursive(full)) return true;
|
|
189
|
+
} else if (/\.(test|spec)\.[^.]+$/.test(entry)) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
} catch { continue; }
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
@@ -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
|
+
}
|