deepflow 0.1.75 → 0.1.77

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 CHANGED
@@ -1,10 +1,12 @@
1
1
  ```
2
- _ __ _
3
- __| | ___ ___ _ __ / _| | _____ __
4
- / _` |/ _ \/ _ \ '_ \ | |_| |/ _ \ \ /\ / /
5
- | (_| | __/ __/ |_) | | _| | (_) \ V V /
6
- \__,_|\___|\___| .__/ |_| |_|\___/ \_/\_/
7
- |_|
2
+
3
+ ██████╗ ███████╗ ███████╗ ██████╗ ███████╗ ██╗ ██████╗ ██╗ ██╗
4
+ ██╔══██╗ ██╔════╝ ██╔════╝ ██╔══██╗ ██╔════╝ ██║ ██╔═══██╗ ██║ ██║
5
+ ██║ ██║ █████╗ █████╗ ██████╔╝ █████╗ ██║ ██║ ██║ ██║ █╗ ██║
6
+ ██║ ██║ ██╔══╝ ██╔══╝ ██╔═══╝ ██╔══╝ ██║ ██║ ██║ ██║███╗██║
7
+ ██████╔╝ ███████╗ ███████╗ ██║ ██║ ███████╗ ╚██████╔╝ ╚███╔███╔╝
8
+ ╚═════╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝
9
+
8
10
  ```
9
11
 
10
12
  <p align="center">
package/bin/install.js CHANGED
@@ -187,7 +187,7 @@ async function main() {
187
187
  console.log(' skills/ — gap-discovery, atomic-commits, code-completeness, context-hub');
188
188
  console.log(' agents/ — reasoner (/df:auto — autonomous execution via /loop)');
189
189
  if (level === 'global') {
190
- console.log(' hooks/ — statusline, update checker');
190
+ console.log(' hooks/ — statusline, update checker, invariant checker');
191
191
  }
192
192
  console.log(' hooks/df-spec-* — spec validation (auto-enforced by /df:spec and /df:plan)');
193
193
  console.log(' env/ — ENABLE_LSP_TOOL (code navigation via goToDefinition, findReferences, workspaceSymbol)');
@@ -474,7 +474,7 @@ async function uninstall() {
474
474
  ];
475
475
 
476
476
  if (level === 'global') {
477
- toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-consolidation-check.js');
477
+ toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-consolidation-check.js', 'hooks/df-invariant-check.js');
478
478
  }
479
479
 
480
480
  for (const item of toRemove) {
@@ -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
+ };
@@ -233,4 +233,4 @@ if (require.main === module) {
233
233
  process.exit(result.hard.length > 0 ? 1 : 0);
234
234
  }
235
235
 
236
- module.exports = { validateSpec };
236
+ module.exports = { validateSpec, extractSection };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepflow",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "Doing reveals what thinking can't predict — spec-driven iterative development for Claude Code",
5
5
  "keywords": [
6
6
  "claude",