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.
Files changed (105) hide show
  1. package/dashboard/dist/components/ContainerSecurityPane.d.ts +45 -0
  2. package/dashboard/dist/components/ContainerSecurityPane.js +44 -0
  3. package/dashboard/dist/components/ContainerSecurityPane.test.d.ts +1 -0
  4. package/dashboard/dist/components/ContainerSecurityPane.test.js +153 -0
  5. package/package.json +1 -1
  6. package/server/lib/access-control.test.js +1 -1
  7. package/server/lib/agents-cancel-command.test.js +1 -1
  8. package/server/lib/agents-get-command.test.js +1 -1
  9. package/server/lib/agents-list-command.test.js +1 -1
  10. package/server/lib/agents-logs-command.test.js +1 -1
  11. package/server/lib/agents-retry-command.test.js +1 -1
  12. package/server/lib/budget-limits.test.js +2 -2
  13. package/server/lib/code-gate/bypass-logger.js +129 -0
  14. package/server/lib/code-gate/bypass-logger.test.js +142 -0
  15. package/server/lib/code-gate/first-commit-audit.js +138 -0
  16. package/server/lib/code-gate/first-commit-audit.test.js +203 -0
  17. package/server/lib/code-gate/gate-command.js +114 -0
  18. package/server/lib/code-gate/gate-command.test.js +111 -0
  19. package/server/lib/code-gate/gate-config.js +163 -0
  20. package/server/lib/code-gate/gate-config.test.js +181 -0
  21. package/server/lib/code-gate/gate-engine.js +193 -0
  22. package/server/lib/code-gate/gate-engine.test.js +258 -0
  23. package/server/lib/code-gate/gate-reporter.js +123 -0
  24. package/server/lib/code-gate/gate-reporter.test.js +159 -0
  25. package/server/lib/code-gate/hooks-generator.js +149 -0
  26. package/server/lib/code-gate/hooks-generator.test.js +142 -0
  27. package/server/lib/code-gate/llm-reviewer.js +176 -0
  28. package/server/lib/code-gate/llm-reviewer.test.js +161 -0
  29. package/server/lib/code-gate/multi-model-reviewer.js +172 -0
  30. package/server/lib/code-gate/multi-model-reviewer.test.js +217 -0
  31. package/server/lib/code-gate/push-gate.js +133 -0
  32. package/server/lib/code-gate/push-gate.test.js +190 -0
  33. package/server/lib/code-gate/rules/architecture-rules.js +228 -0
  34. package/server/lib/code-gate/rules/architecture-rules.test.js +155 -0
  35. package/server/lib/code-gate/rules/client-rules.js +120 -0
  36. package/server/lib/code-gate/rules/client-rules.test.js +121 -0
  37. package/server/lib/code-gate/rules/config-rules.js +140 -0
  38. package/server/lib/code-gate/rules/config-rules.test.js +103 -0
  39. package/server/lib/code-gate/rules/database-rules.js +158 -0
  40. package/server/lib/code-gate/rules/database-rules.test.js +119 -0
  41. package/server/lib/code-gate/rules/docker-rules.js +201 -0
  42. package/server/lib/code-gate/rules/docker-rules.test.js +104 -0
  43. package/server/lib/code-gate/rules/quality-rules.js +304 -0
  44. package/server/lib/code-gate/rules/quality-rules.test.js +199 -0
  45. package/server/lib/code-gate/rules/security-rules.js +228 -0
  46. package/server/lib/code-gate/rules/security-rules.test.js +131 -0
  47. package/server/lib/code-gate/rules/structure-rules.js +155 -0
  48. package/server/lib/code-gate/rules/structure-rules.test.js +107 -0
  49. package/server/lib/code-gate/rules/test-rules.js +93 -0
  50. package/server/lib/code-gate/rules/test-rules.test.js +97 -0
  51. package/server/lib/code-gate/typescript-gate.js +128 -0
  52. package/server/lib/code-gate/typescript-gate.test.js +131 -0
  53. package/server/lib/code-generator.test.js +1 -1
  54. package/server/lib/cost-command.test.js +1 -1
  55. package/server/lib/cost-optimizer.test.js +1 -1
  56. package/server/lib/cost-projections.test.js +1 -1
  57. package/server/lib/cost-reports.test.js +1 -1
  58. package/server/lib/cost-tracker.test.js +1 -1
  59. package/server/lib/crypto-patterns.test.js +1 -1
  60. package/server/lib/design-command.test.js +1 -1
  61. package/server/lib/design-parser.test.js +1 -1
  62. package/server/lib/gemini-vision.test.js +1 -1
  63. package/server/lib/infra/infra-generator.js +331 -0
  64. package/server/lib/infra/infra-generator.test.js +146 -0
  65. package/server/lib/input-validator.test.js +1 -1
  66. package/server/lib/litellm-client.test.js +1 -1
  67. package/server/lib/litellm-command.test.js +1 -1
  68. package/server/lib/litellm-config.test.js +1 -1
  69. package/server/lib/llm/adapters/api-adapter.js +95 -0
  70. package/server/lib/llm/adapters/api-adapter.test.js +81 -0
  71. package/server/lib/llm/adapters/codex-adapter.js +85 -0
  72. package/server/lib/llm/adapters/codex-adapter.test.js +54 -0
  73. package/server/lib/llm/adapters/gemini-adapter.js +100 -0
  74. package/server/lib/llm/adapters/gemini-adapter.test.js +54 -0
  75. package/server/lib/llm/index.js +109 -0
  76. package/server/lib/llm/index.test.js +147 -0
  77. package/server/lib/llm/provider-executor.js +168 -0
  78. package/server/lib/llm/provider-executor.test.js +244 -0
  79. package/server/lib/llm/provider-registry.js +104 -0
  80. package/server/lib/llm/provider-registry.test.js +157 -0
  81. package/server/lib/llm/review-service.js +222 -0
  82. package/server/lib/llm/review-service.test.js +220 -0
  83. package/server/lib/model-pricing.test.js +1 -1
  84. package/server/lib/models-command.test.js +1 -1
  85. package/server/lib/optimize-command.test.js +1 -1
  86. package/server/lib/orchestration-integration.test.js +1 -1
  87. package/server/lib/output-encoder.test.js +1 -1
  88. package/server/lib/quality-evaluator.test.js +1 -1
  89. package/server/lib/quality-gate-command.test.js +1 -1
  90. package/server/lib/quality-gate-scorer.test.js +1 -1
  91. package/server/lib/quality-history.test.js +1 -1
  92. package/server/lib/quality-presets.test.js +1 -1
  93. package/server/lib/quality-retry.test.js +1 -1
  94. package/server/lib/quality-thresholds.test.js +1 -1
  95. package/server/lib/secure-auth.test.js +1 -1
  96. package/server/lib/secure-code-command.test.js +1 -1
  97. package/server/lib/secure-errors.test.js +1 -1
  98. package/server/lib/security/auth-security.test.js +4 -3
  99. package/server/lib/shame/shame-registry.js +224 -0
  100. package/server/lib/shame/shame-registry.test.js +202 -0
  101. package/server/lib/standards/cleanup-dry-run.js +254 -0
  102. package/server/lib/standards/cleanup-dry-run.test.js +220 -0
  103. package/server/lib/vision-command.test.js +1 -1
  104. package/server/lib/visual-command.test.js +1 -1
  105. 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
+ };