tlc-claude-code 2.2.1 → 2.4.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.
Files changed (49) hide show
  1. package/.claude/agents/builder.md +17 -0
  2. package/.claude/commands/tlc/audit.md +12 -0
  3. package/.claude/commands/tlc/autofix.md +31 -0
  4. package/.claude/commands/tlc/build.md +98 -24
  5. package/.claude/commands/tlc/coverage.md +31 -0
  6. package/.claude/commands/tlc/discuss.md +31 -0
  7. package/.claude/commands/tlc/docs.md +31 -0
  8. package/.claude/commands/tlc/edge-cases.md +31 -0
  9. package/.claude/commands/tlc/guard.md +9 -0
  10. package/.claude/commands/tlc/init.md +12 -1
  11. package/.claude/commands/tlc/plan.md +31 -0
  12. package/.claude/commands/tlc/quick.md +31 -0
  13. package/.claude/commands/tlc/review.md +50 -0
  14. package/.claude/hooks/tlc-session-init.sh +14 -3
  15. package/CODING-STANDARDS.md +217 -10
  16. package/bin/setup-autoupdate.js +316 -87
  17. package/bin/setup-autoupdate.test.js +454 -34
  18. package/package.json +1 -1
  19. package/scripts/project-docs.js +1 -1
  20. package/server/lib/careful-patterns.js +142 -0
  21. package/server/lib/careful-patterns.test.js +164 -0
  22. package/server/lib/cli-dispatcher.js +98 -0
  23. package/server/lib/cli-dispatcher.test.js +249 -0
  24. package/server/lib/command-router.js +171 -0
  25. package/server/lib/command-router.test.js +336 -0
  26. package/server/lib/field-report.js +92 -0
  27. package/server/lib/field-report.test.js +195 -0
  28. package/server/lib/orchestration/worktree-manager.js +133 -0
  29. package/server/lib/orchestration/worktree-manager.test.js +198 -0
  30. package/server/lib/overdrive-command.js +31 -9
  31. package/server/lib/overdrive-command.test.js +25 -26
  32. package/server/lib/prompt-packager.js +98 -0
  33. package/server/lib/prompt-packager.test.js +185 -0
  34. package/server/lib/review-fixer.js +107 -0
  35. package/server/lib/review-fixer.test.js +152 -0
  36. package/server/lib/routing-command.js +159 -0
  37. package/server/lib/routing-command.test.js +290 -0
  38. package/server/lib/scope-checker.js +127 -0
  39. package/server/lib/scope-checker.test.js +175 -0
  40. package/server/lib/skill-validator.js +165 -0
  41. package/server/lib/skill-validator.test.js +289 -0
  42. package/server/lib/standards/standards-injector.js +6 -0
  43. package/server/lib/task-router-config.js +142 -0
  44. package/server/lib/task-router-config.test.js +428 -0
  45. package/server/lib/test-selector.js +127 -0
  46. package/server/lib/test-selector.test.js +172 -0
  47. package/server/setup.sh +271 -271
  48. package/server/templates/CLAUDE.md +6 -0
  49. 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 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,98 @@
1
+ /**
2
+ * Prompt Packager Module
3
+ * Serializes project context into a single prompt string for CLI dispatch to external LLMs.
4
+ */
5
+
6
+ const DEFAULT_TOKEN_BUDGET = 100000;
7
+ const TRUNCATION_MARKER = '...';
8
+
9
+ /**
10
+ * Format a source file as a labeled block for inclusion in the prompt.
11
+ * @param {string} filePath - Relative path to the file
12
+ * @param {string} content - File contents
13
+ * @returns {string} Formatted block with path header
14
+ */
15
+ function formatFileBlock(filePath, content) {
16
+ return `--- ${filePath} ---\n${content}`;
17
+ }
18
+
19
+ /**
20
+ * Truncate text to fit within a character budget.
21
+ * When truncation is needed, cuts at a character boundary and appends an ellipsis marker.
22
+ * @param {string} text - The text to truncate
23
+ * @param {number} budget - Maximum character count
24
+ * @returns {string} The text, truncated if necessary
25
+ */
26
+ function truncateToBudget(text, budget) {
27
+ if (budget <= 0) return '';
28
+ if (text.length <= budget) return text;
29
+ const cutLen = budget - TRUNCATION_MARKER.length;
30
+ if (cutLen <= 0) return text.slice(0, budget);
31
+ return text.slice(0, cutLen) + TRUNCATION_MARKER;
32
+ }
33
+
34
+ /**
35
+ * Package project context and an agent prompt into a single string for external LLM dispatch.
36
+ * @param {object} opts
37
+ * @param {string} opts.agentPrompt - The command's instruction text (required)
38
+ * @param {string|null} [opts.projectDoc] - Contents of PROJECT.md
39
+ * @param {string|null} [opts.planDoc] - Contents of current phase PLAN.md
40
+ * @param {string|null} [opts.codingStandards] - Contents of CODING-STANDARDS.md
41
+ * @param {Array<{path: string, content: string}>|null} [opts.files] - Relevant source files
42
+ * @param {number} [opts.tokenBudget=100000] - Maximum characters in output
43
+ * @returns {string} Serialized prompt string
44
+ */
45
+ function packagePrompt({
46
+ agentPrompt,
47
+ projectDoc = null,
48
+ planDoc = null,
49
+ codingStandards = null,
50
+ files = null,
51
+ tokenBudget = DEFAULT_TOKEN_BUDGET,
52
+ }) {
53
+ if (tokenBudget <= 0) return '';
54
+
55
+ // Pre-truncate large individual file contents to a per-file budget.
56
+ // Each file gets at most half the total budget to leave room for other sections.
57
+ const perFileBudget = Math.floor(tokenBudget / 2);
58
+ const sortedFiles =
59
+ files && files.length > 0
60
+ ? [...files]
61
+ .sort((a, b) => a.path.localeCompare(b.path))
62
+ .map((f) => ({
63
+ path: f.path,
64
+ content: truncateToBudget(f.content, perFileBudget),
65
+ }))
66
+ : null;
67
+
68
+ // Task section goes FIRST so truncation of context never drops the instruction
69
+ const sections = [];
70
+
71
+ sections.push(`--- Task ---\n${agentPrompt}`);
72
+
73
+ sections.push('\nYou are working on the following project:');
74
+
75
+ if (projectDoc != null) {
76
+ sections.push(`\n--- PROJECT.md ---\n${projectDoc}`);
77
+ }
78
+
79
+ if (planDoc != null) {
80
+ sections.push(`\n--- Current Phase Plan ---\n${planDoc}`);
81
+ }
82
+
83
+ if (codingStandards != null) {
84
+ sections.push(`\n--- Coding Standards ---\n${codingStandards}`);
85
+ }
86
+
87
+ if (sortedFiles && sortedFiles.length > 0) {
88
+ const fileBlocks = sortedFiles
89
+ .map((f) => formatFileBlock(f.path, f.content))
90
+ .join('\n\n');
91
+ sections.push(`\n--- Relevant Files ---\n\n${fileBlocks}`);
92
+ }
93
+
94
+ const full = sections.join('\n');
95
+ return truncateToBudget(full, tokenBudget);
96
+ }
97
+
98
+ module.exports = { packagePrompt, truncateToBudget, formatFileBlock };
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ packagePrompt,
4
+ truncateToBudget,
5
+ formatFileBlock,
6
+ } from './prompt-packager.js';
7
+
8
+ describe('prompt-packager', () => {
9
+ describe('formatFileBlock', () => {
10
+ it('produces correct header format', () => {
11
+ const result = formatFileBlock('src/auth/login.ts', 'const x = 1;');
12
+ expect(result).toBe('--- src/auth/login.ts ---\nconst x = 1;');
13
+ });
14
+
15
+ it('handles empty content', () => {
16
+ const result = formatFileBlock('empty.js', '');
17
+ expect(result).toBe('--- empty.js ---\n');
18
+ });
19
+ });
20
+
21
+ describe('truncateToBudget', () => {
22
+ it('returns text unchanged when within budget', () => {
23
+ const text = 'Hello world';
24
+ expect(truncateToBudget(text, 100)).toBe(text);
25
+ });
26
+
27
+ it('truncates at char boundary with ellipsis marker', () => {
28
+ const text = 'abcdefghij'; // 10 chars
29
+ const result = truncateToBudget(text, 8);
30
+ expect(result.length).toBeLessThanOrEqual(8);
31
+ expect(result).toContain('...');
32
+ });
33
+
34
+ it('returns empty string for budget of 0', () => {
35
+ expect(truncateToBudget('anything', 0)).toBe('');
36
+ });
37
+
38
+ it('handles text exactly at budget', () => {
39
+ const text = 'exact';
40
+ expect(truncateToBudget(text, 5)).toBe('exact');
41
+ });
42
+
43
+ it('handles empty text', () => {
44
+ expect(truncateToBudget('', 100)).toBe('');
45
+ });
46
+ });
47
+
48
+ describe('packagePrompt', () => {
49
+ const fullInput = {
50
+ agentPrompt: 'Implement the login feature',
51
+ projectDoc: '# My Project\nA cool project.',
52
+ planDoc: '## Phase 1\n- [ ] Task 1',
53
+ codingStandards: '## Standards\nUse JSDoc.',
54
+ files: [
55
+ { path: 'src/auth/login.ts', content: 'export function login() {}' },
56
+ { path: 'tests/auth/login.test.ts', content: 'test("login", () => {})' },
57
+ ],
58
+ };
59
+
60
+ it('packages all sections when everything provided', () => {
61
+ const result = packagePrompt(fullInput);
62
+ expect(result).toContain('You are working on the following project:');
63
+ expect(result).toContain('--- PROJECT.md ---');
64
+ expect(result).toContain('# My Project');
65
+ expect(result).toContain('--- Current Phase Plan ---');
66
+ expect(result).toContain('## Phase 1');
67
+ expect(result).toContain('--- Coding Standards ---');
68
+ expect(result).toContain('## Standards');
69
+ expect(result).toContain('--- Relevant Files ---');
70
+ expect(result).toContain('--- src/auth/login.ts ---');
71
+ expect(result).toContain('export function login() {}');
72
+ expect(result).toContain('--- tests/auth/login.test.ts ---');
73
+ expect(result).toContain('test("login", () => {})');
74
+ expect(result).toContain('--- Task ---');
75
+ expect(result).toContain('Implement the login feature');
76
+ });
77
+
78
+ it('omits PROJECT.md section when null', () => {
79
+ const result = packagePrompt({ ...fullInput, projectDoc: null });
80
+ expect(result).not.toContain('--- PROJECT.md ---');
81
+ expect(result).toContain('--- Current Phase Plan ---');
82
+ expect(result).toContain('--- Task ---');
83
+ });
84
+
85
+ it('omits plan section when null', () => {
86
+ const result = packagePrompt({ ...fullInput, planDoc: null });
87
+ expect(result).not.toContain('--- Current Phase Plan ---');
88
+ expect(result).toContain('--- PROJECT.md ---');
89
+ expect(result).toContain('--- Task ---');
90
+ });
91
+
92
+ it('omits coding standards when null', () => {
93
+ const result = packagePrompt({ ...fullInput, codingStandards: null });
94
+ expect(result).not.toContain('--- Coding Standards ---');
95
+ expect(result).toContain('--- PROJECT.md ---');
96
+ expect(result).toContain('--- Task ---');
97
+ });
98
+
99
+ it('omits files section when null', () => {
100
+ const result = packagePrompt({ ...fullInput, files: null });
101
+ expect(result).not.toContain('--- Relevant Files ---');
102
+ expect(result).toContain('--- PROJECT.md ---');
103
+ expect(result).toContain('--- Task ---');
104
+ });
105
+
106
+ it('omits files section when empty array', () => {
107
+ const result = packagePrompt({ ...fullInput, files: [] });
108
+ expect(result).not.toContain('--- Relevant Files ---');
109
+ expect(result).toContain('--- Task ---');
110
+ });
111
+
112
+ it('empty agentPrompt still produces valid output', () => {
113
+ const result = packagePrompt({ ...fullInput, agentPrompt: '' });
114
+ expect(result).toContain('--- Task ---');
115
+ expect(result).toContain('--- PROJECT.md ---');
116
+ });
117
+
118
+ it('files sorted by path for deterministic output', () => {
119
+ const result = packagePrompt({
120
+ agentPrompt: 'do it',
121
+ files: [
122
+ { path: 'z/last.js', content: 'last' },
123
+ { path: 'a/first.js', content: 'first' },
124
+ { path: 'm/middle.js', content: 'middle' },
125
+ ],
126
+ });
127
+ const aIdx = result.indexOf('--- a/first.js ---');
128
+ const mIdx = result.indexOf('--- m/middle.js ---');
129
+ const zIdx = result.indexOf('--- z/last.js ---');
130
+ expect(aIdx).toBeLessThan(mIdx);
131
+ expect(mIdx).toBeLessThan(zIdx);
132
+ });
133
+
134
+ it('truncates to budget when total exceeds limit', () => {
135
+ const result = packagePrompt({
136
+ ...fullInput,
137
+ tokenBudget: 50,
138
+ });
139
+ expect(result.length).toBeLessThanOrEqual(50);
140
+ });
141
+
142
+ it('budget of 0 returns empty string', () => {
143
+ const result = packagePrompt({ ...fullInput, tokenBudget: 0 });
144
+ expect(result).toBe('');
145
+ });
146
+
147
+ it('large individual files truncated before total budget check', () => {
148
+ const largeContent = 'x'.repeat(200000);
149
+ const result = packagePrompt({
150
+ agentPrompt: 'task',
151
+ files: [{ path: 'big.js', content: largeContent }],
152
+ tokenBudget: 1000,
153
+ });
154
+ expect(result.length).toBeLessThanOrEqual(1000);
155
+ // The file should be present but truncated
156
+ expect(result).toContain('--- big.js ---');
157
+ });
158
+
159
+ it('places task section before context so truncation never drops the instruction', () => {
160
+ const result = packagePrompt(fullInput);
161
+ const taskIdx = result.indexOf('--- Task ---');
162
+ const projectIdx = result.indexOf('--- PROJECT.md ---');
163
+ expect(taskIdx).toBeLessThan(projectIdx);
164
+ });
165
+
166
+ it('uses default tokenBudget of 100000 when not specified', () => {
167
+ const largeProject = 'x'.repeat(200000);
168
+ const result = packagePrompt({
169
+ agentPrompt: 'task',
170
+ projectDoc: largeProject,
171
+ });
172
+ expect(result.length).toBeLessThanOrEqual(100000);
173
+ });
174
+
175
+ it('handles minimal input with only agentPrompt', () => {
176
+ const result = packagePrompt({ agentPrompt: 'just do it' });
177
+ expect(result).toContain('--- Task ---');
178
+ expect(result).toContain('just do it');
179
+ expect(result).not.toContain('--- PROJECT.md ---');
180
+ expect(result).not.toContain('--- Current Phase Plan ---');
181
+ expect(result).not.toContain('--- Coding Standards ---');
182
+ expect(result).not.toContain('--- Relevant Files ---');
183
+ });
184
+ });
185
+ });
@@ -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
+ });