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
package/package.json
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-Commit Audit Hook
|
|
3
|
+
*
|
|
4
|
+
* Auto-runs architectural audit on first commit to catch
|
|
5
|
+
* AI-generated code issues before they accumulate.
|
|
6
|
+
* The "2-hour audit on day 1 saves 10 days" lesson.
|
|
7
|
+
*
|
|
8
|
+
* @module code-gate/first-commit-audit
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const defaultFs = require('fs').promises;
|
|
13
|
+
|
|
14
|
+
/** Marker file path relative to project root */
|
|
15
|
+
const MARKER_FILE = '.tlc/first-audit-done';
|
|
16
|
+
|
|
17
|
+
/** Fix suggestions by audit issue type */
|
|
18
|
+
const FIX_SUGGESTIONS = {
|
|
19
|
+
'hardcoded-url': 'Extract to environment variable using process.env',
|
|
20
|
+
'hardcoded-port': 'Extract port to environment variable',
|
|
21
|
+
'flat-folder': 'Reorganize into entity-based folder structure (src/{entity}/)',
|
|
22
|
+
'inline-interface': 'Extract interface to separate types file',
|
|
23
|
+
'magic-string': 'Replace with named constant',
|
|
24
|
+
'flat-seeds': 'Move seeds into per-entity seed folders',
|
|
25
|
+
'missing-jsdoc': 'Add JSDoc comment to exported function',
|
|
26
|
+
'deep-import': 'Use path aliases or restructure to reduce nesting',
|
|
27
|
+
'missing': 'Create required standards file',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if the first audit has already run
|
|
32
|
+
* @param {string} projectPath - Path to project root
|
|
33
|
+
* @param {Object} options - Injectable dependencies
|
|
34
|
+
* @param {Object} options.fs - File system module
|
|
35
|
+
* @returns {Promise<boolean>} True if marker exists
|
|
36
|
+
*/
|
|
37
|
+
async function hasFirstAuditRun(projectPath, options = {}) {
|
|
38
|
+
const fsModule = options.fs || defaultFs;
|
|
39
|
+
const markerPath = path.join(projectPath, MARKER_FILE);
|
|
40
|
+
try {
|
|
41
|
+
await fsModule.access(markerPath);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert audit results to gate findings
|
|
50
|
+
* @param {Object} auditResults - Results from auditProject()
|
|
51
|
+
* @returns {Array<Object>} Gate findings with severity: warn
|
|
52
|
+
*/
|
|
53
|
+
function convertAuditToFindings(auditResults) {
|
|
54
|
+
const findings = [];
|
|
55
|
+
|
|
56
|
+
const categories = [
|
|
57
|
+
'standardsFiles', 'flatFolders', 'inlineInterfaces',
|
|
58
|
+
'hardcodedUrls', 'magicStrings', 'seedOrganization',
|
|
59
|
+
'jsDocCoverage', 'importStyle',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
for (const category of categories) {
|
|
63
|
+
const result = auditResults[category];
|
|
64
|
+
if (!result || !result.issues) continue;
|
|
65
|
+
|
|
66
|
+
for (const issue of result.issues) {
|
|
67
|
+
findings.push({
|
|
68
|
+
severity: 'warn',
|
|
69
|
+
rule: `first-audit/${issue.type}`,
|
|
70
|
+
file: issue.file || issue.folder || 'project',
|
|
71
|
+
line: 0,
|
|
72
|
+
message: `First-commit audit: ${issue.type}${issue.value ? ` (${issue.value})` : ''}`,
|
|
73
|
+
fix: FIX_SUGGESTIONS[issue.type] || 'Review and fix manually',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run the first-commit audit
|
|
83
|
+
* @param {string} projectPath - Path to project root
|
|
84
|
+
* @param {Object} options - Injectable dependencies
|
|
85
|
+
* @param {Object} options.fs - File system module
|
|
86
|
+
* @param {Function} options.auditProject - Audit function from audit-checker
|
|
87
|
+
* @param {Object} options.config - Gate config
|
|
88
|
+
* @returns {Promise<Object>} Result with findings or skipped flag
|
|
89
|
+
*/
|
|
90
|
+
async function runFirstCommitAudit(projectPath, options = {}) {
|
|
91
|
+
const fsModule = options.fs || defaultFs;
|
|
92
|
+
const { auditProject, config } = options;
|
|
93
|
+
|
|
94
|
+
// Check if disabled via config
|
|
95
|
+
if (config && config.firstCommitAudit === false) {
|
|
96
|
+
return { skipped: true, reason: 'disabled' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check if already run
|
|
100
|
+
const alreadyRun = await hasFirstAuditRun(projectPath, { fs: fsModule });
|
|
101
|
+
if (alreadyRun) {
|
|
102
|
+
return { skipped: true, reason: 'already-run' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Run audit
|
|
106
|
+
const auditResults = await auditProject(projectPath, { fs: fsModule });
|
|
107
|
+
const findings = convertAuditToFindings(auditResults);
|
|
108
|
+
|
|
109
|
+
// Create marker file
|
|
110
|
+
const markerPath = path.join(projectPath, MARKER_FILE);
|
|
111
|
+
const markerDir = path.dirname(markerPath);
|
|
112
|
+
await fsModule.mkdir(markerDir, { recursive: true });
|
|
113
|
+
await fsModule.writeFile(markerPath, `First audit completed at ${new Date().toISOString()}\n`);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
skipped: false,
|
|
117
|
+
findings,
|
|
118
|
+
auditResults,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a first-commit audit instance with dependencies
|
|
124
|
+
* @param {Object} deps - Injectable dependencies
|
|
125
|
+
* @returns {Object} Audit instance with run method
|
|
126
|
+
*/
|
|
127
|
+
function createFirstCommitAudit(deps = {}) {
|
|
128
|
+
return {
|
|
129
|
+
run: (projectPath, config) => runFirstCommitAudit(projectPath, { ...deps, config }),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
createFirstCommitAudit,
|
|
135
|
+
hasFirstAuditRun,
|
|
136
|
+
convertAuditToFindings,
|
|
137
|
+
runFirstCommitAudit,
|
|
138
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-Commit Audit Hook Tests
|
|
3
|
+
*
|
|
4
|
+
* Auto-runs architectural audit on first commit to catch
|
|
5
|
+
* AI-generated code issues before they accumulate.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
createFirstCommitAudit,
|
|
11
|
+
hasFirstAuditRun,
|
|
12
|
+
convertAuditToFindings,
|
|
13
|
+
runFirstCommitAudit,
|
|
14
|
+
} = require('./first-commit-audit.js');
|
|
15
|
+
|
|
16
|
+
describe('First-Commit Audit Hook', () => {
|
|
17
|
+
describe('hasFirstAuditRun', () => {
|
|
18
|
+
it('returns false when no marker exists', async () => {
|
|
19
|
+
const mockFs = {
|
|
20
|
+
access: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
|
21
|
+
};
|
|
22
|
+
const result = await hasFirstAuditRun('/project', { fs: mockFs });
|
|
23
|
+
expect(result).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns true when marker exists', async () => {
|
|
27
|
+
const mockFs = {
|
|
28
|
+
access: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
};
|
|
30
|
+
const result = await hasFirstAuditRun('/project', { fs: mockFs });
|
|
31
|
+
expect(result).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('convertAuditToFindings', () => {
|
|
36
|
+
it('converts audit issues to gate findings with severity warn', () => {
|
|
37
|
+
const auditResults = {
|
|
38
|
+
hardcodedUrls: {
|
|
39
|
+
passed: false,
|
|
40
|
+
issues: [
|
|
41
|
+
{ type: 'hardcoded-url', file: 'src/api.js', value: 'http://localhost:3000' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
flatFolders: {
|
|
45
|
+
passed: false,
|
|
46
|
+
issues: [
|
|
47
|
+
{ type: 'flat-folder', folder: 'services' },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
summary: { totalIssues: 2, passed: false },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const findings = convertAuditToFindings(auditResults);
|
|
54
|
+
expect(findings).toHaveLength(2);
|
|
55
|
+
expect(findings[0].severity).toBe('warn');
|
|
56
|
+
expect(findings[1].severity).toBe('warn');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns correct severity for all findings', () => {
|
|
60
|
+
const auditResults = {
|
|
61
|
+
magicStrings: {
|
|
62
|
+
passed: false,
|
|
63
|
+
issues: [
|
|
64
|
+
{ type: 'magic-string', file: 'src/auth.js', value: 'admin' },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
summary: { totalIssues: 1, passed: false },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const findings = convertAuditToFindings(auditResults);
|
|
71
|
+
expect(findings.every(f => f.severity === 'warn')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('includes fix suggestions from audit', () => {
|
|
75
|
+
const auditResults = {
|
|
76
|
+
hardcodedUrls: {
|
|
77
|
+
passed: false,
|
|
78
|
+
issues: [
|
|
79
|
+
{ type: 'hardcoded-url', file: 'src/api.js', value: 'http://localhost:3000' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
summary: { totalIssues: 1, passed: false },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const findings = convertAuditToFindings(auditResults);
|
|
86
|
+
expect(findings[0].fix).toBeDefined();
|
|
87
|
+
expect(findings[0].fix.length).toBeGreaterThan(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns empty array for clean audit', () => {
|
|
91
|
+
const auditResults = {
|
|
92
|
+
hardcodedUrls: { passed: true, issues: [] },
|
|
93
|
+
flatFolders: { passed: true, issues: [] },
|
|
94
|
+
summary: { totalIssues: 0, passed: true },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const findings = convertAuditToFindings(auditResults);
|
|
98
|
+
expect(findings).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('handles multiple issues from same category', () => {
|
|
102
|
+
const auditResults = {
|
|
103
|
+
hardcodedUrls: {
|
|
104
|
+
passed: false,
|
|
105
|
+
issues: [
|
|
106
|
+
{ type: 'hardcoded-url', file: 'src/api.js', value: 'http://localhost:3000' },
|
|
107
|
+
{ type: 'hardcoded-url', file: 'src/config.js', value: 'http://localhost:5000' },
|
|
108
|
+
{ type: 'hardcoded-port', file: 'src/server.js', value: '8080' },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
summary: { totalIssues: 3, passed: false },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const findings = convertAuditToFindings(auditResults);
|
|
115
|
+
expect(findings).toHaveLength(3);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('runFirstCommitAudit', () => {
|
|
120
|
+
it('runs audit when no marker exists', async () => {
|
|
121
|
+
const mockFs = {
|
|
122
|
+
access: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
|
123
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
124
|
+
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
125
|
+
};
|
|
126
|
+
const mockAuditProject = vi.fn().mockResolvedValue({
|
|
127
|
+
hardcodedUrls: { passed: true, issues: [] },
|
|
128
|
+
summary: { totalIssues: 0, passed: true },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const result = await runFirstCommitAudit('/project', {
|
|
132
|
+
fs: mockFs,
|
|
133
|
+
auditProject: mockAuditProject,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(mockAuditProject).toHaveBeenCalledWith('/project', { fs: mockFs });
|
|
137
|
+
expect(result.findings).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('skips audit when marker exists', async () => {
|
|
141
|
+
const mockFs = {
|
|
142
|
+
access: vi.fn().mockResolvedValue(undefined),
|
|
143
|
+
};
|
|
144
|
+
const mockAuditProject = vi.fn();
|
|
145
|
+
|
|
146
|
+
const result = await runFirstCommitAudit('/project', {
|
|
147
|
+
fs: mockFs,
|
|
148
|
+
auditProject: mockAuditProject,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(mockAuditProject).not.toHaveBeenCalled();
|
|
152
|
+
expect(result.skipped).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('creates marker after successful run', async () => {
|
|
156
|
+
const mockFs = {
|
|
157
|
+
access: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
|
158
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
159
|
+
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
160
|
+
};
|
|
161
|
+
const mockAuditProject = vi.fn().mockResolvedValue({
|
|
162
|
+
summary: { totalIssues: 0, passed: true },
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await runFirstCommitAudit('/project', {
|
|
166
|
+
fs: mockFs,
|
|
167
|
+
auditProject: mockAuditProject,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
|
171
|
+
expect.stringContaining('first-audit-done'),
|
|
172
|
+
expect.any(String)
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('respects enabled/disabled config', async () => {
|
|
177
|
+
const mockFs = {
|
|
178
|
+
access: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
|
179
|
+
};
|
|
180
|
+
const mockAuditProject = vi.fn();
|
|
181
|
+
|
|
182
|
+
const result = await runFirstCommitAudit('/project', {
|
|
183
|
+
fs: mockFs,
|
|
184
|
+
auditProject: mockAuditProject,
|
|
185
|
+
config: { firstCommitAudit: false },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(mockAuditProject).not.toHaveBeenCalled();
|
|
189
|
+
expect(result.skipped).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('createFirstCommitAudit', () => {
|
|
194
|
+
it('works with injectable dependencies', () => {
|
|
195
|
+
const audit = createFirstCommitAudit({
|
|
196
|
+
fs: {},
|
|
197
|
+
auditProject: vi.fn(),
|
|
198
|
+
});
|
|
199
|
+
expect(audit).toBeDefined();
|
|
200
|
+
expect(audit.run).toBeDefined();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Model Reviewer
|
|
3
|
+
*
|
|
4
|
+
* Sends code reviews to 2+ LLM models and aggregates findings.
|
|
5
|
+
* Different models catch different bugs — consensus scoring
|
|
6
|
+
* highlights issues multiple models agree on.
|
|
7
|
+
*
|
|
8
|
+
* @module code-gate/multi-model-reviewer
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Severity priority for conflict resolution (higher wins) */
|
|
12
|
+
const SEVERITY_PRIORITY = { block: 3, warn: 2, info: 1 };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Send diff to multiple models in parallel
|
|
16
|
+
* @param {string} diff - Git diff content
|
|
17
|
+
* @param {string[]} models - List of model names
|
|
18
|
+
* @param {Object} options - Options
|
|
19
|
+
* @param {Function} options.reviewFn - Function(diff, model) => { findings, summary }
|
|
20
|
+
* @param {number} options.timeout - Per-model timeout in ms
|
|
21
|
+
* @returns {Promise<Array>} Results from successful models
|
|
22
|
+
*/
|
|
23
|
+
async function sendToModels(diff, models, options = {}) {
|
|
24
|
+
const { reviewFn, timeout } = options;
|
|
25
|
+
const results = [];
|
|
26
|
+
|
|
27
|
+
const promises = models.map(async (model) => {
|
|
28
|
+
try {
|
|
29
|
+
let reviewPromise = reviewFn(diff, model);
|
|
30
|
+
|
|
31
|
+
// Apply per-model timeout if specified
|
|
32
|
+
if (timeout) {
|
|
33
|
+
reviewPromise = Promise.race([
|
|
34
|
+
reviewPromise,
|
|
35
|
+
new Promise((_, reject) =>
|
|
36
|
+
setTimeout(() => reject(new Error(`Timeout for ${model}`)), timeout)
|
|
37
|
+
),
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = await reviewPromise;
|
|
42
|
+
return { model, ...result };
|
|
43
|
+
} catch {
|
|
44
|
+
return null; // Model failed, will be filtered out
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const settled = await Promise.all(promises);
|
|
49
|
+
for (const result of settled) {
|
|
50
|
+
if (result) results.push(result);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Aggregate and deduplicate findings from multiple models
|
|
58
|
+
* @param {Array} modelResults - Results from sendToModels
|
|
59
|
+
* @returns {Object} Aggregated result with deduplicated findings
|
|
60
|
+
*/
|
|
61
|
+
function aggregateFindings(modelResults) {
|
|
62
|
+
// Collect all findings with model attribution
|
|
63
|
+
const allFindings = [];
|
|
64
|
+
|
|
65
|
+
for (const result of modelResults) {
|
|
66
|
+
for (const finding of (result.findings || [])) {
|
|
67
|
+
allFindings.push({
|
|
68
|
+
...finding,
|
|
69
|
+
flaggedBy: [result.model],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Deduplicate
|
|
75
|
+
const deduped = deduplicateFindings(allFindings);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
findings: deduped,
|
|
79
|
+
modelCount: modelResults.length,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deduplicate findings by file+line+rule, merging flaggedBy lists
|
|
85
|
+
* @param {Array} findings - All findings with flaggedBy
|
|
86
|
+
* @returns {Array} Deduplicated findings
|
|
87
|
+
*/
|
|
88
|
+
function deduplicateFindings(findings) {
|
|
89
|
+
const map = new Map();
|
|
90
|
+
|
|
91
|
+
for (const finding of findings) {
|
|
92
|
+
const key = `${finding.file}:${finding.line}:${finding.rule}`;
|
|
93
|
+
|
|
94
|
+
if (map.has(key)) {
|
|
95
|
+
const existing = map.get(key);
|
|
96
|
+
// Merge flaggedBy
|
|
97
|
+
for (const model of finding.flaggedBy) {
|
|
98
|
+
if (!existing.flaggedBy.includes(model)) {
|
|
99
|
+
existing.flaggedBy.push(model);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Higher severity wins
|
|
103
|
+
const existingPriority = SEVERITY_PRIORITY[existing.severity] || 0;
|
|
104
|
+
const newPriority = SEVERITY_PRIORITY[finding.severity] || 0;
|
|
105
|
+
if (newPriority > existingPriority) {
|
|
106
|
+
existing.severity = finding.severity;
|
|
107
|
+
existing.message = finding.message;
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
map.set(key, { ...finding });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return Array.from(map.values());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Calculate consensus percentage for a finding
|
|
119
|
+
* @param {Object} finding - Finding with flaggedBy array
|
|
120
|
+
* @param {number} totalModels - Total models queried
|
|
121
|
+
* @returns {number} Consensus percentage (0-100)
|
|
122
|
+
*/
|
|
123
|
+
function calculateConsensus(finding, totalModels) {
|
|
124
|
+
if (totalModels === 0) return 0;
|
|
125
|
+
return (finding.flaggedBy.length / totalModels) * 100;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Merge summaries from all model results
|
|
130
|
+
* @param {Array} modelResults - Results with model and summary fields
|
|
131
|
+
* @returns {string} Merged summary
|
|
132
|
+
*/
|
|
133
|
+
function mergeSummaries(modelResults) {
|
|
134
|
+
return modelResults
|
|
135
|
+
.filter(r => r.summary)
|
|
136
|
+
.map(r => `[${r.model}]: ${r.summary}`)
|
|
137
|
+
.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a multi-model reviewer instance
|
|
142
|
+
* @param {Object} options - Configuration
|
|
143
|
+
* @param {string[]} options.models - Model names to use
|
|
144
|
+
* @param {Function} options.reviewFn - Review function
|
|
145
|
+
* @param {number} options.timeout - Per-model timeout
|
|
146
|
+
* @returns {Object} Reviewer instance
|
|
147
|
+
*/
|
|
148
|
+
function createMultiModelReviewer(options = {}) {
|
|
149
|
+
const { models = [], reviewFn, timeout } = options;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
models,
|
|
153
|
+
review: async (diff) => {
|
|
154
|
+
const results = await sendToModels(diff, models, { reviewFn, timeout });
|
|
155
|
+
if (results.length === 0) {
|
|
156
|
+
return { findings: [], summary: 'All models failed — static-only fallback', modelCount: 0 };
|
|
157
|
+
}
|
|
158
|
+
const aggregated = aggregateFindings(results);
|
|
159
|
+
const summary = mergeSummaries(results);
|
|
160
|
+
return { ...aggregated, summary };
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
createMultiModelReviewer,
|
|
167
|
+
sendToModels,
|
|
168
|
+
aggregateFindings,
|
|
169
|
+
deduplicateFindings,
|
|
170
|
+
calculateConsensus,
|
|
171
|
+
mergeSummaries,
|
|
172
|
+
};
|