tlc-claude-code 1.6.4 → 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/dashboard/dist/components/ContainerSecurityPane.d.ts +45 -0
- package/dashboard/dist/components/ContainerSecurityPane.js +44 -0
- package/dashboard/dist/components/ContainerSecurityPane.test.d.ts +1 -0
- package/dashboard/dist/components/ContainerSecurityPane.test.js +153 -0
- package/package.json +1 -1
- package/server/lib/access-control.test.js +1 -1
- package/server/lib/agents-cancel-command.test.js +1 -1
- package/server/lib/agents-get-command.test.js +1 -1
- package/server/lib/agents-list-command.test.js +1 -1
- package/server/lib/agents-logs-command.test.js +1 -1
- package/server/lib/agents-retry-command.test.js +1 -1
- package/server/lib/budget-limits.test.js +2 -2
- package/server/lib/code-gate/bypass-logger.js +129 -0
- package/server/lib/code-gate/bypass-logger.test.js +142 -0
- 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/gate-command.js +114 -0
- package/server/lib/code-gate/gate-command.test.js +111 -0
- package/server/lib/code-gate/gate-config.js +163 -0
- package/server/lib/code-gate/gate-config.test.js +181 -0
- package/server/lib/code-gate/gate-engine.js +193 -0
- package/server/lib/code-gate/gate-engine.test.js +258 -0
- package/server/lib/code-gate/gate-reporter.js +123 -0
- package/server/lib/code-gate/gate-reporter.test.js +159 -0
- package/server/lib/code-gate/hooks-generator.js +149 -0
- package/server/lib/code-gate/hooks-generator.test.js +142 -0
- package/server/lib/code-gate/llm-reviewer.js +176 -0
- package/server/lib/code-gate/llm-reviewer.test.js +161 -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/code-gate/push-gate.js +133 -0
- package/server/lib/code-gate/push-gate.test.js +190 -0
- package/server/lib/code-gate/rules/architecture-rules.js +228 -0
- package/server/lib/code-gate/rules/architecture-rules.test.js +155 -0
- package/server/lib/code-gate/rules/client-rules.js +120 -0
- package/server/lib/code-gate/rules/client-rules.test.js +121 -0
- package/server/lib/code-gate/rules/config-rules.js +140 -0
- package/server/lib/code-gate/rules/config-rules.test.js +103 -0
- package/server/lib/code-gate/rules/database-rules.js +158 -0
- package/server/lib/code-gate/rules/database-rules.test.js +119 -0
- package/server/lib/code-gate/rules/docker-rules.js +201 -0
- package/server/lib/code-gate/rules/docker-rules.test.js +104 -0
- package/server/lib/code-gate/rules/quality-rules.js +304 -0
- package/server/lib/code-gate/rules/quality-rules.test.js +199 -0
- package/server/lib/code-gate/rules/security-rules.js +228 -0
- package/server/lib/code-gate/rules/security-rules.test.js +131 -0
- package/server/lib/code-gate/rules/structure-rules.js +155 -0
- package/server/lib/code-gate/rules/structure-rules.test.js +107 -0
- package/server/lib/code-gate/rules/test-rules.js +93 -0
- package/server/lib/code-gate/rules/test-rules.test.js +97 -0
- package/server/lib/code-gate/typescript-gate.js +128 -0
- package/server/lib/code-gate/typescript-gate.test.js +131 -0
- package/server/lib/code-generator.test.js +1 -1
- package/server/lib/cost-command.test.js +1 -1
- package/server/lib/cost-optimizer.test.js +1 -1
- package/server/lib/cost-projections.test.js +1 -1
- package/server/lib/cost-reports.test.js +1 -1
- package/server/lib/cost-tracker.test.js +1 -1
- package/server/lib/crypto-patterns.test.js +1 -1
- package/server/lib/design-command.test.js +1 -1
- package/server/lib/design-parser.test.js +1 -1
- package/server/lib/gemini-vision.test.js +1 -1
- package/server/lib/infra/infra-generator.js +331 -0
- package/server/lib/infra/infra-generator.test.js +146 -0
- package/server/lib/input-validator.test.js +1 -1
- package/server/lib/litellm-client.test.js +1 -1
- package/server/lib/litellm-command.test.js +1 -1
- package/server/lib/litellm-config.test.js +1 -1
- 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/model-pricing.test.js +1 -1
- package/server/lib/models-command.test.js +1 -1
- package/server/lib/optimize-command.test.js +1 -1
- package/server/lib/orchestration-integration.test.js +1 -1
- package/server/lib/output-encoder.test.js +1 -1
- package/server/lib/quality-evaluator.test.js +1 -1
- package/server/lib/quality-gate-command.test.js +1 -1
- package/server/lib/quality-gate-scorer.test.js +1 -1
- package/server/lib/quality-history.test.js +1 -1
- package/server/lib/quality-presets.test.js +1 -1
- package/server/lib/quality-retry.test.js +1 -1
- package/server/lib/quality-thresholds.test.js +1 -1
- package/server/lib/secure-auth.test.js +1 -1
- package/server/lib/secure-code-command.test.js +1 -1
- package/server/lib/secure-errors.test.js +1 -1
- package/server/lib/security/auth-security.test.js +4 -3
- 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/server/lib/vision-command.test.js +1 -1
- package/server/lib/visual-command.test.js +1 -1
- package/server/lib/visual-testing.test.js +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks Generator Tests
|
|
3
|
+
*
|
|
4
|
+
* Generates and installs git hooks that run the code gate.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
generatePreCommitHook,
|
|
10
|
+
generatePrePushHook,
|
|
11
|
+
installHooks,
|
|
12
|
+
isHookInstalled,
|
|
13
|
+
} = require('./hooks-generator.js');
|
|
14
|
+
|
|
15
|
+
describe('Hooks Generator', () => {
|
|
16
|
+
describe('generatePreCommitHook', () => {
|
|
17
|
+
it('generates a valid shell script', () => {
|
|
18
|
+
const script = generatePreCommitHook();
|
|
19
|
+
expect(script).toContain('#!/bin/sh');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('includes gate check command', () => {
|
|
23
|
+
const script = generatePreCommitHook();
|
|
24
|
+
expect(script).toContain('tlc-gate');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('exits non-zero on gate failure', () => {
|
|
28
|
+
const script = generatePreCommitHook();
|
|
29
|
+
expect(script).toContain('exit 1');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is portable sh, not bash-specific', () => {
|
|
33
|
+
const script = generatePreCommitHook();
|
|
34
|
+
expect(script).not.toContain('#!/bin/bash');
|
|
35
|
+
expect(script).not.toContain('[['); // bash-specific test syntax
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('includes bypass detection', () => {
|
|
39
|
+
const script = generatePreCommitHook();
|
|
40
|
+
// The hook should detect if it was bypassed (for audit logging)
|
|
41
|
+
expect(script).toContain('pre-commit');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('generatePrePushHook', () => {
|
|
46
|
+
it('generates a valid shell script', () => {
|
|
47
|
+
const script = generatePrePushHook();
|
|
48
|
+
expect(script).toContain('#!/bin/sh');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('includes both static and LLM review', () => {
|
|
52
|
+
const script = generatePrePushHook();
|
|
53
|
+
expect(script).toContain('tlc-gate');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('exits non-zero on gate failure', () => {
|
|
57
|
+
const script = generatePrePushHook();
|
|
58
|
+
expect(script).toContain('exit 1');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('installHooks', () => {
|
|
63
|
+
it('writes pre-commit hook to .git/hooks/', async () => {
|
|
64
|
+
let writtenPath = null;
|
|
65
|
+
let writtenContent = null;
|
|
66
|
+
const mockFs = {
|
|
67
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
68
|
+
writeFileSync: vi.fn((path, content) => {
|
|
69
|
+
writtenPath = path;
|
|
70
|
+
writtenContent = content;
|
|
71
|
+
}),
|
|
72
|
+
chmodSync: vi.fn(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await installHooks('/project', { fs: mockFs, hooks: ['pre-commit'] });
|
|
76
|
+
expect(writtenPath).toContain('.git/hooks/pre-commit');
|
|
77
|
+
expect(writtenContent).toContain('#!/bin/sh');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('makes hook executable', async () => {
|
|
81
|
+
const mockFs = {
|
|
82
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
83
|
+
writeFileSync: vi.fn(),
|
|
84
|
+
chmodSync: vi.fn(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
await installHooks('/project', { fs: mockFs, hooks: ['pre-commit'] });
|
|
88
|
+
expect(mockFs.chmodSync).toHaveBeenCalledWith(
|
|
89
|
+
expect.stringContaining('pre-commit'),
|
|
90
|
+
'755'
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('installs both hooks by default', async () => {
|
|
95
|
+
const written = [];
|
|
96
|
+
const mockFs = {
|
|
97
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
98
|
+
writeFileSync: vi.fn((path) => written.push(path)),
|
|
99
|
+
chmodSync: vi.fn(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
await installHooks('/project', { fs: mockFs });
|
|
103
|
+
expect(written).toHaveLength(2);
|
|
104
|
+
expect(written.some(p => p.includes('pre-commit'))).toBe(true);
|
|
105
|
+
expect(written.some(p => p.includes('pre-push'))).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('throws when .git directory missing', async () => {
|
|
109
|
+
const mockFs = {
|
|
110
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await expect(installHooks('/project', { fs: mockFs }))
|
|
114
|
+
.rejects.toThrow('Not a git repository');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('isHookInstalled', () => {
|
|
119
|
+
it('returns true when TLC hook exists', () => {
|
|
120
|
+
const mockFs = {
|
|
121
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
122
|
+
readFileSync: vi.fn().mockReturnValue('#!/bin/sh\n# TLC Code Gate\ntlc-gate check'),
|
|
123
|
+
};
|
|
124
|
+
expect(isHookInstalled('/project', 'pre-commit', { fs: mockFs })).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('returns false when no hook file', () => {
|
|
128
|
+
const mockFs = {
|
|
129
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
130
|
+
};
|
|
131
|
+
expect(isHookInstalled('/project', 'pre-commit', { fs: mockFs })).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns false when hook is not from TLC', () => {
|
|
135
|
+
const mockFs = {
|
|
136
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
137
|
+
readFileSync: vi.fn().mockReturnValue('#!/bin/sh\nhusky run'),
|
|
138
|
+
};
|
|
139
|
+
expect(isHookInstalled('/project', 'pre-commit', { fs: mockFs })).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Reviewer
|
|
3
|
+
*
|
|
4
|
+
* Mandatory LLM-powered code review before every push.
|
|
5
|
+
* Collects diff, sends to LLM via model router, parses structured result.
|
|
6
|
+
* Falls back to static-only review if no LLM is available.
|
|
7
|
+
*
|
|
8
|
+
* @module code-gate/llm-reviewer
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
|
|
14
|
+
/** Default timeout for LLM review in milliseconds */
|
|
15
|
+
const DEFAULT_TIMEOUT = 60000;
|
|
16
|
+
|
|
17
|
+
/** File extensions that are docs-only (skip LLM review) */
|
|
18
|
+
const DOCS_EXTENSIONS = ['.md', '.txt', '.rst', '.adoc'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Severity mapping from LLM response levels to gate levels.
|
|
22
|
+
* LLM may use critical/high/medium/low; we normalize to block/warn/info.
|
|
23
|
+
*/
|
|
24
|
+
const SEVERITY_MAP = {
|
|
25
|
+
critical: 'block',
|
|
26
|
+
high: 'block',
|
|
27
|
+
medium: 'warn',
|
|
28
|
+
low: 'info',
|
|
29
|
+
block: 'block',
|
|
30
|
+
warn: 'warn',
|
|
31
|
+
info: 'info',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a reviewer instance with configurable options.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} [options]
|
|
38
|
+
* @param {number} [options.timeout] - LLM request timeout in ms
|
|
39
|
+
* @returns {{ options: Object }}
|
|
40
|
+
*/
|
|
41
|
+
function createReviewer(options = {}) {
|
|
42
|
+
return {
|
|
43
|
+
options: {
|
|
44
|
+
timeout: options.timeout || DEFAULT_TIMEOUT,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the review prompt to send to the LLM.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} diff - Git diff content
|
|
53
|
+
* @param {string} standards - CODING-STANDARDS.md content
|
|
54
|
+
* @returns {string} Complete review prompt
|
|
55
|
+
*/
|
|
56
|
+
function buildReviewPrompt(diff, standards) {
|
|
57
|
+
return `You are a strict code reviewer. Review this diff against the project's coding standards.
|
|
58
|
+
|
|
59
|
+
${standards ? `## Coding Standards\n${standards}\n\n` : ''}## Diff to Review
|
|
60
|
+
\`\`\`
|
|
61
|
+
${diff}
|
|
62
|
+
\`\`\`
|
|
63
|
+
|
|
64
|
+
## Instructions
|
|
65
|
+
For each issue found, respond with a JSON object:
|
|
66
|
+
\`\`\`json
|
|
67
|
+
{
|
|
68
|
+
"findings": [
|
|
69
|
+
{
|
|
70
|
+
"severity": "critical|high|medium|low",
|
|
71
|
+
"file": "affected file path",
|
|
72
|
+
"line": 0,
|
|
73
|
+
"rule": "which standard is violated",
|
|
74
|
+
"message": "clear description",
|
|
75
|
+
"fix": "how to fix it"
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"summary": "brief overall assessment"
|
|
79
|
+
}
|
|
80
|
+
\`\`\`
|
|
81
|
+
|
|
82
|
+
Be STRICT. Block on: security issues, missing tests, hardcoded secrets, major anti-patterns.
|
|
83
|
+
If the code is clean, return an empty findings array.
|
|
84
|
+
Respond ONLY with the JSON object.`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse the LLM response into a structured review result.
|
|
89
|
+
* Handles raw JSON, markdown-wrapped JSON, and unparseable responses.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} response - Raw LLM response text
|
|
92
|
+
* @returns {{ findings: Array, summary?: string }}
|
|
93
|
+
*/
|
|
94
|
+
function parseReviewResponse(response) {
|
|
95
|
+
// Try to extract JSON from markdown code block
|
|
96
|
+
const codeBlockMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
97
|
+
const jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : response.trim();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(jsonStr);
|
|
101
|
+
const findings = (parsed.findings || []).map(f => ({
|
|
102
|
+
...f,
|
|
103
|
+
severity: SEVERITY_MAP[f.severity] || 'warn',
|
|
104
|
+
}));
|
|
105
|
+
return { findings, summary: parsed.summary };
|
|
106
|
+
} catch {
|
|
107
|
+
return {
|
|
108
|
+
findings: [{
|
|
109
|
+
severity: 'warn',
|
|
110
|
+
rule: 'llm-parse-error',
|
|
111
|
+
file: 'unknown',
|
|
112
|
+
message: 'Could not parse LLM review response',
|
|
113
|
+
fix: 'Run review manually with /tlc:review',
|
|
114
|
+
}],
|
|
115
|
+
summary: 'Review response could not be parsed',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Collect the git diff for review.
|
|
122
|
+
*
|
|
123
|
+
* @param {Object} [options]
|
|
124
|
+
* @param {Function} [options.exec] - Command execution function
|
|
125
|
+
* @returns {Promise<string>} Diff content
|
|
126
|
+
*/
|
|
127
|
+
async function collectDiff(options = {}) {
|
|
128
|
+
const exec = options.exec;
|
|
129
|
+
if (!exec) return '';
|
|
130
|
+
return await exec('git diff origin/main..HEAD');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if the review should be skipped (docs-only changes).
|
|
135
|
+
*
|
|
136
|
+
* @param {string[]} files - Changed file paths
|
|
137
|
+
* @returns {boolean} True if all changes are docs-only
|
|
138
|
+
*/
|
|
139
|
+
function shouldSkipReview(files) {
|
|
140
|
+
if (files.length === 0) return true;
|
|
141
|
+
return files.every(f => {
|
|
142
|
+
const ext = path.extname(f).toLowerCase();
|
|
143
|
+
return DOCS_EXTENSIONS.includes(ext);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Store a review result to disk for audit trail.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} commitHash - Commit hash as filename
|
|
151
|
+
* @param {Object} result - Review result to store
|
|
152
|
+
* @param {Object} [options]
|
|
153
|
+
* @param {Object} [options.fs] - File system module
|
|
154
|
+
* @param {string} [options.projectPath] - Project root
|
|
155
|
+
*/
|
|
156
|
+
function storeReviewResult(commitHash, result, options = {}) {
|
|
157
|
+
const fsModule = options.fs || fs;
|
|
158
|
+
const projectPath = options.projectPath || process.cwd();
|
|
159
|
+
const reviewsDir = path.join(projectPath, '.tlc', 'reviews');
|
|
160
|
+
|
|
161
|
+
if (!fsModule.existsSync(reviewsDir)) {
|
|
162
|
+
fsModule.mkdirSync(reviewsDir, { recursive: true });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const filePath = path.join(reviewsDir, `${commitHash}.json`);
|
|
166
|
+
fsModule.writeFileSync(filePath, JSON.stringify(result, null, 2));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
createReviewer,
|
|
171
|
+
buildReviewPrompt,
|
|
172
|
+
parseReviewResponse,
|
|
173
|
+
collectDiff,
|
|
174
|
+
shouldSkipReview,
|
|
175
|
+
storeReviewResult,
|
|
176
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Reviewer Tests
|
|
3
|
+
*
|
|
4
|
+
* Mandatory LLM code review before every push, using multi-model router.
|
|
5
|
+
* Collects diff, sends to LLM, parses structured review result.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
createReviewer,
|
|
11
|
+
buildReviewPrompt,
|
|
12
|
+
parseReviewResponse,
|
|
13
|
+
collectDiff,
|
|
14
|
+
shouldSkipReview,
|
|
15
|
+
storeReviewResult,
|
|
16
|
+
} = require('./llm-reviewer.js');
|
|
17
|
+
|
|
18
|
+
describe('LLM Reviewer', () => {
|
|
19
|
+
describe('createReviewer', () => {
|
|
20
|
+
it('creates reviewer with default options', () => {
|
|
21
|
+
const reviewer = createReviewer();
|
|
22
|
+
expect(reviewer).toBeDefined();
|
|
23
|
+
expect(reviewer.options.timeout).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('accepts custom timeout', () => {
|
|
27
|
+
const reviewer = createReviewer({ timeout: 30000 });
|
|
28
|
+
expect(reviewer.options.timeout).toBe(30000);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('buildReviewPrompt', () => {
|
|
33
|
+
it('includes diff in prompt', () => {
|
|
34
|
+
const prompt = buildReviewPrompt('--- a/file.js\n+++ b/file.js\n+const x = 1;', '');
|
|
35
|
+
expect(prompt).toContain('file.js');
|
|
36
|
+
expect(prompt).toContain('const x = 1');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('includes coding standards in prompt', () => {
|
|
40
|
+
const standards = '# Coding Standards\n- No hardcoded URLs';
|
|
41
|
+
const prompt = buildReviewPrompt('diff content', standards);
|
|
42
|
+
expect(prompt).toContain('No hardcoded URLs');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('instructs strict review', () => {
|
|
46
|
+
const prompt = buildReviewPrompt('diff', '');
|
|
47
|
+
expect(prompt.toLowerCase()).toContain('strict');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('requests structured JSON output', () => {
|
|
51
|
+
const prompt = buildReviewPrompt('diff', '');
|
|
52
|
+
expect(prompt).toContain('JSON');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('parseReviewResponse', () => {
|
|
57
|
+
it('parses valid JSON review', () => {
|
|
58
|
+
const response = JSON.stringify({
|
|
59
|
+
findings: [
|
|
60
|
+
{ severity: 'high', file: 'src/app.js', line: 10, rule: 'security', message: 'XSS risk', fix: 'Sanitize' },
|
|
61
|
+
],
|
|
62
|
+
summary: 'Found 1 issue',
|
|
63
|
+
});
|
|
64
|
+
const result = parseReviewResponse(response);
|
|
65
|
+
expect(result.findings).toHaveLength(1);
|
|
66
|
+
expect(result.findings[0].severity).toBe('block'); // high normalizes to block
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles response with JSON in markdown code block', () => {
|
|
70
|
+
const response = '```json\n{"findings": [], "summary": "All clear"}\n```';
|
|
71
|
+
const result = parseReviewResponse(response);
|
|
72
|
+
expect(result.findings).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns error finding on unparseable response', () => {
|
|
76
|
+
const result = parseReviewResponse('I could not review this code because...');
|
|
77
|
+
expect(result.findings).toHaveLength(1);
|
|
78
|
+
expect(result.findings[0].severity).toBe('warn');
|
|
79
|
+
expect(result.findings[0].rule).toBe('llm-parse-error');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('normalizes severity levels', () => {
|
|
83
|
+
const response = JSON.stringify({
|
|
84
|
+
findings: [
|
|
85
|
+
{ severity: 'critical', file: 'x.js', message: 'Bad', fix: 'Fix' },
|
|
86
|
+
{ severity: 'low', file: 'y.js', message: 'Minor', fix: 'Maybe' },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const result = parseReviewResponse(response);
|
|
90
|
+
// critical and high map to 'block', medium and low map to 'warn'
|
|
91
|
+
expect(result.findings[0].severity).toBe('block');
|
|
92
|
+
expect(result.findings[1].severity).toBe('info');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('collectDiff', () => {
|
|
97
|
+
it('returns diff from exec', async () => {
|
|
98
|
+
const mockExec = vi.fn().mockResolvedValue('diff --git a/file.js\n+line');
|
|
99
|
+
const diff = await collectDiff({ exec: mockExec });
|
|
100
|
+
expect(diff).toContain('file.js');
|
|
101
|
+
expect(mockExec).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns empty string when no changes', async () => {
|
|
105
|
+
const mockExec = vi.fn().mockResolvedValue('');
|
|
106
|
+
const diff = await collectDiff({ exec: mockExec });
|
|
107
|
+
expect(diff).toBe('');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('shouldSkipReview', () => {
|
|
112
|
+
it('skips docs-only changes', () => {
|
|
113
|
+
const files = ['README.md', 'docs/guide.md', 'CHANGELOG.md'];
|
|
114
|
+
expect(shouldSkipReview(files)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not skip code changes', () => {
|
|
118
|
+
const files = ['src/app.js', 'README.md'];
|
|
119
|
+
expect(shouldSkipReview(files)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('does not skip config changes', () => {
|
|
123
|
+
const files = ['package.json', '.env.example'];
|
|
124
|
+
expect(shouldSkipReview(files)).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('storeReviewResult', () => {
|
|
129
|
+
it('writes review to .tlc/reviews/{hash}.json', () => {
|
|
130
|
+
let writtenPath = '';
|
|
131
|
+
let writtenData = '';
|
|
132
|
+
const mockFs = {
|
|
133
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
134
|
+
mkdirSync: vi.fn(),
|
|
135
|
+
writeFileSync: vi.fn((path, data) => {
|
|
136
|
+
writtenPath = path;
|
|
137
|
+
writtenData = data;
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const result = { findings: [], summary: 'Clean' };
|
|
142
|
+
storeReviewResult('abc123', result, { fs: mockFs });
|
|
143
|
+
|
|
144
|
+
expect(writtenPath).toContain('abc123.json');
|
|
145
|
+
expect(writtenPath).toContain('reviews');
|
|
146
|
+
const parsed = JSON.parse(writtenData);
|
|
147
|
+
expect(parsed.summary).toBe('Clean');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('creates reviews directory if missing', () => {
|
|
151
|
+
const mockFs = {
|
|
152
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
153
|
+
mkdirSync: vi.fn(),
|
|
154
|
+
writeFileSync: vi.fn(),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
storeReviewResult('xyz', { findings: [] }, { fs: mockFs });
|
|
158
|
+
expect(mockFs.mkdirSync).toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -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
|
+
};
|