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.
Files changed (81) 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/gate-command.js +114 -0
  16. package/server/lib/code-gate/gate-command.test.js +111 -0
  17. package/server/lib/code-gate/gate-config.js +163 -0
  18. package/server/lib/code-gate/gate-config.test.js +181 -0
  19. package/server/lib/code-gate/gate-engine.js +193 -0
  20. package/server/lib/code-gate/gate-engine.test.js +258 -0
  21. package/server/lib/code-gate/gate-reporter.js +123 -0
  22. package/server/lib/code-gate/gate-reporter.test.js +159 -0
  23. package/server/lib/code-gate/hooks-generator.js +149 -0
  24. package/server/lib/code-gate/hooks-generator.test.js +142 -0
  25. package/server/lib/code-gate/llm-reviewer.js +176 -0
  26. package/server/lib/code-gate/llm-reviewer.test.js +161 -0
  27. package/server/lib/code-gate/push-gate.js +133 -0
  28. package/server/lib/code-gate/push-gate.test.js +190 -0
  29. package/server/lib/code-gate/rules/architecture-rules.js +228 -0
  30. package/server/lib/code-gate/rules/architecture-rules.test.js +155 -0
  31. package/server/lib/code-gate/rules/client-rules.js +120 -0
  32. package/server/lib/code-gate/rules/client-rules.test.js +121 -0
  33. package/server/lib/code-gate/rules/config-rules.js +140 -0
  34. package/server/lib/code-gate/rules/config-rules.test.js +103 -0
  35. package/server/lib/code-gate/rules/database-rules.js +158 -0
  36. package/server/lib/code-gate/rules/database-rules.test.js +119 -0
  37. package/server/lib/code-gate/rules/docker-rules.js +201 -0
  38. package/server/lib/code-gate/rules/docker-rules.test.js +104 -0
  39. package/server/lib/code-gate/rules/quality-rules.js +304 -0
  40. package/server/lib/code-gate/rules/quality-rules.test.js +199 -0
  41. package/server/lib/code-gate/rules/security-rules.js +228 -0
  42. package/server/lib/code-gate/rules/security-rules.test.js +131 -0
  43. package/server/lib/code-gate/rules/structure-rules.js +155 -0
  44. package/server/lib/code-gate/rules/structure-rules.test.js +107 -0
  45. package/server/lib/code-gate/rules/test-rules.js +93 -0
  46. package/server/lib/code-gate/rules/test-rules.test.js +97 -0
  47. package/server/lib/code-gate/typescript-gate.js +128 -0
  48. package/server/lib/code-gate/typescript-gate.test.js +131 -0
  49. package/server/lib/code-generator.test.js +1 -1
  50. package/server/lib/cost-command.test.js +1 -1
  51. package/server/lib/cost-optimizer.test.js +1 -1
  52. package/server/lib/cost-projections.test.js +1 -1
  53. package/server/lib/cost-reports.test.js +1 -1
  54. package/server/lib/cost-tracker.test.js +1 -1
  55. package/server/lib/crypto-patterns.test.js +1 -1
  56. package/server/lib/design-command.test.js +1 -1
  57. package/server/lib/design-parser.test.js +1 -1
  58. package/server/lib/gemini-vision.test.js +1 -1
  59. package/server/lib/input-validator.test.js +1 -1
  60. package/server/lib/litellm-client.test.js +1 -1
  61. package/server/lib/litellm-command.test.js +1 -1
  62. package/server/lib/litellm-config.test.js +1 -1
  63. package/server/lib/model-pricing.test.js +1 -1
  64. package/server/lib/models-command.test.js +1 -1
  65. package/server/lib/optimize-command.test.js +1 -1
  66. package/server/lib/orchestration-integration.test.js +1 -1
  67. package/server/lib/output-encoder.test.js +1 -1
  68. package/server/lib/quality-evaluator.test.js +1 -1
  69. package/server/lib/quality-gate-command.test.js +1 -1
  70. package/server/lib/quality-gate-scorer.test.js +1 -1
  71. package/server/lib/quality-history.test.js +1 -1
  72. package/server/lib/quality-presets.test.js +1 -1
  73. package/server/lib/quality-retry.test.js +1 -1
  74. package/server/lib/quality-thresholds.test.js +1 -1
  75. package/server/lib/secure-auth.test.js +1 -1
  76. package/server/lib/secure-code-command.test.js +1 -1
  77. package/server/lib/secure-errors.test.js +1 -1
  78. package/server/lib/security/auth-security.test.js +4 -3
  79. package/server/lib/vision-command.test.js +1 -1
  80. package/server/lib/visual-command.test.js +1 -1
  81. 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
+ };