tlc-claude-code 1.6.4 → 1.7.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/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/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/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/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/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,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate Reporter Tests
|
|
3
|
+
*
|
|
4
|
+
* Formats gate results into clear, actionable terminal output
|
|
5
|
+
* with severity badges, fix suggestions, and summary.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
formatReport,
|
|
11
|
+
formatSummary,
|
|
12
|
+
formatFinding,
|
|
13
|
+
groupByFile,
|
|
14
|
+
} = require('./gate-reporter.js');
|
|
15
|
+
|
|
16
|
+
describe('Gate Reporter', () => {
|
|
17
|
+
describe('formatFinding', () => {
|
|
18
|
+
it('formats a block finding with line number', () => {
|
|
19
|
+
const finding = {
|
|
20
|
+
severity: 'block',
|
|
21
|
+
rule: 'no-eval',
|
|
22
|
+
file: 'src/app.js',
|
|
23
|
+
line: 12,
|
|
24
|
+
message: "eval() is not allowed",
|
|
25
|
+
fix: 'Use a safe alternative',
|
|
26
|
+
};
|
|
27
|
+
const output = formatFinding(finding);
|
|
28
|
+
expect(output).toContain('[BLOCK]');
|
|
29
|
+
expect(output).toContain('no-eval');
|
|
30
|
+
expect(output).toContain('line 12');
|
|
31
|
+
expect(output).toContain('eval()');
|
|
32
|
+
expect(output).toContain('Use a safe alternative');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('formats a warn finding', () => {
|
|
36
|
+
const finding = {
|
|
37
|
+
severity: 'warn',
|
|
38
|
+
rule: 'max-function-length',
|
|
39
|
+
file: 'src/big.js',
|
|
40
|
+
line: 45,
|
|
41
|
+
message: 'Function too long',
|
|
42
|
+
fix: 'Extract helper functions',
|
|
43
|
+
};
|
|
44
|
+
const output = formatFinding(finding);
|
|
45
|
+
expect(output).toContain('[WARN]');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('formats info finding', () => {
|
|
49
|
+
const finding = {
|
|
50
|
+
severity: 'info',
|
|
51
|
+
rule: 'docs-hint',
|
|
52
|
+
file: 'src/x.js',
|
|
53
|
+
message: 'Consider adding docs',
|
|
54
|
+
fix: 'Add JSDoc',
|
|
55
|
+
};
|
|
56
|
+
const output = formatFinding(finding);
|
|
57
|
+
expect(output).toContain('[INFO]');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles finding without line number', () => {
|
|
61
|
+
const finding = {
|
|
62
|
+
severity: 'block',
|
|
63
|
+
rule: 'require-test-file',
|
|
64
|
+
file: 'src/x.js',
|
|
65
|
+
message: 'No test file found',
|
|
66
|
+
fix: 'Create test file',
|
|
67
|
+
};
|
|
68
|
+
const output = formatFinding(finding);
|
|
69
|
+
expect(output).not.toContain('line');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('groupByFile', () => {
|
|
74
|
+
it('groups findings by file path', () => {
|
|
75
|
+
const findings = [
|
|
76
|
+
{ file: 'a.js', rule: 'r1', severity: 'block', message: 'A' },
|
|
77
|
+
{ file: 'b.js', rule: 'r2', severity: 'warn', message: 'B' },
|
|
78
|
+
{ file: 'a.js', rule: 'r3', severity: 'info', message: 'C' },
|
|
79
|
+
];
|
|
80
|
+
const groups = groupByFile(findings);
|
|
81
|
+
expect(Object.keys(groups)).toHaveLength(2);
|
|
82
|
+
expect(groups['a.js']).toHaveLength(2);
|
|
83
|
+
expect(groups['b.js']).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('formatSummary', () => {
|
|
88
|
+
it('shows blocking count and pass status', () => {
|
|
89
|
+
const summary = { total: 0, block: 0, warn: 0, info: 0 };
|
|
90
|
+
const output = formatSummary(summary, true);
|
|
91
|
+
expect(output).toContain('passed');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('shows blocking message when blocked', () => {
|
|
95
|
+
const summary = { total: 3, block: 2, warn: 1, info: 0 };
|
|
96
|
+
const output = formatSummary(summary, false);
|
|
97
|
+
expect(output).toContain('2 blocking');
|
|
98
|
+
expect(output).toContain('1 warning');
|
|
99
|
+
expect(output).toContain('blocked');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('formatReport', () => {
|
|
104
|
+
it('formats complete report with header', () => {
|
|
105
|
+
const result = {
|
|
106
|
+
passed: false,
|
|
107
|
+
findings: [
|
|
108
|
+
{ file: 'src/app.js', severity: 'block', rule: 'no-eval', line: 5, message: 'eval found', fix: 'Remove eval' },
|
|
109
|
+
],
|
|
110
|
+
summary: { total: 1, block: 1, warn: 0, info: 0 },
|
|
111
|
+
};
|
|
112
|
+
const output = formatReport(result);
|
|
113
|
+
expect(output).toContain('Code Gate');
|
|
114
|
+
expect(output).toContain('src/app.js');
|
|
115
|
+
expect(output).toContain('[BLOCK]');
|
|
116
|
+
expect(output).toContain('blocked');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('formats all-clear report', () => {
|
|
120
|
+
const result = {
|
|
121
|
+
passed: true,
|
|
122
|
+
findings: [],
|
|
123
|
+
summary: { total: 0, block: 0, warn: 0, info: 0 },
|
|
124
|
+
};
|
|
125
|
+
const output = formatReport(result);
|
|
126
|
+
expect(output).toContain('passed');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('groups findings by file in report', () => {
|
|
130
|
+
const result = {
|
|
131
|
+
passed: false,
|
|
132
|
+
findings: [
|
|
133
|
+
{ file: 'a.js', severity: 'block', rule: 'r1', message: 'X', fix: 'Y' },
|
|
134
|
+
{ file: 'b.js', severity: 'block', rule: 'r2', message: 'X', fix: 'Y' },
|
|
135
|
+
{ file: 'a.js', severity: 'warn', rule: 'r3', message: 'X', fix: 'Y' },
|
|
136
|
+
],
|
|
137
|
+
summary: { total: 3, block: 2, warn: 1, info: 0 },
|
|
138
|
+
};
|
|
139
|
+
const output = formatReport(result);
|
|
140
|
+
// a.js should appear before its findings
|
|
141
|
+
const aIndex = output.indexOf('a.js');
|
|
142
|
+
const bIndex = output.indexOf('b.js');
|
|
143
|
+
expect(aIndex).toBeGreaterThan(-1);
|
|
144
|
+
expect(bIndex).toBeGreaterThan(-1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('includes bypass hint in blocked report', () => {
|
|
148
|
+
const result = {
|
|
149
|
+
passed: false,
|
|
150
|
+
findings: [
|
|
151
|
+
{ file: 'x.js', severity: 'block', rule: 'r1', message: 'Bad', fix: 'Fix' },
|
|
152
|
+
],
|
|
153
|
+
summary: { total: 1, block: 1, warn: 0, info: 0 },
|
|
154
|
+
};
|
|
155
|
+
const output = formatReport(result);
|
|
156
|
+
expect(output).toContain('--no-verify');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates and installs git hooks that run the TLC code gate.
|
|
5
|
+
* Hooks are portable sh scripts (not bash-specific).
|
|
6
|
+
*
|
|
7
|
+
* @module code-gate/hooks-generator
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
/** Marker comment to identify TLC-generated hooks */
|
|
14
|
+
const TLC_HOOK_MARKER = '# TLC Code Gate';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generate a pre-commit hook script.
|
|
18
|
+
* Runs fast static analysis (< 3s) on staged files.
|
|
19
|
+
*
|
|
20
|
+
* @returns {string} Shell script content
|
|
21
|
+
*/
|
|
22
|
+
function generatePreCommitHook() {
|
|
23
|
+
return `#!/bin/sh
|
|
24
|
+
${TLC_HOOK_MARKER} — pre-commit
|
|
25
|
+
# Runs static code gate on staged files.
|
|
26
|
+
# To bypass: git commit --no-verify (bypass is logged)
|
|
27
|
+
|
|
28
|
+
# Get the project root
|
|
29
|
+
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
|
30
|
+
|
|
31
|
+
# Run the TLC gate check on staged files
|
|
32
|
+
if command -v node > /dev/null 2>&1; then
|
|
33
|
+
node "$PROJECT_ROOT/node_modules/.bin/tlc-gate" check --hook pre-commit
|
|
34
|
+
EXIT_CODE=$?
|
|
35
|
+
|
|
36
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
37
|
+
echo ""
|
|
38
|
+
echo "Commit blocked by TLC Code Gate."
|
|
39
|
+
echo "Fix the issues above or use: git commit --no-verify"
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
else
|
|
43
|
+
echo "Warning: Node.js not found. Skipping TLC Code Gate."
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
exit 0
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate a pre-push hook script.
|
|
52
|
+
* Runs full analysis including LLM-powered review.
|
|
53
|
+
*
|
|
54
|
+
* @returns {string} Shell script content
|
|
55
|
+
*/
|
|
56
|
+
function generatePrePushHook() {
|
|
57
|
+
return `#!/bin/sh
|
|
58
|
+
${TLC_HOOK_MARKER} — pre-push
|
|
59
|
+
# Runs full code gate including LLM review before push.
|
|
60
|
+
# To bypass: git push --no-verify (bypass is logged)
|
|
61
|
+
|
|
62
|
+
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
|
63
|
+
|
|
64
|
+
if command -v node > /dev/null 2>&1; then
|
|
65
|
+
node "$PROJECT_ROOT/node_modules/.bin/tlc-gate" check --hook pre-push --full
|
|
66
|
+
EXIT_CODE=$?
|
|
67
|
+
|
|
68
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
69
|
+
echo ""
|
|
70
|
+
echo "Push blocked by TLC Code Gate."
|
|
71
|
+
echo "Fix the issues above or use: git push --no-verify"
|
|
72
|
+
exit 1
|
|
73
|
+
fi
|
|
74
|
+
else
|
|
75
|
+
echo "Warning: Node.js not found. Skipping TLC Code Gate."
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
exit 0
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Install git hooks into the project's .git/hooks/ directory.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} projectPath - Path to project root
|
|
86
|
+
* @param {Object} [options] - Options
|
|
87
|
+
* @param {Object} [options.fs] - File system module (for testing)
|
|
88
|
+
* @param {string[]} [options.hooks] - Which hooks to install (default: both)
|
|
89
|
+
* @returns {Promise<{installed: string[]}>}
|
|
90
|
+
*/
|
|
91
|
+
async function installHooks(projectPath, options = {}) {
|
|
92
|
+
const fsModule = options.fs || fs;
|
|
93
|
+
const hooks = options.hooks || ['pre-commit', 'pre-push'];
|
|
94
|
+
const gitDir = path.join(projectPath, '.git');
|
|
95
|
+
|
|
96
|
+
if (!fsModule.existsSync(gitDir)) {
|
|
97
|
+
throw new Error('Not a git repository: .git directory not found');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
101
|
+
const installed = [];
|
|
102
|
+
|
|
103
|
+
const generators = {
|
|
104
|
+
'pre-commit': generatePreCommitHook,
|
|
105
|
+
'pre-push': generatePrePushHook,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
for (const hookName of hooks) {
|
|
109
|
+
const generator = generators[hookName];
|
|
110
|
+
if (!generator) continue;
|
|
111
|
+
|
|
112
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
113
|
+
const content = generator();
|
|
114
|
+
|
|
115
|
+
fsModule.writeFileSync(hookPath, content, 'utf-8');
|
|
116
|
+
fsModule.chmodSync(hookPath, '755');
|
|
117
|
+
installed.push(hookName);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { installed };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a TLC code gate hook is installed.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} projectPath
|
|
127
|
+
* @param {string} hookName - Hook name (pre-commit, pre-push)
|
|
128
|
+
* @param {Object} [options]
|
|
129
|
+
* @param {Object} [options.fs] - File system module
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
function isHookInstalled(projectPath, hookName, options = {}) {
|
|
133
|
+
const fsModule = options.fs || fs;
|
|
134
|
+
const hookPath = path.join(projectPath, '.git', 'hooks', hookName);
|
|
135
|
+
|
|
136
|
+
if (!fsModule.existsSync(hookPath)) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const content = fsModule.readFileSync(hookPath, 'utf-8');
|
|
141
|
+
return content.includes(TLC_HOOK_MARKER);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
generatePreCommitHook,
|
|
146
|
+
generatePrePushHook,
|
|
147
|
+
installHooks,
|
|
148
|
+
isHookInstalled,
|
|
149
|
+
};
|
|
@@ -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
|
+
};
|