tlc-claude-code 1.7.0 → 1.8.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/package.json +1 -1
- package/server/lib/code-gate/first-commit-audit.js +138 -0
- package/server/lib/code-gate/first-commit-audit.test.js +203 -0
- package/server/lib/code-gate/multi-model-reviewer.js +172 -0
- package/server/lib/code-gate/multi-model-reviewer.test.js +217 -0
- package/server/lib/infra/infra-generator.js +331 -0
- package/server/lib/infra/infra-generator.test.js +146 -0
- package/server/lib/llm/adapters/api-adapter.js +95 -0
- package/server/lib/llm/adapters/api-adapter.test.js +81 -0
- package/server/lib/llm/adapters/codex-adapter.js +85 -0
- package/server/lib/llm/adapters/codex-adapter.test.js +54 -0
- package/server/lib/llm/adapters/gemini-adapter.js +100 -0
- package/server/lib/llm/adapters/gemini-adapter.test.js +54 -0
- package/server/lib/llm/index.js +109 -0
- package/server/lib/llm/index.test.js +147 -0
- package/server/lib/llm/provider-executor.js +168 -0
- package/server/lib/llm/provider-executor.test.js +244 -0
- package/server/lib/llm/provider-registry.js +104 -0
- package/server/lib/llm/provider-registry.test.js +157 -0
- package/server/lib/llm/review-service.js +222 -0
- package/server/lib/llm/review-service.test.js +220 -0
- package/server/lib/shame/shame-registry.js +224 -0
- package/server/lib/shame/shame-registry.test.js +202 -0
- package/server/lib/standards/cleanup-dry-run.js +254 -0
- package/server/lib/standards/cleanup-dry-run.test.js +220 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Registry Tests
|
|
3
|
+
*
|
|
4
|
+
* Runtime registry of available providers with health status.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
createRegistry,
|
|
10
|
+
} = require('./provider-registry.js');
|
|
11
|
+
|
|
12
|
+
describe('Provider Registry', () => {
|
|
13
|
+
let registry;
|
|
14
|
+
let mockHealthCheck;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockHealthCheck = vi.fn().mockResolvedValue({ available: true, version: '1.0.0' });
|
|
18
|
+
registry = createRegistry({ healthCheck: mockHealthCheck });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('register', () => {
|
|
22
|
+
it('registers CLI provider', () => {
|
|
23
|
+
registry.register({
|
|
24
|
+
name: 'codex',
|
|
25
|
+
type: 'cli',
|
|
26
|
+
command: 'codex',
|
|
27
|
+
capabilities: ['review', 'code-gen'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const providers = registry.list();
|
|
31
|
+
expect(providers).toHaveLength(1);
|
|
32
|
+
expect(providers[0].name).toBe('codex');
|
|
33
|
+
expect(providers[0].type).toBe('cli');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('registers API provider', () => {
|
|
37
|
+
registry.register({
|
|
38
|
+
name: 'litellm',
|
|
39
|
+
type: 'api',
|
|
40
|
+
url: 'http://localhost:4000',
|
|
41
|
+
capabilities: ['review'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const providers = registry.list();
|
|
45
|
+
expect(providers).toHaveLength(1);
|
|
46
|
+
expect(providers[0].type).toBe('api');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('health', () => {
|
|
51
|
+
it('health check passes for available CLI', async () => {
|
|
52
|
+
mockHealthCheck.mockResolvedValue({ available: true, version: '2.1.0' });
|
|
53
|
+
registry.register({ name: 'codex', type: 'cli', command: 'codex', capabilities: ['review'] });
|
|
54
|
+
|
|
55
|
+
const status = await registry.checkHealth('codex');
|
|
56
|
+
expect(status.available).toBe(true);
|
|
57
|
+
expect(status.version).toBe('2.1.0');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('health check fails for missing CLI', async () => {
|
|
61
|
+
mockHealthCheck.mockResolvedValue({ available: false, error: 'not found' });
|
|
62
|
+
registry.register({ name: 'codex', type: 'cli', command: 'codex', capabilities: ['review'] });
|
|
63
|
+
|
|
64
|
+
const status = await registry.checkHealth('codex');
|
|
65
|
+
expect(status.available).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('health check passes for reachable API', async () => {
|
|
69
|
+
mockHealthCheck.mockResolvedValue({ available: true, latency: 50 });
|
|
70
|
+
registry.register({ name: 'litellm', type: 'api', url: 'http://localhost:4000', capabilities: ['review'] });
|
|
71
|
+
|
|
72
|
+
const status = await registry.checkHealth('litellm');
|
|
73
|
+
expect(status.available).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('health check fails for unreachable API', async () => {
|
|
77
|
+
mockHealthCheck.mockResolvedValue({ available: false, error: 'connection refused' });
|
|
78
|
+
registry.register({ name: 'litellm', type: 'api', url: 'http://localhost:4000', capabilities: ['review'] });
|
|
79
|
+
|
|
80
|
+
const status = await registry.checkHealth('litellm');
|
|
81
|
+
expect(status.available).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('capability lookup', () => {
|
|
86
|
+
it('lists providers by capability', () => {
|
|
87
|
+
registry.register({ name: 'codex', type: 'cli', command: 'codex', capabilities: ['review', 'code-gen'] });
|
|
88
|
+
registry.register({ name: 'gemini', type: 'cli', command: 'gemini', capabilities: ['design', 'review'] });
|
|
89
|
+
registry.register({ name: 'litellm', type: 'api', url: 'http://localhost:4000', capabilities: ['review'] });
|
|
90
|
+
|
|
91
|
+
const reviewProviders = registry.getByCapability('review');
|
|
92
|
+
expect(reviewProviders).toHaveLength(3);
|
|
93
|
+
|
|
94
|
+
const designProviders = registry.getByCapability('design');
|
|
95
|
+
expect(designProviders).toHaveLength(1);
|
|
96
|
+
expect(designProviders[0].name).toBe('gemini');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns best available provider (first healthy in priority order)', async () => {
|
|
100
|
+
mockHealthCheck
|
|
101
|
+
.mockResolvedValueOnce({ available: false }) // codex unhealthy
|
|
102
|
+
.mockResolvedValueOnce({ available: true, version: '1.0' }); // gemini healthy
|
|
103
|
+
|
|
104
|
+
registry.register({ name: 'codex', type: 'cli', command: 'codex', capabilities: ['review'], priority: 1 });
|
|
105
|
+
registry.register({ name: 'gemini', type: 'cli', command: 'gemini', capabilities: ['review'], priority: 2 });
|
|
106
|
+
|
|
107
|
+
const best = await registry.getBestProvider('review');
|
|
108
|
+
expect(best.name).toBe('gemini');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('health cache', () => {
|
|
113
|
+
it('caches health status within TTL', async () => {
|
|
114
|
+
registry = createRegistry({ healthCheck: mockHealthCheck, cacheTTL: 5000 });
|
|
115
|
+
registry.register({ name: 'codex', type: 'cli', command: 'codex', capabilities: ['review'] });
|
|
116
|
+
|
|
117
|
+
await registry.checkHealth('codex');
|
|
118
|
+
await registry.checkHealth('codex');
|
|
119
|
+
|
|
120
|
+
expect(mockHealthCheck).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refreshes status after TTL expires', async () => {
|
|
124
|
+
registry = createRegistry({ healthCheck: mockHealthCheck, cacheTTL: 10 });
|
|
125
|
+
registry.register({ name: 'codex', type: 'cli', command: 'codex', capabilities: ['review'] });
|
|
126
|
+
|
|
127
|
+
await registry.checkHealth('codex');
|
|
128
|
+
await new Promise(r => setTimeout(r, 20));
|
|
129
|
+
await registry.checkHealth('codex');
|
|
130
|
+
|
|
131
|
+
expect(mockHealthCheck).toHaveBeenCalledTimes(2);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('config loading', () => {
|
|
136
|
+
it('loads providers from config', () => {
|
|
137
|
+
const config = {
|
|
138
|
+
providers: {
|
|
139
|
+
codex: { type: 'cli', command: 'codex', capabilities: ['review'] },
|
|
140
|
+
gemini: { type: 'cli', command: 'gemini', capabilities: ['design'] },
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const reg = createRegistry({ healthCheck: mockHealthCheck });
|
|
145
|
+
reg.loadFromConfig(config);
|
|
146
|
+
|
|
147
|
+
expect(reg.list()).toHaveLength(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('handles empty config gracefully', () => {
|
|
151
|
+
const reg = createRegistry({ healthCheck: mockHealthCheck });
|
|
152
|
+
reg.loadFromConfig({});
|
|
153
|
+
|
|
154
|
+
expect(reg.list()).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Review Service
|
|
3
|
+
*
|
|
4
|
+
* One service: router → executor → prompt → parse → findings.
|
|
5
|
+
* Replaces the disconnected pieces with a single coherent flow.
|
|
6
|
+
*
|
|
7
|
+
* @module llm/review-service
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
/** Docs-only extensions that skip LLM review */
|
|
13
|
+
const DOCS_EXTENSIONS = ['.md', '.txt', '.rst', '.adoc'];
|
|
14
|
+
|
|
15
|
+
/** Severity mapping from LLM levels to gate levels */
|
|
16
|
+
const SEVERITY_MAP = {
|
|
17
|
+
critical: 'block',
|
|
18
|
+
high: 'block',
|
|
19
|
+
medium: 'warn',
|
|
20
|
+
low: 'info',
|
|
21
|
+
block: 'block',
|
|
22
|
+
warn: 'warn',
|
|
23
|
+
info: 'info',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if review should be skipped (docs-only)
|
|
28
|
+
* @param {string[]} files - Changed file paths
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
function shouldSkip(files) {
|
|
32
|
+
if (!files || files.length === 0) return false;
|
|
33
|
+
return files.every(f => {
|
|
34
|
+
const ext = path.extname(f).toLowerCase();
|
|
35
|
+
return DOCS_EXTENSIONS.includes(ext);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build review prompt
|
|
41
|
+
* @param {string} diff - Git diff
|
|
42
|
+
* @param {string} standards - Coding standards content
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function buildPrompt(diff, standards) {
|
|
46
|
+
let prompt = 'You are a strict code reviewer. Review this diff';
|
|
47
|
+
if (standards) {
|
|
48
|
+
prompt += ' against these coding standards:\n\n' + standards + '\n\n';
|
|
49
|
+
}
|
|
50
|
+
prompt += '\n\nDiff:\n```\n' + diff + '\n```\n\n';
|
|
51
|
+
prompt += 'Respond with JSON: {"findings": [{"severity": "critical|high|medium|low", "file": "path", "line": 0, "rule": "name", "message": "desc", "fix": "how"}], "summary": "text"}\n';
|
|
52
|
+
prompt += 'Respond ONLY with JSON.';
|
|
53
|
+
return prompt;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse LLM response to structured findings
|
|
58
|
+
* @param {string} response - Raw LLM response
|
|
59
|
+
* @returns {Object} { findings, summary }
|
|
60
|
+
*/
|
|
61
|
+
function parseResponse(response) {
|
|
62
|
+
const codeBlockMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
63
|
+
const jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : response.trim();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(jsonStr);
|
|
67
|
+
const findings = (parsed.findings || []).map(f => ({
|
|
68
|
+
...f,
|
|
69
|
+
severity: SEVERITY_MAP[f.severity] || 'warn',
|
|
70
|
+
}));
|
|
71
|
+
return { findings, summary: parsed.summary || '' };
|
|
72
|
+
} catch {
|
|
73
|
+
return { findings: [], summary: response.substring(0, 200) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Deduplicate findings by file+line+rule, merge flaggedBy
|
|
79
|
+
* @param {Array} findings - All findings
|
|
80
|
+
* @returns {Array} Deduplicated
|
|
81
|
+
*/
|
|
82
|
+
function deduplicateFindings(findings) {
|
|
83
|
+
const PRIORITY = { block: 3, warn: 2, info: 1 };
|
|
84
|
+
const map = new Map();
|
|
85
|
+
|
|
86
|
+
for (const finding of findings) {
|
|
87
|
+
const key = finding.file + ':' + finding.line + ':' + finding.rule;
|
|
88
|
+
if (map.has(key)) {
|
|
89
|
+
const existing = map.get(key);
|
|
90
|
+
for (const m of (finding.flaggedBy || [])) {
|
|
91
|
+
if (!existing.flaggedBy.includes(m)) existing.flaggedBy.push(m);
|
|
92
|
+
}
|
|
93
|
+
if ((PRIORITY[finding.severity] || 0) > (PRIORITY[existing.severity] || 0)) {
|
|
94
|
+
existing.severity = finding.severity;
|
|
95
|
+
existing.message = finding.message;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
map.set(key, { ...finding, flaggedBy: finding.flaggedBy || [] });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Array.from(map.values());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create the unified review service
|
|
107
|
+
* @param {Object} options - Service options
|
|
108
|
+
* @param {Object} options.registry - Provider registry
|
|
109
|
+
* @param {Object} options.executor - Provider executor
|
|
110
|
+
* @param {boolean} options.multiModel - Enable multi-model mode
|
|
111
|
+
* @param {number} options.timeout - Review timeout
|
|
112
|
+
* @param {string} options.standards - Coding standards text
|
|
113
|
+
* @returns {Object} Service with review method
|
|
114
|
+
*/
|
|
115
|
+
function createReviewService(options = {}) {
|
|
116
|
+
const { registry, executor, multiModel = false, timeout, standards = '' } = options;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
/**
|
|
120
|
+
* Review a diff
|
|
121
|
+
* @param {string} diff - Git diff content
|
|
122
|
+
* @param {Object} reviewOptions - Per-review options
|
|
123
|
+
* @returns {Promise<Object>} { findings, summary, provider, latency }
|
|
124
|
+
*/
|
|
125
|
+
async review(diff, reviewOptions = {}) {
|
|
126
|
+
// Skip docs-only
|
|
127
|
+
if (shouldSkip(reviewOptions.files)) {
|
|
128
|
+
return { findings: [], summary: 'Skipped (docs-only)', skipped: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// No registry or executor — return empty
|
|
132
|
+
if (!registry || !executor) {
|
|
133
|
+
return { findings: [], summary: 'No providers configured' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const prompt = buildPrompt(diff, standards);
|
|
137
|
+
const providers = registry.getByCapability('review');
|
|
138
|
+
|
|
139
|
+
if (providers.length === 0) {
|
|
140
|
+
return { findings: [], summary: 'No review providers available — static-only fallback' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Multi-model: fan out to all providers
|
|
144
|
+
if (multiModel && providers.length > 1) {
|
|
145
|
+
return await reviewMultiModel(prompt, providers, executor, timeout);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Single-model: try providers in order until one works
|
|
149
|
+
return await reviewSingleModel(prompt, providers, executor, timeout);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Single-model review: try providers in priority order
|
|
156
|
+
*/
|
|
157
|
+
async function reviewSingleModel(prompt, providers, executor, timeout) {
|
|
158
|
+
const sorted = [...providers].sort((a, b) => (a.priority || 99) - (b.priority || 99));
|
|
159
|
+
|
|
160
|
+
for (const provider of sorted) {
|
|
161
|
+
try {
|
|
162
|
+
const result = await executor.execute(prompt, { ...provider, timeout });
|
|
163
|
+
const parsed = parseResponse(result.response);
|
|
164
|
+
return {
|
|
165
|
+
findings: parsed.findings,
|
|
166
|
+
summary: parsed.summary,
|
|
167
|
+
provider: provider.name,
|
|
168
|
+
latency: result.latency,
|
|
169
|
+
};
|
|
170
|
+
} catch {
|
|
171
|
+
// Try next provider
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// All failed
|
|
177
|
+
return { findings: [], summary: 'All providers failed — static-only fallback' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Multi-model review: fan out to all, aggregate
|
|
182
|
+
*/
|
|
183
|
+
async function reviewMultiModel(prompt, providers, executor, timeout) {
|
|
184
|
+
const results = await Promise.allSettled(
|
|
185
|
+
providers.map(async (provider) => {
|
|
186
|
+
const result = await executor.execute(prompt, { ...provider, timeout });
|
|
187
|
+
const parsed = parseResponse(result.response);
|
|
188
|
+
return {
|
|
189
|
+
model: provider.name,
|
|
190
|
+
findings: parsed.findings.map(f => ({ ...f, flaggedBy: [provider.name] })),
|
|
191
|
+
summary: parsed.summary,
|
|
192
|
+
latency: result.latency,
|
|
193
|
+
};
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const successful = results
|
|
198
|
+
.filter(r => r.status === 'fulfilled')
|
|
199
|
+
.map(r => r.value);
|
|
200
|
+
|
|
201
|
+
if (successful.length === 0) {
|
|
202
|
+
return { findings: [], summary: 'All providers failed — static-only fallback' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Aggregate findings
|
|
206
|
+
const allFindings = successful.flatMap(r => r.findings);
|
|
207
|
+
const deduped = deduplicateFindings(allFindings);
|
|
208
|
+
|
|
209
|
+
// Merge summaries
|
|
210
|
+
const summaryParts = successful.map(r => '[' + r.model + ']: ' + r.summary);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
findings: deduped,
|
|
214
|
+
summary: summaryParts.join('\n'),
|
|
215
|
+
providers: successful.map(r => r.model),
|
|
216
|
+
latency: Math.max(...successful.map(r => r.latency)),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
createReviewService,
|
|
222
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Review Service Tests
|
|
3
|
+
*
|
|
4
|
+
* One service: router → executor → prompt → parse → findings.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
createReviewService,
|
|
10
|
+
} = require('./review-service.js');
|
|
11
|
+
|
|
12
|
+
describe('Review Service', () => {
|
|
13
|
+
const makeMockRegistry = (providers = []) => ({
|
|
14
|
+
getByCapability: vi.fn().mockReturnValue(providers),
|
|
15
|
+
getBestProvider: vi.fn().mockResolvedValue(providers[0] || null),
|
|
16
|
+
checkHealth: vi.fn().mockResolvedValue({ available: true }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const makeMockExecutor = (response = '{"findings": [], "summary": "Clean"}') => ({
|
|
20
|
+
execute: vi.fn().mockResolvedValue({
|
|
21
|
+
response,
|
|
22
|
+
model: 'test-model',
|
|
23
|
+
latency: 100,
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('single-model review', () => {
|
|
28
|
+
it('routes to configured provider', async () => {
|
|
29
|
+
const registry = makeMockRegistry([
|
|
30
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
31
|
+
]);
|
|
32
|
+
const executor = makeMockExecutor();
|
|
33
|
+
|
|
34
|
+
const service = createReviewService({ registry, executor });
|
|
35
|
+
const result = await service.review('diff content');
|
|
36
|
+
|
|
37
|
+
expect(result.provider).toBe('codex');
|
|
38
|
+
expect(result.findings).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns findings from one provider', async () => {
|
|
42
|
+
const registry = makeMockRegistry([
|
|
43
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
44
|
+
]);
|
|
45
|
+
const executor = makeMockExecutor(
|
|
46
|
+
'{"findings": [{"severity": "high", "file": "a.js", "line": 1, "rule": "xss", "message": "XSS risk", "fix": "sanitize"}], "summary": "1 issue"}'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const service = createReviewService({ registry, executor });
|
|
50
|
+
const result = await service.review('diff content');
|
|
51
|
+
|
|
52
|
+
expect(result.findings).toHaveLength(1);
|
|
53
|
+
expect(result.findings[0].severity).toBe('block');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('falls back to next provider on failure', async () => {
|
|
57
|
+
const providers = [
|
|
58
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
59
|
+
{ name: 'gemini', type: 'cli', command: 'gemini' },
|
|
60
|
+
];
|
|
61
|
+
const registry = makeMockRegistry(providers);
|
|
62
|
+
registry.getBestProvider = vi.fn().mockResolvedValue(providers[0]);
|
|
63
|
+
|
|
64
|
+
const executor = {
|
|
65
|
+
execute: vi.fn()
|
|
66
|
+
.mockRejectedValueOnce(new Error('codex failed'))
|
|
67
|
+
.mockResolvedValueOnce({
|
|
68
|
+
response: '{"findings": [], "summary": "OK"}',
|
|
69
|
+
model: 'gemini',
|
|
70
|
+
latency: 200,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const service = createReviewService({ registry, executor });
|
|
75
|
+
const result = await service.review('diff');
|
|
76
|
+
|
|
77
|
+
expect(result.provider).toBe('gemini');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('includes provider name in result', async () => {
|
|
81
|
+
const registry = makeMockRegistry([
|
|
82
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
83
|
+
]);
|
|
84
|
+
const executor = makeMockExecutor();
|
|
85
|
+
|
|
86
|
+
const service = createReviewService({ registry, executor });
|
|
87
|
+
const result = await service.review('diff');
|
|
88
|
+
|
|
89
|
+
expect(result.provider).toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('includes latency in result', async () => {
|
|
93
|
+
const registry = makeMockRegistry([
|
|
94
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
95
|
+
]);
|
|
96
|
+
const executor = makeMockExecutor();
|
|
97
|
+
|
|
98
|
+
const service = createReviewService({ registry, executor });
|
|
99
|
+
const result = await service.review('diff');
|
|
100
|
+
|
|
101
|
+
expect(result.latency).toBeDefined();
|
|
102
|
+
expect(typeof result.latency).toBe('number');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('multi-model review', () => {
|
|
107
|
+
it('fans out to all review providers', async () => {
|
|
108
|
+
const providers = [
|
|
109
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
110
|
+
{ name: 'gemini', type: 'cli', command: 'gemini' },
|
|
111
|
+
];
|
|
112
|
+
const registry = makeMockRegistry(providers);
|
|
113
|
+
const executor = makeMockExecutor();
|
|
114
|
+
|
|
115
|
+
const service = createReviewService({ registry, executor, multiModel: true });
|
|
116
|
+
const result = await service.review('diff');
|
|
117
|
+
|
|
118
|
+
expect(executor.execute).toHaveBeenCalledTimes(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('aggregates multi-model findings with deduplication', async () => {
|
|
122
|
+
const providers = [
|
|
123
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
124
|
+
{ name: 'gemini', type: 'cli', command: 'gemini' },
|
|
125
|
+
];
|
|
126
|
+
const registry = makeMockRegistry(providers);
|
|
127
|
+
|
|
128
|
+
const executor = {
|
|
129
|
+
execute: vi.fn()
|
|
130
|
+
.mockResolvedValueOnce({
|
|
131
|
+
response: '{"findings": [{"severity": "warn", "file": "a.js", "line": 1, "rule": "no-console", "message": "console"}], "summary": "A"}',
|
|
132
|
+
model: 'codex',
|
|
133
|
+
latency: 100,
|
|
134
|
+
})
|
|
135
|
+
.mockResolvedValueOnce({
|
|
136
|
+
response: '{"findings": [{"severity": "warn", "file": "a.js", "line": 1, "rule": "no-console", "message": "console log"}], "summary": "B"}',
|
|
137
|
+
model: 'gemini',
|
|
138
|
+
latency: 150,
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const service = createReviewService({ registry, executor, multiModel: true });
|
|
143
|
+
const result = await service.review('diff');
|
|
144
|
+
|
|
145
|
+
// Same file+line+rule → deduplicated to 1 finding
|
|
146
|
+
expect(result.findings).toHaveLength(1);
|
|
147
|
+
expect(result.findings[0].flaggedBy).toContain('codex');
|
|
148
|
+
expect(result.findings[0].flaggedBy).toContain('gemini');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('edge cases', () => {
|
|
153
|
+
it('skips docs-only changes', async () => {
|
|
154
|
+
const registry = makeMockRegistry([
|
|
155
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
156
|
+
]);
|
|
157
|
+
const executor = makeMockExecutor();
|
|
158
|
+
|
|
159
|
+
const service = createReviewService({ registry, executor });
|
|
160
|
+
const result = await service.review('diff --git a/README.md b/README.md', {
|
|
161
|
+
files: ['README.md', 'docs/guide.md'],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result.skipped).toBe(true);
|
|
165
|
+
expect(executor.execute).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('handles all providers failing (static-only fallback)', async () => {
|
|
169
|
+
const registry = makeMockRegistry([
|
|
170
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
171
|
+
]);
|
|
172
|
+
const executor = {
|
|
173
|
+
execute: vi.fn().mockRejectedValue(new Error('all fail')),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const service = createReviewService({ registry, executor });
|
|
177
|
+
const result = await service.review('diff');
|
|
178
|
+
|
|
179
|
+
expect(result.findings).toEqual([]);
|
|
180
|
+
expect(result.summary).toContain('static-only');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('works with no config (sensible defaults)', () => {
|
|
184
|
+
const service = createReviewService({});
|
|
185
|
+
expect(service).toBeDefined();
|
|
186
|
+
expect(service.review).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('provider order from config determines priority', async () => {
|
|
190
|
+
const providers = [
|
|
191
|
+
{ name: 'codex', type: 'cli', command: 'codex', priority: 1 },
|
|
192
|
+
{ name: 'gemini', type: 'cli', command: 'gemini', priority: 2 },
|
|
193
|
+
];
|
|
194
|
+
const registry = makeMockRegistry(providers);
|
|
195
|
+
const executor = makeMockExecutor();
|
|
196
|
+
|
|
197
|
+
const service = createReviewService({ registry, executor });
|
|
198
|
+
await service.review('diff');
|
|
199
|
+
|
|
200
|
+
// Should try codex first (priority 1)
|
|
201
|
+
const firstCall = executor.execute.mock.calls[0];
|
|
202
|
+
expect(firstCall[1].command || firstCall[1].name).toBeDefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('respects timeout from config', async () => {
|
|
206
|
+
const registry = makeMockRegistry([
|
|
207
|
+
{ name: 'codex', type: 'cli', command: 'codex' },
|
|
208
|
+
]);
|
|
209
|
+
const executor = makeMockExecutor();
|
|
210
|
+
|
|
211
|
+
const service = createReviewService({ registry, executor, timeout: 30000 });
|
|
212
|
+
await service.review('diff');
|
|
213
|
+
|
|
214
|
+
expect(executor.execute).toHaveBeenCalledWith(
|
|
215
|
+
expect.any(String),
|
|
216
|
+
expect.objectContaining({ timeout: 30000 })
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|