tlc-claude-code 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,11 +75,11 @@ describe('overdrive-command', () => {
75
75
 
76
76
  it('parses --model flag', () => {
77
77
  expect(parseOverdriveArgs('--model opus').model).toBe('opus');
78
- expect(parseOverdriveArgs('--model sonnet').model).toBe('sonnet');
79
- expect(parseOverdriveArgs('--model haiku').model).toBe('haiku');
80
78
  });
81
79
 
82
- it('ignores invalid model values', () => {
80
+ it('ignores non-opus model values', () => {
81
+ expect(parseOverdriveArgs('--model sonnet').model).toBeNull();
82
+ expect(parseOverdriveArgs('--model haiku').model).toBeNull();
83
83
  expect(parseOverdriveArgs('--model gpt4').model).toBeNull();
84
84
  });
85
85
 
@@ -89,12 +89,12 @@ describe('overdrive-command', () => {
89
89
  });
90
90
 
91
91
  it('parses multiple flags including new ones', () => {
92
- const options = parseOverdriveArgs('5 --agents 2 --mode test --model sonnet --max-turns 25 --dry-run');
92
+ const options = parseOverdriveArgs('5 --agents 2 --mode test --model opus --max-turns 25 --dry-run');
93
93
 
94
94
  expect(options.phase).toBe(5);
95
95
  expect(options.agents).toBe(2);
96
96
  expect(options.mode).toBe('test');
97
- expect(options.model).toBe('sonnet');
97
+ expect(options.model).toBe('opus');
98
98
  expect(options.maxTurns).toBe(25);
99
99
  expect(options.dryRun).toBe(true);
100
100
  });
@@ -152,7 +152,7 @@ describe('overdrive-command', () => {
152
152
  expect(prompts[0].prompt).toContain('Fix any failing tests');
153
153
  });
154
154
 
155
- it('assigns model based on task complexity', () => {
155
+ it('assigns opus for all task complexities', () => {
156
156
  const tasks = [
157
157
  { id: 1, title: 'Refactor authentication system' },
158
158
  { id: 2, title: 'Add helper function' },
@@ -163,9 +163,9 @@ describe('overdrive-command', () => {
163
163
  mode: 'build', projectDir: '/p', phase: 1,
164
164
  });
165
165
 
166
- expect(prompts[0].model).toBe('opus'); // refactor = heavy
167
- expect(prompts[1].model).toBe('sonnet'); // default = standard
168
- expect(prompts[2].model).toBe('haiku'); // enum = light
166
+ expect(prompts[0].model).toBe('opus');
167
+ expect(prompts[1].model).toBe('opus');
168
+ expect(prompts[2].model).toBe('opus');
169
169
  });
170
170
 
171
171
  it('respects model override', () => {
@@ -176,11 +176,11 @@ describe('overdrive-command', () => {
176
176
 
177
177
  const prompts = generateAgentPrompts(tasks, {
178
178
  mode: 'build', projectDir: '/p', phase: 1,
179
- model: 'haiku',
179
+ model: 'opus',
180
180
  });
181
181
 
182
- expect(prompts[0].model).toBe('haiku');
183
- expect(prompts[1].model).toBe('haiku');
182
+ expect(prompts[0].model).toBe('opus');
183
+ expect(prompts[1].model).toBe('opus');
184
184
  });
185
185
 
186
186
  it('respects maxTurns override', () => {
@@ -286,8 +286,8 @@ describe('overdrive-command', () => {
286
286
  describe('generateTaskCalls', () => {
287
287
  it('generates task tool calls with model and max_turns', () => {
288
288
  const prompts = [
289
- { taskId: 1, taskTitle: 'Test', prompt: 'Do task 1', agentType: 'general-purpose', model: 'sonnet', maxTurns: 50 },
290
- { taskId: 2, taskTitle: 'Test 2', prompt: 'Do task 2', agentType: 'general-purpose', model: 'haiku', maxTurns: 30 },
289
+ { taskId: 1, taskTitle: 'Test', prompt: 'Do task 1', agentType: 'general-purpose', model: 'opus', maxTurns: 50 },
290
+ { taskId: 2, taskTitle: 'Test 2', prompt: 'Do task 2', agentType: 'general-purpose', model: 'opus', maxTurns: 30 },
291
291
  ];
292
292
 
293
293
  const calls = generateTaskCalls(prompts);
@@ -297,9 +297,9 @@ describe('overdrive-command', () => {
297
297
  expect(calls[0].params.description).toContain('Agent 1');
298
298
  expect(calls[0].params.run_in_background).toBe(true);
299
299
  expect(calls[0].params.subagent_type).toBe('general-purpose');
300
- expect(calls[0].params.model).toBe('sonnet');
300
+ expect(calls[0].params.model).toBe('opus');
301
301
  expect(calls[0].params.max_turns).toBe(50);
302
- expect(calls[1].params.model).toBe('haiku');
302
+ expect(calls[1].params.model).toBe('opus');
303
303
  expect(calls[1].params.max_turns).toBe(30);
304
304
  });
305
305
  });
@@ -497,16 +497,16 @@ Blocked by Task 1
497
497
  expect(getModelForTask({ title: 'Refactor auth' })).toBe('opus');
498
498
  });
499
499
 
500
- it('returns sonnet for standard tasks', () => {
501
- expect(getModelForTask({ title: 'Add endpoint' })).toBe('sonnet');
500
+ it('returns opus for standard tasks', () => {
501
+ expect(getModelForTask({ title: 'Add endpoint' })).toBe('opus');
502
502
  });
503
503
 
504
- it('returns haiku for light tasks', () => {
505
- expect(getModelForTask({ title: 'Create enum' })).toBe('haiku');
504
+ it('returns opus for light tasks', () => {
505
+ expect(getModelForTask({ title: 'Create enum' })).toBe('opus');
506
506
  });
507
507
 
508
508
  it('respects model override', () => {
509
- expect(getModelForTask({ title: 'Refactor auth' }, 'haiku')).toBe('haiku');
509
+ expect(getModelForTask({ title: 'Refactor auth' }, 'opus')).toBe('opus');
510
510
  expect(getModelForTask({ title: 'Create enum' }, 'opus')).toBe('opus');
511
511
  });
512
512
  });
@@ -533,10 +533,10 @@ Blocked by Task 1
533
533
  expect(AGENT_TYPES.PLAN).toBe('Plan');
534
534
  });
535
535
 
536
- it('exports valid model tiers', () => {
536
+ it('exports valid model tiers (all opus)', () => {
537
537
  expect(MODEL_TIERS.HEAVY).toBe('opus');
538
- expect(MODEL_TIERS.STANDARD).toBe('sonnet');
539
- expect(MODEL_TIERS.LIGHT).toBe('haiku');
538
+ expect(MODEL_TIERS.STANDARD).toBe('opus');
539
+ expect(MODEL_TIERS.LIGHT).toBe('opus');
540
540
  });
541
541
 
542
542
  it('exports default max turns', () => {
@@ -562,8 +562,7 @@ Blocked by Task 1
562
562
 
563
563
  expect(output).toContain('Opus 4.6');
564
564
  expect(output).toContain('[opus]');
565
- expect(output).toContain('[haiku]');
566
- expect(output).toContain('Model selection per task complexity');
565
+ expect(output).toContain('All agents use opus');
567
566
  expect(output).toContain('Agent resumption');
568
567
  expect(output).toContain('TaskOutput');
569
568
  expect(output).toContain('TaskStop');
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Review Fixer Module
3
+ * Classifies review findings as AUTO-FIX (apply immediately) or ASK (batch for user approval).
4
+ */
5
+
6
+ /**
7
+ * Finding types that can be automatically fixed without user input.
8
+ * These are mechanical, low-risk corrections.
9
+ */
10
+ const AUTO_FIX_TYPES = {
11
+ any_type: 'Type annotation can be inferred or added mechanically',
12
+ missing_return_type: 'Return type can be inferred from implementation',
13
+ console_log: 'Debug logging can be safely removed',
14
+ missing_test_skeleton: 'Test skeleton can be generated from function signature',
15
+ hardcoded_url: 'URL can be extracted to environment variable',
16
+ };
17
+
18
+ /**
19
+ * Finding types that require user decision.
20
+ * These involve judgment, security, or architectural choices.
21
+ */
22
+ const ASK_TYPES = {
23
+ ownership_missing: 'Ownership assignment requires team decision',
24
+ secrets_in_response: 'Secret handling requires security review',
25
+ architectural_decision: 'Architecture changes need explicit approval',
26
+ ambiguous_security: 'Security implications need human assessment',
27
+ scope_creep: 'Scope change requires product owner approval',
28
+ };
29
+
30
+ /**
31
+ * Classify a review finding into auto-fix or ask category
32
+ * @param {string} type - The finding type identifier
33
+ * @returns {{ action: 'auto-fix' | 'ask', reason: string }} Classification result
34
+ */
35
+ function classifyFinding(type) {
36
+ if (!type || typeof type !== 'string') {
37
+ return { action: 'ask', reason: 'Invalid or missing finding type' };
38
+ }
39
+
40
+ if (AUTO_FIX_TYPES[type]) {
41
+ return { action: 'auto-fix', reason: AUTO_FIX_TYPES[type] };
42
+ }
43
+
44
+ if (ASK_TYPES[type]) {
45
+ return { action: 'ask', reason: ASK_TYPES[type] };
46
+ }
47
+
48
+ return { action: 'ask', reason: `Finding type "${type}" is unknown — defaulting to ask for safety` };
49
+ }
50
+
51
+ /**
52
+ * Split an array of findings into auto-fix and ask buckets
53
+ * @param {Array<{ type: string }>} findings - Array of finding objects with a type property
54
+ * @returns {{ autoFix: Array, ask: Array }} Separated findings
55
+ */
56
+ function splitFindings(findings) {
57
+ if (!Array.isArray(findings)) {
58
+ return { autoFix: [], ask: [] };
59
+ }
60
+
61
+ const autoFix = [];
62
+ const ask = [];
63
+
64
+ for (const finding of findings) {
65
+ const classification = classifyFinding(finding.type);
66
+ if (classification.action === 'auto-fix') {
67
+ autoFix.push(finding);
68
+ } else {
69
+ ask.push(finding);
70
+ }
71
+ }
72
+
73
+ return { autoFix, ask };
74
+ }
75
+
76
+ /**
77
+ * Format a markdown summary report of fix results
78
+ * @param {number} autoFixed - Count of automatically fixed findings
79
+ * @param {number} needsInput - Count of findings needing user input
80
+ * @returns {string} Formatted markdown report
81
+ */
82
+ function formatFixReport(autoFixed, needsInput) {
83
+ const total = autoFixed + needsInput;
84
+
85
+ const lines = [
86
+ `## Review Fix Report`,
87
+ '',
88
+ `**${total}** findings processed.`,
89
+ '',
90
+ `### Auto-Fixed: ${autoFixed}`,
91
+ '',
92
+ autoFixed > 0
93
+ ? `${autoFixed} finding(s) were automatically resolved.`
94
+ : 'No findings were auto-fixed.',
95
+ '',
96
+ `### Needs Input: ${needsInput}`,
97
+ '',
98
+ needsInput > 0
99
+ ? `${needsInput} finding(s) require your review and decision.`
100
+ : 'No findings need your input.',
101
+ ];
102
+
103
+ return lines.join('\n');
104
+ }
105
+
106
+
107
+ module.exports = { classifyFinding, splitFindings, formatFixReport };
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ classifyFinding,
4
+ splitFindings,
5
+ formatFixReport,
6
+ } from './review-fixer.js';
7
+
8
+ describe('review-fixer', () => {
9
+ describe('classifyFinding', () => {
10
+ it('classifies any_type as auto-fix', () => {
11
+ const result = classifyFinding('any_type');
12
+ expect(result.action).toBe('auto-fix');
13
+ expect(result.reason).toBeTypeOf('string');
14
+ expect(result.reason.length).toBeGreaterThan(0);
15
+ });
16
+
17
+ it('classifies ownership_missing as ask', () => {
18
+ const result = classifyFinding('ownership_missing');
19
+ expect(result.action).toBe('ask');
20
+ expect(result.reason).toBeTypeOf('string');
21
+ expect(result.reason.length).toBeGreaterThan(0);
22
+ });
23
+
24
+ it('classifies console_log as auto-fix', () => {
25
+ const result = classifyFinding('console_log');
26
+ expect(result.action).toBe('auto-fix');
27
+ });
28
+
29
+ it('classifies missing_return_type as auto-fix', () => {
30
+ const result = classifyFinding('missing_return_type');
31
+ expect(result.action).toBe('auto-fix');
32
+ });
33
+
34
+ it('classifies missing_test_skeleton as auto-fix', () => {
35
+ const result = classifyFinding('missing_test_skeleton');
36
+ expect(result.action).toBe('auto-fix');
37
+ });
38
+
39
+ it('classifies hardcoded_url as auto-fix', () => {
40
+ const result = classifyFinding('hardcoded_url');
41
+ expect(result.action).toBe('auto-fix');
42
+ });
43
+
44
+ it('classifies secrets_in_response as ask', () => {
45
+ const result = classifyFinding('secrets_in_response');
46
+ expect(result.action).toBe('ask');
47
+ });
48
+
49
+ it('classifies architectural_decision as ask', () => {
50
+ const result = classifyFinding('architectural_decision');
51
+ expect(result.action).toBe('ask');
52
+ });
53
+
54
+ it('classifies ambiguous_security as ask', () => {
55
+ const result = classifyFinding('ambiguous_security');
56
+ expect(result.action).toBe('ask');
57
+ });
58
+
59
+ it('classifies scope_creep as ask', () => {
60
+ const result = classifyFinding('scope_creep');
61
+ expect(result.action).toBe('ask');
62
+ });
63
+
64
+ it('defaults unknown types to ask (safe default)', () => {
65
+ const result = classifyFinding('unknown_type');
66
+ expect(result.action).toBe('ask');
67
+ expect(result.reason).toContain('unknown');
68
+ });
69
+
70
+ it('defaults another unrecognized type to ask', () => {
71
+ const result = classifyFinding('some_random_finding');
72
+ expect(result.action).toBe('ask');
73
+ });
74
+ });
75
+
76
+ describe('splitFindings', () => {
77
+ it('correctly separates auto-fix from ask findings', () => {
78
+ const findings = [
79
+ { type: 'any_type', file: 'a.js', line: 1 },
80
+ { type: 'ownership_missing', file: 'b.js', line: 5 },
81
+ { type: 'console_log', file: 'c.js', line: 10 },
82
+ { type: 'architectural_decision', file: 'd.js', line: 3 },
83
+ { type: 'hardcoded_url', file: 'e.js', line: 7 },
84
+ ];
85
+
86
+ const result = splitFindings(findings);
87
+
88
+ expect(result.autoFix).toHaveLength(3);
89
+ expect(result.ask).toHaveLength(2);
90
+
91
+ expect(result.autoFix.map(f => f.type)).toContain('any_type');
92
+ expect(result.autoFix.map(f => f.type)).toContain('console_log');
93
+ expect(result.autoFix.map(f => f.type)).toContain('hardcoded_url');
94
+
95
+ expect(result.ask.map(f => f.type)).toContain('ownership_missing');
96
+ expect(result.ask.map(f => f.type)).toContain('architectural_decision');
97
+ });
98
+
99
+ it('returns empty arrays for empty input', () => {
100
+ const result = splitFindings([]);
101
+ expect(result.autoFix).toEqual([]);
102
+ expect(result.ask).toEqual([]);
103
+ });
104
+
105
+ it('handles all auto-fix findings', () => {
106
+ const findings = [
107
+ { type: 'any_type', file: 'a.js', line: 1 },
108
+ { type: 'console_log', file: 'b.js', line: 2 },
109
+ ];
110
+ const result = splitFindings(findings);
111
+ expect(result.autoFix).toHaveLength(2);
112
+ expect(result.ask).toHaveLength(0);
113
+ });
114
+
115
+ it('handles all ask findings', () => {
116
+ const findings = [
117
+ { type: 'ownership_missing', file: 'a.js', line: 1 },
118
+ { type: 'scope_creep', file: 'b.js', line: 2 },
119
+ ];
120
+ const result = splitFindings(findings);
121
+ expect(result.autoFix).toHaveLength(0);
122
+ expect(result.ask).toHaveLength(2);
123
+ });
124
+ });
125
+
126
+ describe('formatFixReport', () => {
127
+ it('includes counts and section headers', () => {
128
+ const report = formatFixReport(3, 2);
129
+ expect(report).toContain('3');
130
+ expect(report).toContain('2');
131
+ expect(report).toContain('Auto-Fix');
132
+ expect(report).toContain('Needs Input');
133
+ });
134
+
135
+ it('handles zero auto-fixed', () => {
136
+ const report = formatFixReport(0, 4);
137
+ expect(report).toContain('0');
138
+ expect(report).toContain('4');
139
+ });
140
+
141
+ it('handles zero needing input', () => {
142
+ const report = formatFixReport(5, 0);
143
+ expect(report).toContain('5');
144
+ expect(report).toContain('0');
145
+ });
146
+
147
+ it('returns valid markdown', () => {
148
+ const report = formatFixReport(2, 1);
149
+ expect(report).toContain('#');
150
+ });
151
+ });
152
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Scope Checker
3
+ * Compare diff files against plan task files to detect scope drift
4
+ */
5
+
6
+ /**
7
+ * Extract planned file paths from PLAN.md content.
8
+ * Parses all `**Files:**` sections and collects listed file paths.
9
+ * @param {string} planContent - Raw content of a PLAN.md file
10
+ * @returns {string[]} Deduplicated array of planned file paths
11
+ */
12
+ function extractPlannedFiles(planContent) {
13
+ if (!planContent) return [];
14
+
15
+ const files = [];
16
+ const seen = new Set();
17
+ const lines = planContent.split('\n');
18
+ let inFilesSection = false;
19
+
20
+ for (const line of lines) {
21
+ if (line.trim().startsWith('**Files:**')) {
22
+ inFilesSection = true;
23
+ continue;
24
+ }
25
+
26
+ if (inFilesSection) {
27
+ const match = line.match(/^- (.+)/);
28
+ if (match) {
29
+ // Strip backticks, em-dashes, and parenthetical annotations like (new), (modify)
30
+ const filePath = match[1]
31
+ .replace(/`/g, '')
32
+ .split(' — ')[0]
33
+ .split(' -- ')[0]
34
+ .replace(/\s*\((?:new|modify|delete|update|create)\)\s*$/i, '')
35
+ .trim();
36
+ if (!seen.has(filePath)) {
37
+ seen.add(filePath);
38
+ files.push(filePath);
39
+ }
40
+ } else {
41
+ // Non-list line ends the Files section
42
+ inFilesSection = false;
43
+ }
44
+ }
45
+ }
46
+
47
+ return files;
48
+ }
49
+
50
+ /**
51
+ * Check if a file path is a test file.
52
+ * Matches `.test.`, `_test.`, and `test_` patterns.
53
+ * @param {string} filePath - File path to check
54
+ * @returns {boolean} True if the file is a test file
55
+ */
56
+ function isTestFile(filePath) {
57
+ const name = filePath.split('/').pop() || filePath;
58
+ // Check filename patterns
59
+ const nameMatch = (
60
+ name.includes('.test.') ||
61
+ name.includes('.spec.') ||
62
+ name.includes('_test.') ||
63
+ name.startsWith('test_')
64
+ );
65
+ if (nameMatch) return true;
66
+
67
+ // Check directory-based test paths (tests/, __tests__/, test/, spec/)
68
+ const dirMatch = /(?:^|\/)(?:tests|__tests__|test|spec)\//.test(filePath);
69
+ return dirMatch;
70
+ }
71
+
72
+ /**
73
+ * Detect scope drift between diff files and planned files.
74
+ * Returns files present in diff but not in plan (drift),
75
+ * and files in plan but not in diff (missing).
76
+ * Test files are excluded from drift detection.
77
+ * @param {string[]} diffFiles - Files changed in the diff
78
+ * @param {string[]} plannedFiles - Files listed in the plan
79
+ * @returns {{ drift: string[], missing: string[] }} Drift report
80
+ */
81
+ function detectDrift(diffFiles, plannedFiles) {
82
+ if (!diffFiles || !plannedFiles) return { drift: [], missing: [] };
83
+
84
+ const plannedSet = new Set(plannedFiles);
85
+ const diffSet = new Set(diffFiles);
86
+
87
+ const drift = diffFiles.filter(
88
+ (f) => !plannedSet.has(f) && !isTestFile(f)
89
+ );
90
+
91
+ const missing = plannedFiles.filter((f) => !diffSet.has(f));
92
+
93
+ return { drift, missing };
94
+ }
95
+
96
+ /**
97
+ * Format a drift report as markdown.
98
+ * Returns "No drift detected" when both arrays are empty.
99
+ * @param {string[]} drift - Files in diff but not in plan
100
+ * @param {string[]} missing - Files in plan but not in diff
101
+ * @returns {string} Formatted markdown report
102
+ */
103
+ function formatDriftReport(drift, missing) {
104
+ if ((!drift || drift.length === 0) && (!missing || missing.length === 0)) {
105
+ return 'No drift detected';
106
+ }
107
+
108
+ const sections = [];
109
+
110
+ if (drift && drift.length > 0) {
111
+ sections.push(
112
+ '### Scope Drift\n\nFiles changed but not in plan:\n\n' +
113
+ drift.map((f) => `- \`${f}\``).join('\n')
114
+ );
115
+ }
116
+
117
+ if (missing && missing.length > 0) {
118
+ sections.push(
119
+ '### Missing Work\n\nPlanned files not yet changed:\n\n' +
120
+ missing.map((f) => `- \`${f}\``).join('\n')
121
+ );
122
+ }
123
+
124
+ return sections.join('\n\n');
125
+ }
126
+
127
+ module.exports = { extractPlannedFiles, isTestFile, detectDrift, formatDriftReport };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Scope Checker Tests
3
+ * Detect scope drift between plan files and actual diffs
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import {
8
+ extractPlannedFiles,
9
+ detectDrift,
10
+ formatDriftReport,
11
+ } from './scope-checker.js';
12
+
13
+ describe('scope-checker', () => {
14
+ describe('extractPlannedFiles', () => {
15
+ it('extracts file paths from a Files section', () => {
16
+ const plan = '**Files:**\n- src/auth.ts\n- src/user.ts';
17
+ const result = extractPlannedFiles(plan);
18
+ expect(result).toEqual(['src/auth.ts', 'src/user.ts']);
19
+ });
20
+
21
+ it('returns empty array when no Files section exists', () => {
22
+ const plan = 'no files section';
23
+ const result = extractPlannedFiles(plan);
24
+ expect(result).toEqual([]);
25
+ });
26
+
27
+ it('strips parenthetical annotations like (new) and (modify)', () => {
28
+ const plan = '**Files:**\n- src/auth.ts (new)\n- src/user.ts (modify)\n- src/config.ts (delete)';
29
+ const result = extractPlannedFiles(plan);
30
+ expect(result).toEqual(['src/auth.ts', 'src/user.ts', 'src/config.ts']);
31
+ });
32
+
33
+ it('strips backticks from file paths', () => {
34
+ const plan = '**Files:**\n- `server/lib/review-fixer.js`\n- `server/lib/scope-checker.js` — new module';
35
+ const result = extractPlannedFiles(plan);
36
+ expect(result).toEqual(['server/lib/review-fixer.js', 'server/lib/scope-checker.js']);
37
+ });
38
+
39
+ it('collects files from multiple tasks', () => {
40
+ const plan = [
41
+ '## Task 1: Auth',
42
+ '**Files:**',
43
+ '- src/auth.ts',
44
+ '- src/auth.test.ts',
45
+ '',
46
+ '## Task 2: Users',
47
+ '**Files:**',
48
+ '- src/user.ts',
49
+ '- src/user.test.ts',
50
+ ].join('\n');
51
+ const result = extractPlannedFiles(plan);
52
+ expect(result).toEqual([
53
+ 'src/auth.ts',
54
+ 'src/auth.test.ts',
55
+ 'src/user.ts',
56
+ 'src/user.test.ts',
57
+ ]);
58
+ });
59
+
60
+ it('handles file entries with descriptions after em-dash', () => {
61
+ const plan = '**Files:**\n- server/lib/scope-checker.js — compares diff files against plan';
62
+ const result = extractPlannedFiles(plan);
63
+ expect(result).toEqual(['server/lib/scope-checker.js']);
64
+ });
65
+
66
+ it('returns empty array for empty string', () => {
67
+ const result = extractPlannedFiles('');
68
+ expect(result).toEqual([]);
69
+ });
70
+
71
+ it('deduplicates file paths across tasks', () => {
72
+ const plan = [
73
+ '## Task 1',
74
+ '**Files:**',
75
+ '- src/shared.ts',
76
+ '',
77
+ '## Task 2',
78
+ '**Files:**',
79
+ '- src/shared.ts',
80
+ '- src/other.ts',
81
+ ].join('\n');
82
+ const result = extractPlannedFiles(plan);
83
+ expect(result).toEqual(['src/shared.ts', 'src/other.ts']);
84
+ });
85
+ });
86
+
87
+ describe('detectDrift', () => {
88
+ it('returns no drift when diff matches plan exactly', () => {
89
+ const result = detectDrift(['src/auth.ts'], ['src/auth.ts']);
90
+ expect(result).toEqual({ drift: [], missing: [] });
91
+ });
92
+
93
+ it('detects extra files not in plan as drift', () => {
94
+ const result = detectDrift(
95
+ ['src/auth.ts', 'src/extra.ts'],
96
+ ['src/auth.ts']
97
+ );
98
+ expect(result).toEqual({
99
+ drift: ['src/extra.ts'],
100
+ missing: [],
101
+ });
102
+ });
103
+
104
+ it('detects planned files not in diff as missing', () => {
105
+ const result = detectDrift(
106
+ ['src/auth.ts'],
107
+ ['src/auth.ts', 'src/missing.ts']
108
+ );
109
+ expect(result).toEqual({
110
+ drift: [],
111
+ missing: ['src/missing.ts'],
112
+ });
113
+ });
114
+
115
+ it('excludes test files from drift detection', () => {
116
+ const result = detectDrift(['src/auth.test.ts'], []);
117
+ expect(result).toEqual({ drift: [], missing: [] });
118
+ });
119
+
120
+ it('excludes _test. pattern from drift detection', () => {
121
+ const result = detectDrift(['src/auth_test.go'], []);
122
+ expect(result).toEqual({ drift: [], missing: [] });
123
+ });
124
+
125
+ it('excludes test_ prefix from drift detection', () => {
126
+ const result = detectDrift(['test_auth.py'], []);
127
+ expect(result).toEqual({ drift: [], missing: [] });
128
+ });
129
+
130
+ it('returns empty arrays when both inputs are empty', () => {
131
+ const result = detectDrift([], []);
132
+ expect(result).toEqual({ drift: [], missing: [] });
133
+ });
134
+
135
+ it('handles both drift and missing simultaneously', () => {
136
+ const result = detectDrift(
137
+ ['src/auth.ts', 'src/extra.ts'],
138
+ ['src/auth.ts', 'src/missing.ts']
139
+ );
140
+ expect(result).toEqual({
141
+ drift: ['src/extra.ts'],
142
+ missing: ['src/missing.ts'],
143
+ });
144
+ });
145
+ });
146
+
147
+ describe('formatDriftReport', () => {
148
+ it('formats report with both drift and missing sections', () => {
149
+ const report = formatDriftReport(['extra.ts'], ['missing.ts']);
150
+ expect(report).toContain('extra.ts');
151
+ expect(report).toContain('missing.ts');
152
+ expect(report).toContain('Scope Drift');
153
+ expect(report).toContain('Missing');
154
+ });
155
+
156
+ it('returns no drift message when both arrays are empty', () => {
157
+ const report = formatDriftReport([], []);
158
+ expect(report).toBe('No drift detected');
159
+ });
160
+
161
+ it('formats report with only drift', () => {
162
+ const report = formatDriftReport(['extra.ts'], []);
163
+ expect(report).toContain('extra.ts');
164
+ expect(report).toContain('Scope Drift');
165
+ expect(report).not.toContain('Missing');
166
+ });
167
+
168
+ it('formats report with only missing', () => {
169
+ const report = formatDriftReport([], ['missing.ts']);
170
+ expect(report).toContain('missing.ts');
171
+ expect(report).toContain('Missing');
172
+ expect(report).not.toContain('Scope Drift');
173
+ });
174
+ });
175
+ });