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,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Gate Engine
|
|
3
|
+
*
|
|
4
|
+
* Core engine that accepts changed files and runs configurable rule sets
|
|
5
|
+
* against each file, returning pass/fail with detailed findings per file.
|
|
6
|
+
*
|
|
7
|
+
* @module code-gate/gate-engine
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Severity levels for gate findings.
|
|
14
|
+
* - block: Commit/push is rejected
|
|
15
|
+
* - warn: Warning shown but allowed through
|
|
16
|
+
* - info: Informational, no action needed
|
|
17
|
+
*/
|
|
18
|
+
const SEVERITY = {
|
|
19
|
+
BLOCK: 'block',
|
|
20
|
+
WARN: 'warn',
|
|
21
|
+
INFO: 'info',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Deduction points per severity level for scoring */
|
|
25
|
+
const SCORE_DEDUCTIONS = {
|
|
26
|
+
[SEVERITY.BLOCK]: 25,
|
|
27
|
+
[SEVERITY.WARN]: 10,
|
|
28
|
+
[SEVERITY.INFO]: 2,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a gate engine instance with configurable rules and options.
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} options - Engine options
|
|
35
|
+
* @param {Array<{id: string, check: Function}>} [options.rules] - Rule set to run
|
|
36
|
+
* @param {string[]} [options.ignore] - Glob patterns for files to skip
|
|
37
|
+
* @returns {{ rules: Array, options: Object }}
|
|
38
|
+
*/
|
|
39
|
+
function createGateEngine(options = {}) {
|
|
40
|
+
return {
|
|
41
|
+
rules: options.rules || [],
|
|
42
|
+
options: {
|
|
43
|
+
ignore: options.ignore || [],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run all configured rules against a set of changed files.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ rules: Array, options: Object }} engine - Gate engine instance
|
|
52
|
+
* @param {Array<{path: string, content: string}>} files - Changed files to check
|
|
53
|
+
* @returns {Promise<{passed: boolean, findings: Array, summary: Object, duration: number}>}
|
|
54
|
+
*/
|
|
55
|
+
async function runGate(engine, files) {
|
|
56
|
+
const start = Date.now();
|
|
57
|
+
const findings = [];
|
|
58
|
+
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
if (shouldIgnore(file.path, engine.options.ignore)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const rule of engine.rules) {
|
|
65
|
+
try {
|
|
66
|
+
const ruleFindings = rule.check(file.path, file.content);
|
|
67
|
+
for (const finding of ruleFindings) {
|
|
68
|
+
findings.push({ ...finding, file: file.path });
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
findings.push({
|
|
72
|
+
severity: SEVERITY.WARN,
|
|
73
|
+
rule: rule.id,
|
|
74
|
+
file: file.path,
|
|
75
|
+
message: `Rule error: ${err.message}`,
|
|
76
|
+
fix: 'Check rule configuration',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const summary = buildSummary(findings);
|
|
83
|
+
const passed = summary.block === 0;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
passed,
|
|
87
|
+
findings,
|
|
88
|
+
summary,
|
|
89
|
+
duration: Date.now() - start,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Group findings by file path for reporting.
|
|
95
|
+
*
|
|
96
|
+
* @param {Array} findings - Array of finding objects
|
|
97
|
+
* @returns {Object.<string, Array>} Findings grouped by file
|
|
98
|
+
*/
|
|
99
|
+
function aggregateFindings(findings) {
|
|
100
|
+
const grouped = {};
|
|
101
|
+
for (const finding of findings) {
|
|
102
|
+
const key = finding.file;
|
|
103
|
+
if (!grouped[key]) {
|
|
104
|
+
grouped[key] = [];
|
|
105
|
+
}
|
|
106
|
+
grouped[key].push(finding);
|
|
107
|
+
}
|
|
108
|
+
return grouped;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Calculate a 0-100 quality score from findings.
|
|
113
|
+
* Starts at 100, deducts per severity. Floors at 0.
|
|
114
|
+
*
|
|
115
|
+
* @param {Array<{severity: string}>} findings - Finding objects with severity
|
|
116
|
+
* @returns {number} Score from 0 to 100
|
|
117
|
+
*/
|
|
118
|
+
function calculateScore(findings) {
|
|
119
|
+
let score = 100;
|
|
120
|
+
for (const finding of findings) {
|
|
121
|
+
const deduction = SCORE_DEDUCTIONS[finding.severity] || 0;
|
|
122
|
+
score -= deduction;
|
|
123
|
+
}
|
|
124
|
+
return Math.max(0, score);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build summary counts from findings array.
|
|
129
|
+
*
|
|
130
|
+
* @param {Array<{severity: string}>} findings
|
|
131
|
+
* @returns {{ total: number, block: number, warn: number, info: number }}
|
|
132
|
+
*/
|
|
133
|
+
function buildSummary(findings) {
|
|
134
|
+
const summary = { total: findings.length, block: 0, warn: 0, info: 0 };
|
|
135
|
+
for (const finding of findings) {
|
|
136
|
+
if (summary[finding.severity] !== undefined) {
|
|
137
|
+
summary[finding.severity]++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return summary;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if a file path matches any ignore pattern.
|
|
145
|
+
* Supports simple glob matching: *.ext and dir/* patterns.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} filePath - File path to check
|
|
148
|
+
* @param {string[]} patterns - Glob patterns
|
|
149
|
+
* @returns {boolean} True if file should be ignored
|
|
150
|
+
*/
|
|
151
|
+
function shouldIgnore(filePath, patterns) {
|
|
152
|
+
for (const pattern of patterns) {
|
|
153
|
+
if (matchGlob(filePath, pattern)) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Simple glob matcher for common patterns.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} filePath
|
|
164
|
+
* @param {string} pattern
|
|
165
|
+
* @returns {boolean}
|
|
166
|
+
*/
|
|
167
|
+
function matchGlob(filePath, pattern) {
|
|
168
|
+
// *.ext — match extension anywhere
|
|
169
|
+
if (pattern.startsWith('*.')) {
|
|
170
|
+
const ext = pattern.slice(1);
|
|
171
|
+
return filePath.endsWith(ext);
|
|
172
|
+
}
|
|
173
|
+
// **/*.ext — match extension in any directory
|
|
174
|
+
if (pattern.startsWith('**/')) {
|
|
175
|
+
const sub = pattern.slice(3);
|
|
176
|
+
return matchGlob(filePath, sub) || matchGlob(path.basename(filePath), sub);
|
|
177
|
+
}
|
|
178
|
+
// dir/* — match files in directory
|
|
179
|
+
if (pattern.endsWith('/*')) {
|
|
180
|
+
const dir = pattern.slice(0, -2);
|
|
181
|
+
return filePath.startsWith(dir + '/') || filePath.startsWith(dir + path.sep);
|
|
182
|
+
}
|
|
183
|
+
// Exact match
|
|
184
|
+
return filePath === pattern;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
createGateEngine,
|
|
189
|
+
runGate,
|
|
190
|
+
aggregateFindings,
|
|
191
|
+
calculateScore,
|
|
192
|
+
SEVERITY,
|
|
193
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate Engine Tests
|
|
3
|
+
*
|
|
4
|
+
* The gate engine accepts changed files and runs configurable rule sets
|
|
5
|
+
* against each file, returning pass/fail with detailed findings.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
createGateEngine,
|
|
11
|
+
runGate,
|
|
12
|
+
aggregateFindings,
|
|
13
|
+
calculateScore,
|
|
14
|
+
SEVERITY,
|
|
15
|
+
} = require('./gate-engine.js');
|
|
16
|
+
|
|
17
|
+
describe('Gate Engine', () => {
|
|
18
|
+
describe('SEVERITY', () => {
|
|
19
|
+
it('defines all severity levels', () => {
|
|
20
|
+
expect(SEVERITY.BLOCK).toBe('block');
|
|
21
|
+
expect(SEVERITY.WARN).toBe('warn');
|
|
22
|
+
expect(SEVERITY.INFO).toBe('info');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('createGateEngine', () => {
|
|
27
|
+
it('creates engine with default options', () => {
|
|
28
|
+
const engine = createGateEngine();
|
|
29
|
+
expect(engine).toBeDefined();
|
|
30
|
+
expect(engine.rules).toEqual([]);
|
|
31
|
+
expect(engine.options).toBeDefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('creates engine with custom rules', () => {
|
|
35
|
+
const mockRule = { id: 'test-rule', check: () => [] };
|
|
36
|
+
const engine = createGateEngine({ rules: [mockRule] });
|
|
37
|
+
expect(engine.rules).toHaveLength(1);
|
|
38
|
+
expect(engine.rules[0].id).toBe('test-rule');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts ignore patterns', () => {
|
|
42
|
+
const engine = createGateEngine({ ignore: ['*.md', 'dist/*'] });
|
|
43
|
+
expect(engine.options.ignore).toEqual(['*.md', 'dist/*']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('runGate', () => {
|
|
48
|
+
let engine;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
engine = createGateEngine();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns pass for empty changeset', async () => {
|
|
55
|
+
const result = await runGate(engine, []);
|
|
56
|
+
expect(result.passed).toBe(true);
|
|
57
|
+
expect(result.findings).toEqual([]);
|
|
58
|
+
expect(result.summary.total).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('runs rules against each file', async () => {
|
|
62
|
+
const ruleCallCount = { count: 0 };
|
|
63
|
+
const mockRule = {
|
|
64
|
+
id: 'counter-rule',
|
|
65
|
+
check: (file, content) => {
|
|
66
|
+
ruleCallCount.count++;
|
|
67
|
+
return [];
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
engine = createGateEngine({ rules: [mockRule] });
|
|
71
|
+
|
|
72
|
+
const files = [
|
|
73
|
+
{ path: 'src/a.js', content: 'const a = 1;' },
|
|
74
|
+
{ path: 'src/b.js', content: 'const b = 2;' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
await runGate(engine, files);
|
|
78
|
+
expect(ruleCallCount.count).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('collects findings from all rules', async () => {
|
|
82
|
+
const rule1 = {
|
|
83
|
+
id: 'rule-a',
|
|
84
|
+
check: () => [
|
|
85
|
+
{ severity: 'block', rule: 'rule-a', line: 1, message: 'Issue A', fix: 'Fix A' },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
const rule2 = {
|
|
89
|
+
id: 'rule-b',
|
|
90
|
+
check: () => [
|
|
91
|
+
{ severity: 'warn', rule: 'rule-b', line: 5, message: 'Issue B', fix: 'Fix B' },
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
engine = createGateEngine({ rules: [rule1, rule2] });
|
|
95
|
+
|
|
96
|
+
const result = await runGate(engine, [{ path: 'src/a.js', content: 'code' }]);
|
|
97
|
+
expect(result.findings).toHaveLength(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('fails when any finding has block severity', async () => {
|
|
101
|
+
const rule = {
|
|
102
|
+
id: 'blocker',
|
|
103
|
+
check: () => [
|
|
104
|
+
{ severity: 'block', rule: 'blocker', line: 1, message: 'Blocked', fix: 'Fix it' },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
engine = createGateEngine({ rules: [rule] });
|
|
108
|
+
|
|
109
|
+
const result = await runGate(engine, [{ path: 'src/a.js', content: 'code' }]);
|
|
110
|
+
expect(result.passed).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('passes when findings are warn-only', async () => {
|
|
114
|
+
const rule = {
|
|
115
|
+
id: 'warner',
|
|
116
|
+
check: () => [
|
|
117
|
+
{ severity: 'warn', rule: 'warner', line: 1, message: 'Warning', fix: 'Maybe fix' },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
engine = createGateEngine({ rules: [rule] });
|
|
121
|
+
|
|
122
|
+
const result = await runGate(engine, [{ path: 'src/a.js', content: 'code' }]);
|
|
123
|
+
expect(result.passed).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('passes when findings are info-only', async () => {
|
|
127
|
+
const rule = {
|
|
128
|
+
id: 'informer',
|
|
129
|
+
check: () => [
|
|
130
|
+
{ severity: 'info', rule: 'informer', line: 1, message: 'FYI', fix: 'Optional' },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
engine = createGateEngine({ rules: [rule] });
|
|
134
|
+
|
|
135
|
+
const result = await runGate(engine, [{ path: 'src/a.js', content: 'code' }]);
|
|
136
|
+
expect(result.passed).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('attaches file path to each finding', async () => {
|
|
140
|
+
const rule = {
|
|
141
|
+
id: 'path-test',
|
|
142
|
+
check: () => [
|
|
143
|
+
{ severity: 'warn', rule: 'path-test', line: 1, message: 'X', fix: 'Y' },
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
engine = createGateEngine({ rules: [rule] });
|
|
147
|
+
|
|
148
|
+
const result = await runGate(engine, [{ path: 'src/deep/file.js', content: 'x' }]);
|
|
149
|
+
expect(result.findings[0].file).toBe('src/deep/file.js');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('skips files matching ignore patterns', async () => {
|
|
153
|
+
const rule = {
|
|
154
|
+
id: 'skip-test',
|
|
155
|
+
check: () => [
|
|
156
|
+
{ severity: 'block', rule: 'skip-test', line: 1, message: 'Bad', fix: 'Fix' },
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
engine = createGateEngine({ rules: [rule], ignore: ['*.md', '*.json'] });
|
|
160
|
+
|
|
161
|
+
const files = [
|
|
162
|
+
{ path: 'README.md', content: '# Hello' },
|
|
163
|
+
{ path: 'package.json', content: '{}' },
|
|
164
|
+
{ path: 'src/app.js', content: 'code' },
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const result = await runGate(engine, files);
|
|
168
|
+
// Only src/app.js should be checked
|
|
169
|
+
expect(result.findings).toHaveLength(1);
|
|
170
|
+
expect(result.findings[0].file).toBe('src/app.js');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('includes summary with counts per severity', async () => {
|
|
174
|
+
const rule = {
|
|
175
|
+
id: 'multi',
|
|
176
|
+
check: () => [
|
|
177
|
+
{ severity: 'block', rule: 'multi', line: 1, message: 'A', fix: 'A' },
|
|
178
|
+
{ severity: 'warn', rule: 'multi', line: 2, message: 'B', fix: 'B' },
|
|
179
|
+
{ severity: 'info', rule: 'multi', line: 3, message: 'C', fix: 'C' },
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
engine = createGateEngine({ rules: [rule] });
|
|
183
|
+
|
|
184
|
+
const result = await runGate(engine, [{ path: 'x.js', content: '' }]);
|
|
185
|
+
expect(result.summary.total).toBe(3);
|
|
186
|
+
expect(result.summary.block).toBe(1);
|
|
187
|
+
expect(result.summary.warn).toBe(1);
|
|
188
|
+
expect(result.summary.info).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('handles rule that throws gracefully', async () => {
|
|
192
|
+
const rule = {
|
|
193
|
+
id: 'crasher',
|
|
194
|
+
check: () => { throw new Error('Rule crashed'); },
|
|
195
|
+
};
|
|
196
|
+
engine = createGateEngine({ rules: [rule] });
|
|
197
|
+
|
|
198
|
+
const result = await runGate(engine, [{ path: 'x.js', content: '' }]);
|
|
199
|
+
// Should not throw - engine catches rule errors
|
|
200
|
+
expect(result).toBeDefined();
|
|
201
|
+
expect(result.findings).toHaveLength(1);
|
|
202
|
+
expect(result.findings[0].rule).toBe('crasher');
|
|
203
|
+
expect(result.findings[0].severity).toBe('warn');
|
|
204
|
+
expect(result.findings[0].message).toContain('Rule crashed');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('measures execution duration', async () => {
|
|
208
|
+
engine = createGateEngine();
|
|
209
|
+
const result = await runGate(engine, []);
|
|
210
|
+
expect(typeof result.duration).toBe('number');
|
|
211
|
+
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('aggregateFindings', () => {
|
|
216
|
+
it('groups findings by file', () => {
|
|
217
|
+
const findings = [
|
|
218
|
+
{ file: 'a.js', rule: 'r1', severity: 'block', message: 'A' },
|
|
219
|
+
{ file: 'b.js', rule: 'r2', severity: 'warn', message: 'B' },
|
|
220
|
+
{ file: 'a.js', rule: 'r3', severity: 'info', message: 'C' },
|
|
221
|
+
];
|
|
222
|
+
const grouped = aggregateFindings(findings);
|
|
223
|
+
expect(grouped['a.js']).toHaveLength(2);
|
|
224
|
+
expect(grouped['b.js']).toHaveLength(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('returns empty object for no findings', () => {
|
|
228
|
+
const grouped = aggregateFindings([]);
|
|
229
|
+
expect(Object.keys(grouped)).toHaveLength(0);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('calculateScore', () => {
|
|
234
|
+
it('returns 100 for no findings', () => {
|
|
235
|
+
expect(calculateScore([])).toBe(100);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('deducts points for block findings', () => {
|
|
239
|
+
const findings = [
|
|
240
|
+
{ severity: 'block' },
|
|
241
|
+
];
|
|
242
|
+
const score = calculateScore(findings);
|
|
243
|
+
expect(score).toBeLessThan(100);
|
|
244
|
+
expect(score).toBeGreaterThanOrEqual(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('deducts fewer points for warnings', () => {
|
|
248
|
+
const blockFindings = [{ severity: 'block' }];
|
|
249
|
+
const warnFindings = [{ severity: 'warn' }];
|
|
250
|
+
expect(calculateScore(warnFindings)).toBeGreaterThan(calculateScore(blockFindings));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('floors at zero', () => {
|
|
254
|
+
const manyFindings = Array.from({ length: 50 }, () => ({ severity: 'block' }));
|
|
255
|
+
expect(calculateScore(manyFindings)).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate Reporter
|
|
3
|
+
*
|
|
4
|
+
* Formats gate results into clear, actionable terminal output
|
|
5
|
+
* with severity badges, fix suggestions, and summary line.
|
|
6
|
+
*
|
|
7
|
+
* @module code-gate/gate-reporter
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format a single finding as a readable string.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} finding
|
|
14
|
+
* @param {string} finding.severity
|
|
15
|
+
* @param {string} finding.rule
|
|
16
|
+
* @param {string} finding.file
|
|
17
|
+
* @param {number} [finding.line]
|
|
18
|
+
* @param {string} finding.message
|
|
19
|
+
* @param {string} finding.fix
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function formatFinding(finding) {
|
|
23
|
+
const badge = `[${finding.severity.toUpperCase()}]`;
|
|
24
|
+
const location = finding.line ? ` (line ${finding.line})` : '';
|
|
25
|
+
const rule = finding.rule;
|
|
26
|
+
|
|
27
|
+
let output = ` ${badge} ${rule}${location}\n`;
|
|
28
|
+
output += ` ${finding.message}\n`;
|
|
29
|
+
if (finding.fix) {
|
|
30
|
+
output += ` Fix: ${finding.fix}\n`;
|
|
31
|
+
}
|
|
32
|
+
return output;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Group findings by file path.
|
|
37
|
+
*
|
|
38
|
+
* @param {Array} findings
|
|
39
|
+
* @returns {Object.<string, Array>}
|
|
40
|
+
*/
|
|
41
|
+
function groupByFile(findings) {
|
|
42
|
+
const groups = {};
|
|
43
|
+
for (const finding of findings) {
|
|
44
|
+
const key = finding.file;
|
|
45
|
+
if (!groups[key]) groups[key] = [];
|
|
46
|
+
groups[key].push(finding);
|
|
47
|
+
}
|
|
48
|
+
return groups;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Format the summary line.
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} summary - { total, block, warn, info }
|
|
55
|
+
* @param {boolean} passed
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
function formatSummary(summary, passed) {
|
|
59
|
+
if (passed && summary.total === 0) {
|
|
60
|
+
return 'All clear — gate passed.';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const parts = [];
|
|
64
|
+
if (summary.block > 0) parts.push(`${summary.block} blocking`);
|
|
65
|
+
if (summary.warn > 0) parts.push(`${summary.warn} warning${summary.warn > 1 ? 's' : ''}`);
|
|
66
|
+
if (summary.info > 0) parts.push(`${summary.info} info`);
|
|
67
|
+
|
|
68
|
+
const status = passed ? 'passed' : 'blocked';
|
|
69
|
+
return `Summary: ${parts.join(' | ')} — gate ${status}.`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Format a complete gate report.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} result - Gate engine result
|
|
76
|
+
* @param {boolean} result.passed
|
|
77
|
+
* @param {Array} result.findings
|
|
78
|
+
* @param {Object} result.summary
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function formatReport(result) {
|
|
82
|
+
const { passed, findings, summary } = result;
|
|
83
|
+
|
|
84
|
+
let report = '';
|
|
85
|
+
|
|
86
|
+
// Header
|
|
87
|
+
const status = passed ? 'Passed' : 'Blocked';
|
|
88
|
+
report += `TLC Code Gate — ${status}\n`;
|
|
89
|
+
report += '─'.repeat(42) + '\n\n';
|
|
90
|
+
|
|
91
|
+
if (findings.length === 0) {
|
|
92
|
+
report += 'All clear — no issues found.\n\n';
|
|
93
|
+
report += formatSummary(summary, passed) + '\n';
|
|
94
|
+
return report;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Group and display findings by file
|
|
98
|
+
const grouped = groupByFile(findings);
|
|
99
|
+
for (const [file, fileFindings] of Object.entries(grouped)) {
|
|
100
|
+
report += `${file}\n`;
|
|
101
|
+
for (const finding of fileFindings) {
|
|
102
|
+
report += formatFinding(finding);
|
|
103
|
+
}
|
|
104
|
+
report += '\n';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Summary
|
|
108
|
+
report += formatSummary(summary, passed) + '\n';
|
|
109
|
+
|
|
110
|
+
// Bypass hint (only when blocked)
|
|
111
|
+
if (!passed) {
|
|
112
|
+
report += 'Fix blocking issues or use --no-verify (logged).\n';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return report;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
formatReport,
|
|
120
|
+
formatSummary,
|
|
121
|
+
formatFinding,
|
|
122
|
+
groupByFile,
|
|
123
|
+
};
|