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.
- package/.claude/agents/builder.md +17 -0
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/build.md +126 -78
- package/.claude/commands/tlc/guard.md +9 -0
- package/.claude/commands/tlc/init.md +12 -1
- package/.claude/commands/tlc/review.md +19 -0
- package/CODING-STANDARDS.md +217 -10
- package/package.json +1 -1
- package/server/lib/careful-patterns.js +142 -0
- package/server/lib/careful-patterns.test.js +164 -0
- package/server/lib/field-report.js +92 -0
- package/server/lib/field-report.test.js +195 -0
- package/server/lib/orchestration/worktree-manager.js +133 -0
- package/server/lib/orchestration/worktree-manager.test.js +198 -0
- package/server/lib/overdrive-command.js +31 -9
- package/server/lib/overdrive-command.test.js +25 -26
- package/server/lib/review-fixer.js +107 -0
- package/server/lib/review-fixer.test.js +152 -0
- package/server/lib/scope-checker.js +127 -0
- package/server/lib/scope-checker.test.js +175 -0
- package/server/lib/skill-validator.js +165 -0
- package/server/lib/skill-validator.test.js +289 -0
- package/server/lib/standards/standards-injector.js +6 -0
- package/server/lib/test-selector.js +127 -0
- package/server/lib/test-selector.test.js +172 -0
- package/server/templates/CLAUDE.md +6 -0
- package/server/templates/CODING-STANDARDS.md +356 -10
|
@@ -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
|
|
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
|
|
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('
|
|
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
|
|
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');
|
|
167
|
-
expect(prompts[1].model).toBe('
|
|
168
|
-
expect(prompts[2].model).toBe('
|
|
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: '
|
|
179
|
+
model: 'opus',
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
-
expect(prompts[0].model).toBe('
|
|
183
|
-
expect(prompts[1].model).toBe('
|
|
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: '
|
|
290
|
-
{ taskId: 2, taskTitle: 'Test 2', prompt: 'Do task 2', agentType: 'general-purpose', model: '
|
|
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('
|
|
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('
|
|
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
|
|
501
|
-
expect(getModelForTask({ title: 'Add endpoint' })).toBe('
|
|
500
|
+
it('returns opus for standard tasks', () => {
|
|
501
|
+
expect(getModelForTask({ title: 'Add endpoint' })).toBe('opus');
|
|
502
502
|
});
|
|
503
503
|
|
|
504
|
-
it('returns
|
|
505
|
-
expect(getModelForTask({ title: 'Create enum' })).toBe('
|
|
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' }, '
|
|
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('
|
|
539
|
-
expect(MODEL_TIERS.LIGHT).toBe('
|
|
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('
|
|
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
|
+
});
|