deepflow 0.1.74 → 0.1.76
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 +8 -6
- package/hooks/df-invariant-check.js +1107 -0
- package/hooks/df-spec-lint.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
```
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
|
|
3
|
+
██████╗ ███████╗ ███████╗ ██████╗ ███████╗ ██╗ ██████╗ ██╗ ██╗
|
|
4
|
+
██╔══██╗ ██╔════╝ ██╔════╝ ██╔══██╗ ██╔════╝ ██║ ██╔═══██╗ ██║ ██║
|
|
5
|
+
██║ ██║ █████╗ █████╗ ██████╔╝ █████╗ ██║ ██║ ██║ ██║ █╗ ██║
|
|
6
|
+
██║ ██║ ██╔══╝ ██╔══╝ ██╔═══╝ ██╔══╝ ██║ ██║ ██║ ██║███╗██║
|
|
7
|
+
██████╔╝ ███████╗ ███████╗ ██║ ██║ ███████╗ ╚██████╔╝ ╚███╔███╔╝
|
|
8
|
+
╚═════╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝
|
|
9
|
+
|
|
8
10
|
```
|
|
9
11
|
|
|
10
12
|
<p align="center">
|
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deepflow invariant checker
|
|
4
|
+
* Checks implementation diffs against spec invariants.
|
|
5
|
+
*
|
|
6
|
+
* Usage (CLI): node df-invariant-check.js --invariants <spec-file.md> <diff-file>
|
|
7
|
+
* Usage (module): const { checkInvariants } = require('./df-invariant-check');
|
|
8
|
+
*
|
|
9
|
+
* REQ-6: CLI mode — parse args, read files, exit non-zero on hard failures
|
|
10
|
+
* REQ-7: Output format — `${file}:${line}: [${TAG}] ${description}`, capped at 15 lines
|
|
11
|
+
* REQ-9: Auto-mode escalation — advisory items promoted to hard when mode === 'auto'
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { execSync } = require('child_process');
|
|
19
|
+
const { extractSection } = require('./df-spec-lint');
|
|
20
|
+
|
|
21
|
+
// ── LSP availability check (REQ-5, AC-11) ────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Language server detection rules.
|
|
25
|
+
* Each entry maps a set of indicator files/patterns to a language server binary
|
|
26
|
+
* and its install instructions.
|
|
27
|
+
*/
|
|
28
|
+
const LSP_DETECTION_RULES = [
|
|
29
|
+
{
|
|
30
|
+
indicators: ['tsconfig.json'],
|
|
31
|
+
fileExtensions: ['.ts', '.tsx'],
|
|
32
|
+
binary: 'typescript-language-server',
|
|
33
|
+
installCmd: 'npm install -g typescript-language-server',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
indicators: ['jsconfig.json', 'package.json'],
|
|
37
|
+
fileExtensions: ['.js', '.jsx', '.mjs', '.cjs'],
|
|
38
|
+
binary: 'typescript-language-server',
|
|
39
|
+
installCmd: 'npm install -g typescript-language-server',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
indicators: ['pyrightconfig.json'],
|
|
43
|
+
fileExtensions: ['.py'],
|
|
44
|
+
binary: 'pyright',
|
|
45
|
+
installCmd: 'npm install -g pyright',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
indicators: ['Cargo.toml'],
|
|
49
|
+
fileExtensions: ['.rs'],
|
|
50
|
+
binary: 'rust-analyzer',
|
|
51
|
+
installCmd: 'rustup component add rust-analyzer',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
indicators: ['go.mod'],
|
|
55
|
+
fileExtensions: ['.go'],
|
|
56
|
+
binary: 'gopls',
|
|
57
|
+
installCmd: 'go install golang.org/x/tools/gopls@latest',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Detect the appropriate language server for the given project root.
|
|
63
|
+
* Checks for indicator files first, then falls back to file extensions in the diff.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} projectRoot - Absolute path to the project root directory
|
|
66
|
+
* @param {string[]} diffFilePaths - List of file paths from the diff
|
|
67
|
+
* @returns {{ binary: string, installCmd: string } | null}
|
|
68
|
+
*/
|
|
69
|
+
function detectLanguageServer(projectRoot, diffFilePaths) {
|
|
70
|
+
for (const rule of LSP_DETECTION_RULES) {
|
|
71
|
+
// Check for indicator files in the project root
|
|
72
|
+
for (const indicator of rule.indicators) {
|
|
73
|
+
try {
|
|
74
|
+
fs.accessSync(path.join(projectRoot, indicator));
|
|
75
|
+
return { binary: rule.binary, installCmd: rule.installCmd };
|
|
76
|
+
} catch (_) {
|
|
77
|
+
// File not present, continue
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Check for matching file extensions in the diff
|
|
81
|
+
if (rule.fileExtensions && diffFilePaths.some((f) => rule.fileExtensions.some((ext) => f.endsWith(ext)))) {
|
|
82
|
+
return { binary: rule.binary, installCmd: rule.installCmd };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check whether a binary is available on the system PATH.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} binary - Binary name to check
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
function isBinaryAvailable(binary) {
|
|
95
|
+
try {
|
|
96
|
+
execSync(`which ${binary}`, { stdio: 'ignore' });
|
|
97
|
+
return true;
|
|
98
|
+
} catch (_) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check LSP availability for the project.
|
|
105
|
+
* Auto-detects the appropriate language server based on project files and the diff,
|
|
106
|
+
* then verifies the binary is present on PATH.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} projectRoot - Absolute path to the project root directory
|
|
109
|
+
* @param {string[]} diffFilePaths - List of file paths from the diff (used for extension-based detection)
|
|
110
|
+
* @returns {{ available: boolean, binary: string | null, installCmd: string | null, message: string | null }}
|
|
111
|
+
*/
|
|
112
|
+
function checkLspAvailability(projectRoot, diffFilePaths) {
|
|
113
|
+
const detected = detectLanguageServer(projectRoot, diffFilePaths);
|
|
114
|
+
|
|
115
|
+
if (!detected) {
|
|
116
|
+
// No language server detected for this project type — not a hard failure
|
|
117
|
+
return { available: true, binary: null, installCmd: null, message: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const available = isBinaryAvailable(detected.binary);
|
|
121
|
+
if (!available) {
|
|
122
|
+
return {
|
|
123
|
+
available: false,
|
|
124
|
+
binary: detected.binary,
|
|
125
|
+
installCmd: detected.installCmd,
|
|
126
|
+
message:
|
|
127
|
+
`LSP binary "${detected.binary}" not found on PATH. ` +
|
|
128
|
+
`Install it with: ${detected.installCmd}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { available: true, binary: detected.binary, installCmd: null, message: null };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Valid violation tags (REQ-7) ──────────────────────────────────────────────
|
|
136
|
+
const TAGS = {
|
|
137
|
+
MOCK: 'MOCK', // Production code contains mock/stub placeholders
|
|
138
|
+
MISSING_TEST: 'MISSING_TEST', // Changed code has no corresponding test coverage
|
|
139
|
+
HARDCODED: 'HARDCODED', // Hardcoded values that should be configurable
|
|
140
|
+
STUB: 'STUB', // Incomplete stub left in production code
|
|
141
|
+
PHANTOM: 'PHANTOM', // Reference to non-existent symbol/file/function
|
|
142
|
+
SCOPE_GAP: 'SCOPE_GAP', // Implementation goes beyond or falls short of spec scope
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ── Diff parsing ──────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse a unified diff string into a structured list of file changes.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} diff - Raw unified diff text
|
|
151
|
+
* @returns {Array<{ file: string, hunks: Array<{ startLine: number, lines: Array<{ lineNo: number, content: string }> }> }>}
|
|
152
|
+
*/
|
|
153
|
+
function parseDiff(diff) {
|
|
154
|
+
const files = [];
|
|
155
|
+
let currentFile = null;
|
|
156
|
+
let currentHunk = null;
|
|
157
|
+
let newLineNo = 0;
|
|
158
|
+
|
|
159
|
+
for (const line of diff.split('\n')) {
|
|
160
|
+
// New file header: "+++ b/path/to/file" or "+++ path/to/file"
|
|
161
|
+
if (line.startsWith('+++ ')) {
|
|
162
|
+
const filePath = line.slice(4).replace(/^[ab]\//, '');
|
|
163
|
+
currentFile = { file: filePath, hunks: [] };
|
|
164
|
+
files.push(currentFile);
|
|
165
|
+
currentHunk = null;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Skip "---" lines (old file header)
|
|
170
|
+
if (line.startsWith('--- ')) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Hunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
|
175
|
+
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
176
|
+
if (hunkMatch && currentFile) {
|
|
177
|
+
newLineNo = parseInt(hunkMatch[1], 10);
|
|
178
|
+
currentHunk = { startLine: newLineNo, lines: [] };
|
|
179
|
+
currentFile.hunks.push(currentHunk);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!currentHunk) continue;
|
|
184
|
+
|
|
185
|
+
if (line.startsWith('+')) {
|
|
186
|
+
// Added line
|
|
187
|
+
currentHunk.lines.push({ lineNo: newLineNo, content: line.slice(1) });
|
|
188
|
+
newLineNo++;
|
|
189
|
+
} else if (line.startsWith('-')) {
|
|
190
|
+
// Removed line — does not advance new-file line numbers
|
|
191
|
+
} else if (line.startsWith(' ')) {
|
|
192
|
+
// Context line
|
|
193
|
+
newLineNo++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return files;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Task-type helpers (REQ-8) ─────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Classify a file path as a test file or a production file.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} filePath
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
function isTestFile(filePath) {
|
|
209
|
+
return (
|
|
210
|
+
/\.(test|spec)\.[jt]sx?$/.test(filePath) ||
|
|
211
|
+
/^tests?\//.test(filePath) ||
|
|
212
|
+
/\/__tests__\//.test(filePath)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Placeholder check functions (T4-T8 will implement these) ─────────────────
|
|
217
|
+
//
|
|
218
|
+
// Each check function receives:
|
|
219
|
+
// - files: parsed diff (output of parseDiff())
|
|
220
|
+
// - specContent: raw spec markdown string
|
|
221
|
+
// - taskType: 'bootstrap' | 'spike' | 'implementation'
|
|
222
|
+
//
|
|
223
|
+
// Each function returns an array of violation objects:
|
|
224
|
+
// { file: string, line: number, tag: string, description: string }
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* T4 placeholder: Check for mock/stub markers left in production code.
|
|
228
|
+
* Looks for patterns like TODO, FIXME, console.log, mock(), stub() in added lines.
|
|
229
|
+
*
|
|
230
|
+
* @param {Array} files - Parsed diff files
|
|
231
|
+
* @param {string} specContent - Raw spec markdown
|
|
232
|
+
* @param {string} taskType - Task type
|
|
233
|
+
* @returns {Array<{ file: string, line: number, tag: string, description: string }>}
|
|
234
|
+
*/
|
|
235
|
+
function checkMocks(files, specContent, taskType) { // eslint-disable-line no-unused-vars
|
|
236
|
+
// REQ-8: taskType filtering
|
|
237
|
+
// bootstrap: skip mock detection entirely for test files
|
|
238
|
+
// spike: only check production files (skip test files)
|
|
239
|
+
// implementation: check all files
|
|
240
|
+
let filesToCheck = files;
|
|
241
|
+
if (taskType === 'bootstrap') {
|
|
242
|
+
filesToCheck = files.filter((f) => !isTestFile(f.file));
|
|
243
|
+
} else if (taskType === 'spike') {
|
|
244
|
+
filesToCheck = files.filter((f) => !isTestFile(f.file));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// REQ-1: Detect mock usage patterns in production (non-test) files
|
|
248
|
+
const MOCK_PATTERNS = [
|
|
249
|
+
/\bjest\.fn\s*\(/,
|
|
250
|
+
/\bvi\.fn\s*\(/,
|
|
251
|
+
/\bsinon\.stub\s*\(/,
|
|
252
|
+
/=\s*mock\s*\(/,
|
|
253
|
+
/\bjest\.mock\s*\(/,
|
|
254
|
+
/\bvi\.mock\s*\(/,
|
|
255
|
+
/\bsinon\.mock\s*\(/,
|
|
256
|
+
/\bjest\.spyOn\s*\(/,
|
|
257
|
+
/\bcreateMock\s*\(/,
|
|
258
|
+
/\bmockImplementation\s*\(/,
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
const violations = [];
|
|
262
|
+
|
|
263
|
+
for (const fileObj of filesToCheck) {
|
|
264
|
+
for (const hunk of fileObj.hunks) {
|
|
265
|
+
for (const addedLine of hunk.lines) {
|
|
266
|
+
for (const pattern of MOCK_PATTERNS) {
|
|
267
|
+
if (pattern.test(addedLine.content)) {
|
|
268
|
+
violations.push({
|
|
269
|
+
file: fileObj.file,
|
|
270
|
+
line: addedLine.lineNo,
|
|
271
|
+
tag: TAGS.MOCK,
|
|
272
|
+
description: `Mock pattern found: ${pattern}`,
|
|
273
|
+
});
|
|
274
|
+
break; // Only report one violation per line
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return violations;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Assertion patterns used to count meaningful assertions in test code.
|
|
286
|
+
* Covers Jest, Chai, Node assert, and other common assertion styles.
|
|
287
|
+
*/
|
|
288
|
+
const ASSERTION_PATTERNS = [
|
|
289
|
+
/\bexpect\s*\(/,
|
|
290
|
+
/\bassert\s*\(/,
|
|
291
|
+
/\bassert\./,
|
|
292
|
+
/\bassert_eq\s*\(/,
|
|
293
|
+
/\bassertEqual\s*\(/,
|
|
294
|
+
/\bassertThat\s*\(/,
|
|
295
|
+
/\bshould\./,
|
|
296
|
+
/\.should\b/,
|
|
297
|
+
/\.to\./,
|
|
298
|
+
/\.toBe\s*\(/,
|
|
299
|
+
/\.toEqual\s*\(/,
|
|
300
|
+
/\.toHave\w*\s*\(/,
|
|
301
|
+
/\.toContain\s*\(/,
|
|
302
|
+
/\.toThrow\s*\(/,
|
|
303
|
+
/\.toMatch\s*\(/,
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Count assertion calls in an array of source lines.
|
|
308
|
+
* Each line is checked against all ASSERTION_PATTERNS; a line may only count once.
|
|
309
|
+
*
|
|
310
|
+
* @param {string[]} lines - Source lines to scan
|
|
311
|
+
* @returns {number} Total number of lines containing at least one assertion
|
|
312
|
+
*/
|
|
313
|
+
function countAssertions(lines) {
|
|
314
|
+
let count = 0;
|
|
315
|
+
for (const line of lines) {
|
|
316
|
+
if (ASSERTION_PATTERNS.some((p) => p.test(line))) {
|
|
317
|
+
count++;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return count;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Extract the lines belonging to test blocks (describe/it/test) that mention a
|
|
325
|
+
* given REQ-N identifier from a flat array of source lines.
|
|
326
|
+
*
|
|
327
|
+
* Strategy: find every line that contains the reqId, then walk outward to capture
|
|
328
|
+
* the surrounding block delimited by matching braces. We keep a simple brace-depth
|
|
329
|
+
* counter so nested blocks are included.
|
|
330
|
+
*
|
|
331
|
+
* @param {string[]} lines - All source lines (added lines from test files)
|
|
332
|
+
* @param {string} reqId - e.g. "REQ-3"
|
|
333
|
+
* @returns {string[]} Lines that are part of blocks mentioning the reqId
|
|
334
|
+
*/
|
|
335
|
+
function extractReqTestBlockLines(lines, reqId) {
|
|
336
|
+
const result = [];
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < lines.length; i++) {
|
|
339
|
+
if (!lines[i].includes(reqId)) continue;
|
|
340
|
+
|
|
341
|
+
// Walk backwards to find the opening of the nearest enclosing describe/it/test block
|
|
342
|
+
let blockStart = i;
|
|
343
|
+
for (let j = i; j >= 0; j--) {
|
|
344
|
+
if (/\b(describe|it|test)\s*\(/.test(lines[j])) {
|
|
345
|
+
blockStart = j;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Walk forward from blockStart, tracking brace depth to find the end of the block
|
|
351
|
+
let depth = 0;
|
|
352
|
+
let started = false;
|
|
353
|
+
for (let k = blockStart; k < lines.length; k++) {
|
|
354
|
+
for (const ch of lines[k]) {
|
|
355
|
+
if (ch === '{') { depth++; started = true; }
|
|
356
|
+
else if (ch === '}') { depth--; }
|
|
357
|
+
}
|
|
358
|
+
result.push(lines[k]);
|
|
359
|
+
if (started && depth === 0) break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Check that every REQ-N identifier in the spec has at least one mention
|
|
368
|
+
* in the added lines of a test file in the diff (REQ-2), and that those test
|
|
369
|
+
* references include at least 2 assertion calls (AC-5).
|
|
370
|
+
*
|
|
371
|
+
* @param {Array} files - Parsed diff files
|
|
372
|
+
* @param {string} specContent - Raw spec markdown
|
|
373
|
+
* @param {string} taskType - Task type
|
|
374
|
+
* @returns {Array<{ file: string, line: number, tag: string, description: string }>}
|
|
375
|
+
*/
|
|
376
|
+
function checkMissingTests(files, specContent, taskType) {
|
|
377
|
+
// REQ-8: taskType filtering
|
|
378
|
+
// spike: skip entirely (spikes don't require test coverage)
|
|
379
|
+
// bootstrap: skip (bootstrapping doesn't need tests yet)
|
|
380
|
+
// implementation: enforce
|
|
381
|
+
if (taskType === 'spike' || taskType === 'bootstrap') {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const violations = [];
|
|
386
|
+
|
|
387
|
+
// Extract the Requirements section and collect all REQ-N identifiers
|
|
388
|
+
const reqSection = extractSection(specContent, 'Requirements');
|
|
389
|
+
if (!reqSection) return violations;
|
|
390
|
+
|
|
391
|
+
const reqPattern = /REQ-\d+[a-z]?/g;
|
|
392
|
+
const allReqIds = new Set(reqSection.match(reqPattern) || []);
|
|
393
|
+
if (allReqIds.size === 0) return violations;
|
|
394
|
+
|
|
395
|
+
// Identify test files in the diff
|
|
396
|
+
const isTestFilePath = (filePath) =>
|
|
397
|
+
/\.test\.js$/.test(filePath) ||
|
|
398
|
+
/\.spec\.js$/.test(filePath) ||
|
|
399
|
+
/(^|\/)test(s)?\//.test(filePath);
|
|
400
|
+
|
|
401
|
+
// Collect added lines (with file context) from test files in the diff
|
|
402
|
+
const testFiles = files.filter((f) => isTestFilePath(f.file));
|
|
403
|
+
const allTestLines = testFiles.flatMap((f) => f.hunks.flatMap((h) => h.lines.map((l) => l.content)));
|
|
404
|
+
const testFileContent = allTestLines.join('\n');
|
|
405
|
+
|
|
406
|
+
// For each REQ-N: check existence then assertion count
|
|
407
|
+
for (const reqId of allReqIds) {
|
|
408
|
+
if (!testFileContent.includes(reqId)) {
|
|
409
|
+
// Zero test references — existing behavior
|
|
410
|
+
violations.push({
|
|
411
|
+
file: 'spec',
|
|
412
|
+
line: 1,
|
|
413
|
+
tag: TAGS.MISSING_TEST,
|
|
414
|
+
description: `${reqId} has no test reference in diff`,
|
|
415
|
+
});
|
|
416
|
+
} else {
|
|
417
|
+
// Has at least one test reference — count assertions in the relevant test blocks
|
|
418
|
+
const blockLines = extractReqTestBlockLines(allTestLines, reqId);
|
|
419
|
+
const assertionCount = countAssertions(blockLines);
|
|
420
|
+
if (assertionCount < 2) {
|
|
421
|
+
violations.push({
|
|
422
|
+
file: 'spec',
|
|
423
|
+
line: 1,
|
|
424
|
+
tag: TAGS.MISSING_TEST,
|
|
425
|
+
description: `${reqId} has test reference but only ${assertionCount} assertion(s) (minimum 2 required)`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return violations;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Check for stub returns and TODO/FIXME/HACK markers left in production code.
|
|
436
|
+
* Detects patterns like `return null`, `return []`, `throw new Error('not implemented')`,
|
|
437
|
+
* and comment markers that indicate incomplete work.
|
|
438
|
+
*
|
|
439
|
+
* @param {Array} files - Parsed diff files
|
|
440
|
+
* @param {string} specContent - Raw spec markdown
|
|
441
|
+
* @param {string} taskType - Task type
|
|
442
|
+
* @returns {Array<{ file: string, line: number, tag: string, description: string }>}
|
|
443
|
+
*/
|
|
444
|
+
function checkStubsAndTodos(files, specContent, taskType) {
|
|
445
|
+
// REQ-8: taskType filtering
|
|
446
|
+
// spike: skip TODO/FIXME/HACK detection (spikes are exploratory)
|
|
447
|
+
// implementation: all checks enforced
|
|
448
|
+
const violations = [];
|
|
449
|
+
|
|
450
|
+
const stubReturnPattern = /\breturn\s+(null|undefined|\[\]|\{\})\s*;?\s*$/;
|
|
451
|
+
const notImplementedPattern = /throw\s+new\s+Error\s*\(\s*['"]not implemented['"]\s*\)/i;
|
|
452
|
+
const todoCommentPattern = /\/\/\s*(TODO|FIXME|HACK)\b/i;
|
|
453
|
+
|
|
454
|
+
for (const fileEntry of files) {
|
|
455
|
+
for (const hunk of fileEntry.hunks) {
|
|
456
|
+
for (const { lineNo, content } of hunk.lines) {
|
|
457
|
+
if (taskType !== 'spike' && todoCommentPattern.test(content)) {
|
|
458
|
+
violations.push({
|
|
459
|
+
file: fileEntry.file,
|
|
460
|
+
line: lineNo,
|
|
461
|
+
tag: TAGS.STUB,
|
|
462
|
+
description: `TODO/FIXME/HACK comment found: ${content.trim()}`,
|
|
463
|
+
});
|
|
464
|
+
} else if (notImplementedPattern.test(content)) {
|
|
465
|
+
violations.push({
|
|
466
|
+
file: fileEntry.file,
|
|
467
|
+
line: lineNo,
|
|
468
|
+
tag: TAGS.STUB,
|
|
469
|
+
description: `Stub return found: ${content.trim()}`,
|
|
470
|
+
});
|
|
471
|
+
} else if (stubReturnPattern.test(content)) {
|
|
472
|
+
violations.push({
|
|
473
|
+
file: fileEntry.file,
|
|
474
|
+
line: lineNo,
|
|
475
|
+
tag: TAGS.STUB,
|
|
476
|
+
description: `Stub return found: ${content.trim()}`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return violations;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* REQ-3: Check for hardcoded values that should be configurable.
|
|
488
|
+
* Parses spec for requirements containing "configurable" or "dynamic", then
|
|
489
|
+
* scans added diff lines for numeric/string literals assigned to module-level
|
|
490
|
+
* constants or returned directly.
|
|
491
|
+
*
|
|
492
|
+
* @param {Array} files - Parsed diff files
|
|
493
|
+
* @param {string} specContent - Raw spec markdown
|
|
494
|
+
* @param {string} taskType - Task type
|
|
495
|
+
* @returns {Array<{ file: string, line: number, tag: string, description: string }>}
|
|
496
|
+
*/
|
|
497
|
+
function checkHardcoded(files, specContent, taskType) { // eslint-disable-line no-unused-vars
|
|
498
|
+
const violations = [];
|
|
499
|
+
|
|
500
|
+
// Step 1: Parse spec Requirements section for REQ-Ns that mention
|
|
501
|
+
// "configurable" or "dynamic" (case-insensitive).
|
|
502
|
+
const reqSection = extractSection(specContent, 'Requirements');
|
|
503
|
+
if (!reqSection) return violations;
|
|
504
|
+
|
|
505
|
+
// Split into individual requirement lines/blocks. Each REQ-N block starts
|
|
506
|
+
// with a REQ-N identifier and may span multiple lines until the next REQ-N
|
|
507
|
+
// or end of section.
|
|
508
|
+
const reqBlockPattern = /\*\*?(REQ-\d+[a-z]?)\b.*?\*\*?[^]*?(?=\*\*?REQ-\d+|$)/gi;
|
|
509
|
+
const configurableReqs = new Set();
|
|
510
|
+
|
|
511
|
+
let m;
|
|
512
|
+
while ((m = reqBlockPattern.exec(reqSection)) !== null) {
|
|
513
|
+
const block = m[0];
|
|
514
|
+
if (/configurable|dynamic/i.test(block)) {
|
|
515
|
+
// Extract the REQ-N identifier from this block
|
|
516
|
+
const idMatch = block.match(/REQ-\d+[a-z]?/i);
|
|
517
|
+
if (idMatch) {
|
|
518
|
+
configurableReqs.add(idMatch[0]);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Fallback: also scan line-by-line for simpler markdown formats
|
|
524
|
+
for (const line of reqSection.split('\n')) {
|
|
525
|
+
if (/configurable|dynamic/i.test(line)) {
|
|
526
|
+
const idMatch = line.match(/REQ-\d+[a-z]?/i);
|
|
527
|
+
if (idMatch) {
|
|
528
|
+
configurableReqs.add(idMatch[0]);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// If no "configurable" or "dynamic" requirements exist in spec, nothing to check.
|
|
534
|
+
if (configurableReqs.size === 0) return violations;
|
|
535
|
+
|
|
536
|
+
// Step 2: Scan added diff lines for patterns indicating hardcoded literals
|
|
537
|
+
// in module-level constant assignments or direct returns.
|
|
538
|
+
//
|
|
539
|
+
// Patterns we consider "hardcoded":
|
|
540
|
+
// - Module-level const/let/var assignment with a numeric literal:
|
|
541
|
+
// const MAX_RETRIES = 42;
|
|
542
|
+
// const BASE_URL = "https://example.com";
|
|
543
|
+
// - Direct return of a literal (not trivially safe like 0, 1, -1, true, false, ""):
|
|
544
|
+
// return 3000;
|
|
545
|
+
// return "https://api.example.com";
|
|
546
|
+
//
|
|
547
|
+
// Safe literals that are excluded (common, non-configurable values):
|
|
548
|
+
// Numbers: 0, 1, -1, 2, 100, null
|
|
549
|
+
// Strings: '', "", true, false
|
|
550
|
+
|
|
551
|
+
const SAFE_NUMERIC_LITERALS = new Set(['0', '1', '-1', '2', '100', '-0']);
|
|
552
|
+
const SAFE_STRING_LITERALS = new Set(['""', "''", '``', '"true"', '"false"', "'true'", "'false'"]);
|
|
553
|
+
|
|
554
|
+
// Matches: const/let/var NAME = <literal>; (module-level, not indented heavily)
|
|
555
|
+
// We consider lines with <=2 leading spaces as module-level.
|
|
556
|
+
const MODULE_CONST_PATTERN =
|
|
557
|
+
/^[ \t]{0,2}(?:const|let|var)\s+[A-Z_][A-Z0-9_]*\s*=\s*(.+?)\s*;?\s*$/;
|
|
558
|
+
|
|
559
|
+
// Matches: return <literal>;
|
|
560
|
+
const RETURN_LITERAL_PATTERN =
|
|
561
|
+
/^\s*return\s+(.+?)\s*;?\s*$/;
|
|
562
|
+
|
|
563
|
+
// Numeric literal (integer or float, optional sign)
|
|
564
|
+
const NUMERIC_LITERAL_RE = /^-?\d+(\.\d+)?$/;
|
|
565
|
+
|
|
566
|
+
// String literal (single, double, or template quotes — no interpolation)
|
|
567
|
+
const STRING_LITERAL_RE = /^(['"`])[^'"`]*\1$/;
|
|
568
|
+
|
|
569
|
+
for (const fileObj of files) {
|
|
570
|
+
// Skip test files — hardcoded values in tests are expected
|
|
571
|
+
if (isTestFile(fileObj.file)) continue;
|
|
572
|
+
|
|
573
|
+
for (const hunk of fileObj.hunks) {
|
|
574
|
+
for (const { lineNo, content } of hunk.lines) {
|
|
575
|
+
let literal = null;
|
|
576
|
+
let context = null;
|
|
577
|
+
|
|
578
|
+
const constMatch = MODULE_CONST_PATTERN.exec(content);
|
|
579
|
+
if (constMatch) {
|
|
580
|
+
literal = constMatch[1].trim();
|
|
581
|
+
context = 'module-level constant';
|
|
582
|
+
} else {
|
|
583
|
+
const returnMatch = RETURN_LITERAL_PATTERN.exec(content);
|
|
584
|
+
if (returnMatch) {
|
|
585
|
+
literal = returnMatch[1].trim();
|
|
586
|
+
context = 'direct return';
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (!literal) continue;
|
|
591
|
+
|
|
592
|
+
// Determine if literal is a numeric or string literal
|
|
593
|
+
const isNumeric = NUMERIC_LITERAL_RE.test(literal);
|
|
594
|
+
const isString = STRING_LITERAL_RE.test(literal);
|
|
595
|
+
|
|
596
|
+
if (!isNumeric && !isString) continue;
|
|
597
|
+
|
|
598
|
+
// Skip safe/trivial literals
|
|
599
|
+
if (isNumeric && SAFE_NUMERIC_LITERALS.has(literal)) continue;
|
|
600
|
+
if (isString && SAFE_STRING_LITERALS.has(literal)) continue;
|
|
601
|
+
|
|
602
|
+
// This is a meaningful hardcoded literal in a file modified under
|
|
603
|
+
// a spec that requires configurability/dynamic behavior.
|
|
604
|
+
const reqList = [...configurableReqs].join(', ');
|
|
605
|
+
violations.push({
|
|
606
|
+
file: fileObj.file,
|
|
607
|
+
line: lineNo,
|
|
608
|
+
tag: TAGS.HARDCODED,
|
|
609
|
+
description: `Hardcoded literal ${literal} in ${context}; spec requires configurable/dynamic values (${reqList})`,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return violations;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* REQ-4c: Check for phantom imports — a file imports a module that is new in the
|
|
620
|
+
* diff and that new module exports only stub symbols (return null, return [], etc.).
|
|
621
|
+
*
|
|
622
|
+
* @param {Array} files - Parsed diff files
|
|
623
|
+
* @param {string} specContent - Raw spec markdown
|
|
624
|
+
* @param {string} taskType - Task type
|
|
625
|
+
* @returns {Array<{ file: string, line: number, tag: string, description: string }>}
|
|
626
|
+
*/
|
|
627
|
+
function checkPhantoms(files, specContent, taskType) { // eslint-disable-line no-unused-vars
|
|
628
|
+
const violations = [];
|
|
629
|
+
|
|
630
|
+
// Collect the set of new files introduced in this diff (files with added lines but
|
|
631
|
+
// recognisable as new — heuristic: first hunk starts at line 1).
|
|
632
|
+
const newFiles = new Set();
|
|
633
|
+
for (const fileObj of files) {
|
|
634
|
+
if (fileObj.hunks.length > 0 && fileObj.hunks[0].startLine === 1) {
|
|
635
|
+
newFiles.add(fileObj.file);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (newFiles.size === 0) return violations;
|
|
640
|
+
|
|
641
|
+
// For each new file, determine whether it exports only stub symbols.
|
|
642
|
+
// A stub export is one whose only added non-blank, non-comment body line is a
|
|
643
|
+
// stub return: `return null`, `return []`, `return {}`, `return undefined`,
|
|
644
|
+
// or a `throw new Error('not implemented')`.
|
|
645
|
+
const STUB_EXPORT_PATTERN =
|
|
646
|
+
/\breturn\s+(null|undefined|\[\]|\{\})\s*;?\s*$|\bthrow\s+new\s+Error\s*\(\s*['"]not implemented['"]\s*\)/i;
|
|
647
|
+
const EXPORT_DECL_PATTERN =
|
|
648
|
+
/\bmodule\.exports\b|\bexport\s+(default|function|const|let|var|class)\b/;
|
|
649
|
+
|
|
650
|
+
const stubOnlyFiles = new Set();
|
|
651
|
+
for (const newFile of newFiles) {
|
|
652
|
+
const fileObj = files.find((f) => f.file === newFile);
|
|
653
|
+
if (!fileObj) continue;
|
|
654
|
+
|
|
655
|
+
const addedLines = fileObj.hunks.flatMap((h) => h.lines.map((l) => l.content));
|
|
656
|
+
const hasExport = addedLines.some((l) => EXPORT_DECL_PATTERN.test(l));
|
|
657
|
+
if (!hasExport) continue; // not a module with explicit exports — skip
|
|
658
|
+
|
|
659
|
+
const substantiveLines = addedLines.filter(
|
|
660
|
+
(l) => l.trim() !== '' && !/^\s*\/\//.test(l) && !/^\s*\*/.test(l)
|
|
661
|
+
);
|
|
662
|
+
const allStubs = substantiveLines.every(
|
|
663
|
+
(l) => STUB_EXPORT_PATTERN.test(l) || EXPORT_DECL_PATTERN.test(l) ||
|
|
664
|
+
/^\s*[\{\}()\[\],;]/.test(l) || /^\s*(function|const|let|var|class|module)\b/.test(l)
|
|
665
|
+
);
|
|
666
|
+
if (allStubs) {
|
|
667
|
+
stubOnlyFiles.add(newFile);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (stubOnlyFiles.size === 0) return violations;
|
|
672
|
+
|
|
673
|
+
// Patterns to extract the imported path from require() or import … from '…'
|
|
674
|
+
const REQUIRE_PATTERN = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
675
|
+
const IMPORT_PATTERN = /^\s*import\s+.*?\s+from\s+['"]([^'"]+)['"]/;
|
|
676
|
+
|
|
677
|
+
for (const fileObj of files) {
|
|
678
|
+
for (const hunk of fileObj.hunks) {
|
|
679
|
+
for (const { lineNo, content } of hunk.lines) {
|
|
680
|
+
// Check require() calls
|
|
681
|
+
let m;
|
|
682
|
+
REQUIRE_PATTERN.lastIndex = 0;
|
|
683
|
+
while ((m = REQUIRE_PATTERN.exec(content)) !== null) {
|
|
684
|
+
const importedPath = m[1];
|
|
685
|
+
for (const stubFile of stubOnlyFiles) {
|
|
686
|
+
if (stubFile.endsWith(importedPath) || stubFile.endsWith(`${importedPath}.js`)) {
|
|
687
|
+
violations.push({
|
|
688
|
+
file: fileObj.file,
|
|
689
|
+
line: lineNo,
|
|
690
|
+
tag: TAGS.PHANTOM,
|
|
691
|
+
description: `Imports "${importedPath}" which is a new file with only stub exports`,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Check ES import statements
|
|
698
|
+
const im = IMPORT_PATTERN.exec(content);
|
|
699
|
+
if (im) {
|
|
700
|
+
const importedPath = im[1];
|
|
701
|
+
for (const stubFile of stubOnlyFiles) {
|
|
702
|
+
if (stubFile.endsWith(importedPath) || stubFile.endsWith(`${importedPath}.js`)) {
|
|
703
|
+
violations.push({
|
|
704
|
+
file: fileObj.file,
|
|
705
|
+
line: lineNo,
|
|
706
|
+
tag: TAGS.PHANTOM,
|
|
707
|
+
description: `Imports "${importedPath}" which is a new file with only stub exports`,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return violations;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* REQ-4d: Check edit_scope coverage — every file or glob listed under an
|
|
721
|
+
* `edit_scope` section in the spec must appear in the total diff.
|
|
722
|
+
*
|
|
723
|
+
* @param {Array} files - Parsed diff files
|
|
724
|
+
* @param {string} specContent - Raw spec markdown
|
|
725
|
+
* @param {string} taskType - Task type
|
|
726
|
+
* @returns {Array<{ file: string, line: number, tag: string, description: string }>}
|
|
727
|
+
*/
|
|
728
|
+
function checkScopeGaps(files, specContent, taskType) { // eslint-disable-line no-unused-vars
|
|
729
|
+
const violations = [];
|
|
730
|
+
|
|
731
|
+
// Try to extract an edit_scope section from the spec (various heading names).
|
|
732
|
+
const SCOPE_SECTION_NAMES = ['edit_scope', 'edit scope', 'scope', 'files'];
|
|
733
|
+
let scopeSection = null;
|
|
734
|
+
for (const name of SCOPE_SECTION_NAMES) {
|
|
735
|
+
scopeSection = extractSection(specContent, name);
|
|
736
|
+
if (scopeSection) break;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// If no dedicated section, look for inline `edit_scope:` YAML-style block.
|
|
740
|
+
if (!scopeSection) {
|
|
741
|
+
const inlineMatch = specContent.match(/edit_scope\s*:\s*\n((?:\s*-\s*.+\n?)+)/i);
|
|
742
|
+
if (inlineMatch) {
|
|
743
|
+
scopeSection = inlineMatch[1];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (!scopeSection) return violations;
|
|
748
|
+
|
|
749
|
+
// Extract the listed paths / globs from the section.
|
|
750
|
+
// Accepts lines like: `- path/to/file.js`, `* path`, `path/to/file` (bare).
|
|
751
|
+
const SCOPE_ITEM_PATTERN = /^\s*[-*]\s+(.+?)\s*$|^\s{2,}(\S.+?)\s*$/gm;
|
|
752
|
+
const scopeItems = [];
|
|
753
|
+
let m;
|
|
754
|
+
while ((m = SCOPE_ITEM_PATTERN.exec(scopeSection)) !== null) {
|
|
755
|
+
const item = (m[1] || m[2] || '').trim();
|
|
756
|
+
if (item) scopeItems.push(item);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (scopeItems.length === 0) return violations;
|
|
760
|
+
|
|
761
|
+
// Build the set of files touched in the diff.
|
|
762
|
+
const diffFilePaths = new Set(files.map((f) => f.file));
|
|
763
|
+
|
|
764
|
+
for (const scopeItem of scopeItems) {
|
|
765
|
+
// Convert glob wildcards to a simple regex for matching.
|
|
766
|
+
const escaped = scopeItem
|
|
767
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex meta-chars (but NOT * or ?)
|
|
768
|
+
.replace(/\\\*/g, '.*') // re-un-escape our * → .*
|
|
769
|
+
.replace(/\\\?/g, '.'); // ? → .
|
|
770
|
+
const pattern = new RegExp(`(^|/)${escaped}($|/)`);
|
|
771
|
+
|
|
772
|
+
const touched = [...diffFilePaths].some((p) => pattern.test(p) || p === scopeItem);
|
|
773
|
+
if (!touched) {
|
|
774
|
+
violations.push({
|
|
775
|
+
file: 'spec',
|
|
776
|
+
line: 1,
|
|
777
|
+
tag: TAGS.SCOPE_GAP,
|
|
778
|
+
description: `edit_scope entry "${scopeItem}" was not touched in the diff`,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return violations;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* REQ-4a: Check for mock covering implementation gap — a test file mocks module X
|
|
788
|
+
* but module X has no non-test changes in the total diff.
|
|
789
|
+
*
|
|
790
|
+
* @param {Array} files - Parsed diff files
|
|
791
|
+
* @param {string} specContent - Raw spec markdown
|
|
792
|
+
* @param {string} taskType - Task type
|
|
793
|
+
* @returns {Array<{ file: string, line: number, tag: string, description: string }>}
|
|
794
|
+
*/
|
|
795
|
+
function checkMockCoveringGap(files, specContent, taskType) { // eslint-disable-line no-unused-vars
|
|
796
|
+
const violations = [];
|
|
797
|
+
|
|
798
|
+
// Patterns that identify mock/jest.mock/vi.mock/require + module references.
|
|
799
|
+
// We capture the module path string from jest.mock('…') / vi.mock('…').
|
|
800
|
+
const JEST_MOCK_PATTERN = /(?:jest|vi)\.mock\s*\(\s*['"]([^'"]+)['"]/g;
|
|
801
|
+
|
|
802
|
+
// Collect production (non-test) file paths in the diff.
|
|
803
|
+
const prodFilePaths = new Set(
|
|
804
|
+
files.filter((f) => !isTestFile(f.file)).map((f) => f.file)
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
// Scan test files for mock declarations.
|
|
808
|
+
for (const fileObj of files) {
|
|
809
|
+
if (!isTestFile(fileObj.file)) continue;
|
|
810
|
+
|
|
811
|
+
for (const hunk of fileObj.hunks) {
|
|
812
|
+
for (const { lineNo, content } of hunk.lines) {
|
|
813
|
+
JEST_MOCK_PATTERN.lastIndex = 0;
|
|
814
|
+
let m;
|
|
815
|
+
while ((m = JEST_MOCK_PATTERN.exec(content)) !== null) {
|
|
816
|
+
const mockedPath = m[1];
|
|
817
|
+
// Check if any production file in the diff matches the mocked module path.
|
|
818
|
+
const hasProductionChange = [...prodFilePaths].some(
|
|
819
|
+
(p) => p.endsWith(mockedPath) || p.endsWith(`${mockedPath}.js`) || p.endsWith(`${mockedPath}.ts`)
|
|
820
|
+
);
|
|
821
|
+
if (!hasProductionChange) {
|
|
822
|
+
violations.push({
|
|
823
|
+
file: fileObj.file,
|
|
824
|
+
line: lineNo,
|
|
825
|
+
tag: TAGS.MOCK,
|
|
826
|
+
description: `mock covers implementation gap: "${mockedPath}" is mocked in tests but has no production changes in diff`,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return violations;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* REQ-4b: Check for REQ-N identifiers that appear only in test files but not
|
|
839
|
+
* in production source files in the diff.
|
|
840
|
+
*
|
|
841
|
+
* @param {Array} files - Parsed diff files
|
|
842
|
+
* @param {string} specContent - Raw spec markdown
|
|
843
|
+
* @param {string} taskType - Task type
|
|
844
|
+
* @returns {Array<{ file: string, line: number, tag: string, description: string }>}
|
|
845
|
+
*/
|
|
846
|
+
function checkReqOnlyInTests(files, specContent, taskType) { // eslint-disable-line no-unused-vars
|
|
847
|
+
// Only meaningful for implementation tasks.
|
|
848
|
+
if (taskType === 'spike' || taskType === 'bootstrap') return [];
|
|
849
|
+
|
|
850
|
+
const violations = [];
|
|
851
|
+
|
|
852
|
+
// Collect REQ-N identifiers mentioned in added lines of test files.
|
|
853
|
+
const REQ_PATTERN = /REQ-\d+[a-z]?/g;
|
|
854
|
+
|
|
855
|
+
const reqsInTests = new Map(); // reqId → { file, line } of first occurrence
|
|
856
|
+
for (const fileObj of files) {
|
|
857
|
+
if (!isTestFile(fileObj.file)) continue;
|
|
858
|
+
for (const hunk of fileObj.hunks) {
|
|
859
|
+
for (const { lineNo, content } of hunk.lines) {
|
|
860
|
+
let m;
|
|
861
|
+
REQ_PATTERN.lastIndex = 0;
|
|
862
|
+
while ((m = REQ_PATTERN.exec(content)) !== null) {
|
|
863
|
+
const reqId = m[0];
|
|
864
|
+
if (!reqsInTests.has(reqId)) {
|
|
865
|
+
reqsInTests.set(reqId, { file: fileObj.file, line: lineNo });
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (reqsInTests.size === 0) return violations;
|
|
873
|
+
|
|
874
|
+
// Collect all REQ-N identifiers mentioned in added lines of production files.
|
|
875
|
+
const reqsInProd = new Set();
|
|
876
|
+
for (const fileObj of files) {
|
|
877
|
+
if (isTestFile(fileObj.file)) continue;
|
|
878
|
+
for (const hunk of fileObj.hunks) {
|
|
879
|
+
for (const { content } of hunk.lines) {
|
|
880
|
+
let m;
|
|
881
|
+
REQ_PATTERN.lastIndex = 0;
|
|
882
|
+
while ((m = REQ_PATTERN.exec(content)) !== null) {
|
|
883
|
+
reqsInProd.add(m[0]);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Emit a violation for each REQ-N present only in tests but not in production.
|
|
890
|
+
for (const [reqId, loc] of reqsInTests) {
|
|
891
|
+
if (!reqsInProd.has(reqId)) {
|
|
892
|
+
violations.push({
|
|
893
|
+
file: loc.file,
|
|
894
|
+
line: loc.line,
|
|
895
|
+
tag: TAGS.SCOPE_GAP,
|
|
896
|
+
description: `${reqId} appears only in test files, not in production source`,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return violations;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── Output formatting (REQ-7) ─────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Format a single violation into the canonical output string.
|
|
908
|
+
*
|
|
909
|
+
* @param {{ file: string, line: number, tag: string, description: string }} violation
|
|
910
|
+
* @returns {string} Formatted as `${file}:${line}: [${TAG}] ${description}`
|
|
911
|
+
*/
|
|
912
|
+
function formatViolation(violation) {
|
|
913
|
+
return `${violation.file}:${violation.line}: [${violation.tag}] ${violation.description}`;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Format checkInvariants results into printable output lines.
|
|
918
|
+
* Caps output at 15 violation lines; appends a summary if truncated.
|
|
919
|
+
*
|
|
920
|
+
* @param {{ hard: Array, advisory: Array }} results
|
|
921
|
+
* @returns {string[]} Lines ready for printing
|
|
922
|
+
*/
|
|
923
|
+
function formatOutput(results) {
|
|
924
|
+
const MAX_LINES = 15;
|
|
925
|
+
const lines = [];
|
|
926
|
+
|
|
927
|
+
const allViolations = [
|
|
928
|
+
...results.hard.map((v) => ({ ...v, severity: 'HARD' })),
|
|
929
|
+
...results.advisory.map((v) => ({ ...v, severity: 'ADVISORY' })),
|
|
930
|
+
];
|
|
931
|
+
|
|
932
|
+
const total = allViolations.length;
|
|
933
|
+
const shown = allViolations.slice(0, MAX_LINES);
|
|
934
|
+
|
|
935
|
+
for (const v of shown) {
|
|
936
|
+
lines.push(formatViolation(v));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (total > MAX_LINES) {
|
|
940
|
+
const remaining = total - MAX_LINES;
|
|
941
|
+
lines.push(`... and ${remaining} more invariant violation${remaining === 1 ? '' : 's'}`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return lines;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ── Core API ──────────────────────────────────────────────────────────────────
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Check implementation diffs against spec invariants.
|
|
951
|
+
*
|
|
952
|
+
* @param {string} diff - Raw unified diff text
|
|
953
|
+
* @param {string} specContent - Raw spec markdown content
|
|
954
|
+
* @param {object} opts
|
|
955
|
+
* @param {'interactive'|'auto'} opts.mode - 'auto' promotes advisory to hard (REQ-9)
|
|
956
|
+
* @param {'bootstrap'|'spike'|'implementation'} opts.taskType - Affects which checks apply
|
|
957
|
+
* @param {string} [opts.projectRoot] - Project root for LSP detection (defaults to process.cwd())
|
|
958
|
+
* @returns {{ hard: Array<{ file: string, line: number, tag: string, description: string }>,
|
|
959
|
+
* advisory: Array<{ file: string, line: number, tag: string, description: string }> }}
|
|
960
|
+
*/
|
|
961
|
+
function checkInvariants(diff, specContent, opts = {}) {
|
|
962
|
+
const { mode = 'interactive', taskType = 'implementation', projectRoot = process.cwd() } = opts;
|
|
963
|
+
|
|
964
|
+
const hard = [];
|
|
965
|
+
const advisory = [];
|
|
966
|
+
|
|
967
|
+
// Parse the diff into structured file/hunk/line data
|
|
968
|
+
const files = parseDiff(diff);
|
|
969
|
+
|
|
970
|
+
// ── REQ-5, AC-11: LSP availability check ────────────────────────────────
|
|
971
|
+
const diffFilePaths = files.map((f) => f.file);
|
|
972
|
+
const lspCheck = checkLspAvailability(projectRoot, diffFilePaths);
|
|
973
|
+
if (!lspCheck.available) {
|
|
974
|
+
hard.push({
|
|
975
|
+
file: 'lsp',
|
|
976
|
+
line: 0,
|
|
977
|
+
tag: 'LSP_UNAVAILABLE',
|
|
978
|
+
description: lspCheck.message,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ── Run placeholder checks (T4-T8 will fill these in) ───────────────────
|
|
983
|
+
// Hard invariant checks: failures block the commit/task
|
|
984
|
+
const mockViolations = checkMocks(files, specContent, taskType);
|
|
985
|
+
hard.push(...mockViolations);
|
|
986
|
+
|
|
987
|
+
const stubViolations = checkStubsAndTodos(files, specContent, taskType);
|
|
988
|
+
hard.push(...stubViolations);
|
|
989
|
+
|
|
990
|
+
// REQ-4c: phantom imports (new files with only stub exports)
|
|
991
|
+
const phantomViolations = checkPhantoms(files, specContent, taskType);
|
|
992
|
+
hard.push(...phantomViolations);
|
|
993
|
+
|
|
994
|
+
// REQ-4d: edit_scope coverage
|
|
995
|
+
const scopeGapViolations = checkScopeGaps(files, specContent, taskType);
|
|
996
|
+
hard.push(...scopeGapViolations);
|
|
997
|
+
|
|
998
|
+
// Hard invariant: REQ-2 requires hard fail when any REQ-N has zero test references
|
|
999
|
+
const missingTestViolations = checkMissingTests(files, specContent, taskType);
|
|
1000
|
+
hard.push(...missingTestViolations);
|
|
1001
|
+
|
|
1002
|
+
const hardcodedViolations = checkHardcoded(files, specContent, taskType);
|
|
1003
|
+
advisory.push(...hardcodedViolations);
|
|
1004
|
+
|
|
1005
|
+
// REQ-4a: mock covering implementation gap
|
|
1006
|
+
const mockGapViolations = checkMockCoveringGap(files, specContent, taskType);
|
|
1007
|
+
hard.push(...mockGapViolations);
|
|
1008
|
+
|
|
1009
|
+
// REQ-4b: REQ-N only in tests, not in production source
|
|
1010
|
+
const reqOnlyInTestsViolations = checkReqOnlyInTests(files, specContent, taskType);
|
|
1011
|
+
hard.push(...reqOnlyInTestsViolations);
|
|
1012
|
+
|
|
1013
|
+
// ── Auto-mode escalation (REQ-9) ─────────────────────────────────────────
|
|
1014
|
+
// In auto mode (non-interactive CI/hook runs), all advisory items are promoted
|
|
1015
|
+
// to hard failures so the pipeline blocks on any violation.
|
|
1016
|
+
if (mode === 'auto') {
|
|
1017
|
+
hard.push(...advisory.splice(0, advisory.length));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return { hard, advisory };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ── CLI entry point (REQ-6) ───────────────────────────────────────────────────
|
|
1024
|
+
if (require.main === module) {
|
|
1025
|
+
const args = process.argv.slice(2);
|
|
1026
|
+
|
|
1027
|
+
// Parse --invariants <spec-path> <diff-file>
|
|
1028
|
+
const invariantsIdx = args.indexOf('--invariants');
|
|
1029
|
+
if (invariantsIdx === -1 || args.length < invariantsIdx + 3) {
|
|
1030
|
+
console.error('Usage: df-invariant-check.js --invariants <spec-file.md> <diff-file>');
|
|
1031
|
+
console.error('');
|
|
1032
|
+
console.error('Options:');
|
|
1033
|
+
console.error(' --invariants <spec-file.md> <diff-file> Run invariant checks');
|
|
1034
|
+
console.error(' --auto Auto mode (advisory => hard)');
|
|
1035
|
+
console.error(' --task-type <bootstrap|spike|implementation> Task type (default: implementation)');
|
|
1036
|
+
process.exit(1);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const specPath = args[invariantsIdx + 1];
|
|
1040
|
+
const diffPath = args[invariantsIdx + 2];
|
|
1041
|
+
const mode = args.includes('--auto') ? 'auto' : 'interactive';
|
|
1042
|
+
|
|
1043
|
+
const taskTypeIdx = args.indexOf('--task-type');
|
|
1044
|
+
const taskType = taskTypeIdx !== -1 ? args[taskTypeIdx + 1] : 'implementation';
|
|
1045
|
+
|
|
1046
|
+
let specContent, diff;
|
|
1047
|
+
try {
|
|
1048
|
+
specContent = fs.readFileSync(specPath, 'utf8');
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
console.error(`Error reading spec file "${specPath}": ${err.message}`);
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
diff = fs.readFileSync(diffPath, 'utf8');
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
console.error(`Error reading diff file "${diffPath}": ${err.message}`);
|
|
1058
|
+
process.exit(1);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const results = checkInvariants(diff, specContent, { mode, taskType });
|
|
1062
|
+
const outputLines = formatOutput(results);
|
|
1063
|
+
|
|
1064
|
+
if (results.hard.length > 0) {
|
|
1065
|
+
console.error('HARD invariant failures:');
|
|
1066
|
+
for (const line of outputLines.filter((_, i) => i < results.hard.length)) {
|
|
1067
|
+
console.error(` ${line}`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (results.advisory.length > 0) {
|
|
1072
|
+
console.warn('Advisory warnings:');
|
|
1073
|
+
for (const v of results.advisory) {
|
|
1074
|
+
console.warn(` ${formatViolation(v)}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (results.hard.length === 0 && results.advisory.length === 0) {
|
|
1079
|
+
console.log('All invariant checks passed.');
|
|
1080
|
+
} else if (outputLines.length > 0) {
|
|
1081
|
+
// Print formatted output (respects 15-line cap)
|
|
1082
|
+
for (const line of outputLines) {
|
|
1083
|
+
if (results.hard.some((v) => formatViolation(v) === line)) {
|
|
1084
|
+
console.error(line);
|
|
1085
|
+
} else {
|
|
1086
|
+
console.warn(line);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
process.exit(results.hard.length > 0 ? 1 : 0);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
module.exports = {
|
|
1095
|
+
checkInvariants,
|
|
1096
|
+
checkLspAvailability,
|
|
1097
|
+
detectLanguageServer,
|
|
1098
|
+
isBinaryAvailable,
|
|
1099
|
+
formatOutput,
|
|
1100
|
+
formatViolation,
|
|
1101
|
+
parseDiff,
|
|
1102
|
+
TAGS,
|
|
1103
|
+
checkMockCoveringGap,
|
|
1104
|
+
checkReqOnlyInTests,
|
|
1105
|
+
checkPhantoms,
|
|
1106
|
+
checkScopeGaps,
|
|
1107
|
+
};
|
package/hooks/df-spec-lint.js
CHANGED