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,45 @@
1
+ export interface DockerfileFinding {
2
+ rule: string;
3
+ severity: 'critical' | 'high' | 'medium' | 'low';
4
+ line?: number;
5
+ message: string;
6
+ fix: string;
7
+ }
8
+ export interface RuntimeFinding {
9
+ rule: string;
10
+ severity: 'critical' | 'high' | 'medium' | 'low';
11
+ service: string;
12
+ message: string;
13
+ fix: string;
14
+ }
15
+ export interface VulnerabilitySummary {
16
+ critical: number;
17
+ high: number;
18
+ medium: number;
19
+ low: number;
20
+ total: number;
21
+ }
22
+ export interface CisBenchmarkResult {
23
+ level1Score: number;
24
+ passed: boolean;
25
+ findings: Array<{
26
+ cis: string;
27
+ severity: string;
28
+ message: string;
29
+ }>;
30
+ }
31
+ export interface ContainerSecurityPaneProps {
32
+ dockerfileLintScore: number;
33
+ dockerfileFindings: DockerfileFinding[];
34
+ runtimeScore: number;
35
+ runtimeFindings: RuntimeFinding[];
36
+ networkScore: number;
37
+ vulnerabilities: VulnerabilitySummary;
38
+ cisBenchmark: CisBenchmarkResult;
39
+ secretsScore: number;
40
+ onRescan?: () => void;
41
+ loading?: boolean;
42
+ error?: string | null;
43
+ isActive?: boolean;
44
+ }
45
+ export declare function ContainerSecurityPane({ dockerfileLintScore, dockerfileFindings, runtimeScore, runtimeFindings, networkScore, vulnerabilities, cisBenchmark, secretsScore, onRescan, loading, error, isActive, }: ContainerSecurityPaneProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ function getSeverityColor(severity) {
4
+ switch (severity) {
5
+ case 'critical':
6
+ return 'magenta';
7
+ case 'high':
8
+ return 'red';
9
+ case 'medium':
10
+ return 'yellow';
11
+ case 'low':
12
+ return 'cyan';
13
+ default:
14
+ return 'white';
15
+ }
16
+ }
17
+ function getScoreColor(score) {
18
+ if (score >= 80)
19
+ return 'green';
20
+ if (score >= 60)
21
+ return 'yellow';
22
+ return 'red';
23
+ }
24
+ function ScoreBar({ label, score }) {
25
+ const filled = Math.round(score / 5);
26
+ const empty = 20 - filled;
27
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
28
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { children: label }) }), _jsx(Text, { color: getScoreColor(score), children: bar }), _jsxs(Text, { children: [" ", score, "%"] })] }));
29
+ }
30
+ export function ContainerSecurityPane({ dockerfileLintScore, dockerfileFindings, runtimeScore, runtimeFindings, networkScore, vulnerabilities, cisBenchmark, secretsScore, onRescan, loading = false, error = null, isActive = false, }) {
31
+ useInput((input) => {
32
+ if (input === 'r' && onRescan) {
33
+ onRescan();
34
+ }
35
+ }, { isActive });
36
+ if (error) {
37
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Container Security Error" }), _jsx(Text, { color: "red", children: error })] }));
38
+ }
39
+ if (loading) {
40
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "Container Security" }), _jsx(Text, { dimColor: true, children: "Scanning containers..." })] }));
41
+ }
42
+ const overallScore = Math.round((dockerfileLintScore + runtimeScore + networkScore + secretsScore) / 4);
43
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "Container Security" }), _jsx(Text, { dimColor: true, children: "CIS Docker Benchmark compliance and security analysis" }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Overall Score: ", _jsxs(Text, { color: getScoreColor(overallScore), children: [overallScore, "%"] })] }), _jsx(Box, { marginTop: 1 }), _jsx(ScoreBar, { label: "Dockerfile Lint", score: dockerfileLintScore }), _jsx(ScoreBar, { label: "Runtime Security", score: runtimeScore }), _jsx(ScoreBar, { label: "Network Policy", score: networkScore }), _jsx(ScoreBar, { label: "Secrets Management", score: secretsScore })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "CIS Docker Benchmark Level 1" }), _jsxs(Text, { children: ["Score: ", _jsxs(Text, { color: cisBenchmark.passed ? 'green' : 'red', children: [cisBenchmark.level1Score, "%"] }), ' ', cisBenchmark.passed ? '\u2713 PASSED' : '\u2717 FAILED'] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Image Vulnerabilities" }), _jsxs(Box, { children: [_jsxs(Text, { color: "magenta", children: ["Critical: ", vulnerabilities.critical] }), _jsx(Text, { children: " | " }), _jsxs(Text, { color: "red", children: ["High: ", vulnerabilities.high] }), _jsx(Text, { children: " | " }), _jsxs(Text, { color: "yellow", children: ["Medium: ", vulnerabilities.medium] }), _jsx(Text, { children: " | " }), _jsxs(Text, { color: "cyan", children: ["Low: ", vulnerabilities.low] })] })] }), _jsx(Box, { marginTop: 1 }), dockerfileFindings.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Dockerfile Findings (", dockerfileFindings.length, ")"] }), dockerfileFindings.slice(0, 5).map((f, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: getSeverityColor(f.severity), children: ["[", f.severity.toUpperCase(), "]"] }), _jsxs(Text, { children: [" ", f.message] })] }, `df-${i}`))), dockerfileFindings.length > 5 && (_jsxs(Text, { dimColor: true, children: ["...and ", dockerfileFindings.length - 5, " more"] }))] })), runtimeFindings.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Runtime Findings (", runtimeFindings.length, ")"] }), runtimeFindings.slice(0, 5).map((f, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: getSeverityColor(f.severity), children: ["[", f.severity.toUpperCase(), "]"] }), _jsxs(Text, { children: [" ", f.service, ": ", f.message] })] }, `rt-${i}`))), runtimeFindings.length > 5 && (_jsxs(Text, { dimColor: true, children: ["...and ", runtimeFindings.length - 5, " more"] }))] })), isActive && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press [r] to rescan" }) }))] }));
44
+ }
@@ -0,0 +1,153 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import { ContainerSecurityPane } from './ContainerSecurityPane.js';
5
+ describe('ContainerSecurityPane', () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+ const mockDockerfileFindings = [
13
+ {
14
+ rule: 'no-root-user',
15
+ severity: 'high',
16
+ line: 1,
17
+ message: 'No USER directive found',
18
+ fix: 'Add USER directive',
19
+ },
20
+ {
21
+ rule: 'latest-tag',
22
+ severity: 'medium',
23
+ message: 'Using latest tag',
24
+ fix: 'Pin to specific version',
25
+ },
26
+ ];
27
+ const mockRuntimeFindings = [
28
+ {
29
+ rule: 'privileged',
30
+ severity: 'critical',
31
+ service: 'web',
32
+ message: 'Running in privileged mode',
33
+ fix: 'Remove privileged: true',
34
+ },
35
+ ];
36
+ const mockVulnerabilities = {
37
+ critical: 0,
38
+ high: 2,
39
+ medium: 5,
40
+ low: 10,
41
+ total: 17,
42
+ };
43
+ const mockCisBenchmark = {
44
+ level1Score: 85,
45
+ passed: true,
46
+ findings: [
47
+ { cis: '4.1', severity: 'high', message: 'No non-root USER directive found.' },
48
+ ],
49
+ };
50
+ const defaultProps = {
51
+ dockerfileLintScore: 80,
52
+ dockerfileFindings: mockDockerfileFindings,
53
+ runtimeScore: 70,
54
+ runtimeFindings: mockRuntimeFindings,
55
+ networkScore: 90,
56
+ vulnerabilities: mockVulnerabilities,
57
+ cisBenchmark: mockCisBenchmark,
58
+ secretsScore: 85,
59
+ };
60
+ it('renders container security header', () => {
61
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
62
+ expect(lastFrame()).toContain('Container Security');
63
+ });
64
+ it('shows overall score', () => {
65
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
66
+ // (80 + 70 + 90 + 85) / 4 = 81.25 => 81
67
+ expect(lastFrame()).toContain('81%');
68
+ });
69
+ it('shows score bars for each category', () => {
70
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
71
+ expect(lastFrame()).toContain('Dockerfile Lint');
72
+ expect(lastFrame()).toContain('Runtime Security');
73
+ expect(lastFrame()).toContain('Network Policy');
74
+ expect(lastFrame()).toContain('Secrets Management');
75
+ });
76
+ it('shows CIS benchmark result', () => {
77
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
78
+ expect(lastFrame()).toContain('CIS Docker Benchmark Level 1');
79
+ expect(lastFrame()).toContain('85%');
80
+ expect(lastFrame()).toContain('PASSED');
81
+ });
82
+ it('shows CIS benchmark failure', () => {
83
+ const failedBenchmark = {
84
+ level1Score: 45,
85
+ passed: false,
86
+ findings: [],
87
+ };
88
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, cisBenchmark: failedBenchmark }));
89
+ expect(lastFrame()).toContain('45%');
90
+ expect(lastFrame()).toContain('FAILED');
91
+ });
92
+ it('shows vulnerability summary', () => {
93
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
94
+ expect(lastFrame()).toContain('Image Vulnerabilities');
95
+ expect(lastFrame()).toContain('Critical: 0');
96
+ expect(lastFrame()).toContain('High: 2');
97
+ expect(lastFrame()).toContain('Medium: 5');
98
+ expect(lastFrame()).toContain('Low: 10');
99
+ });
100
+ it('shows dockerfile findings', () => {
101
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
102
+ expect(lastFrame()).toContain('Dockerfile Findings (2)');
103
+ expect(lastFrame()).toContain('No USER directive found');
104
+ expect(lastFrame()).toContain('[HIGH]');
105
+ });
106
+ it('shows runtime findings', () => {
107
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
108
+ expect(lastFrame()).toContain('Runtime Findings (1)');
109
+ expect(lastFrame()).toContain('Running in privileged mode');
110
+ expect(lastFrame()).toContain('[CRITICAL]');
111
+ });
112
+ it('shows loading state', () => {
113
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, loading: true }));
114
+ expect(lastFrame()).toContain('Scanning containers');
115
+ });
116
+ it('shows error state', () => {
117
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, error: "Docker not running" }));
118
+ expect(lastFrame()).toContain('Docker not running');
119
+ });
120
+ it('shows rescan hint when active', () => {
121
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, isActive: true }));
122
+ expect(lastFrame()).toContain('Press [r] to rescan');
123
+ });
124
+ it('hides rescan hint when not active', () => {
125
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, isActive: false }));
126
+ expect(lastFrame()).not.toContain('Press [r] to rescan');
127
+ });
128
+ it('truncates findings list beyond 5', () => {
129
+ const manyFindings = Array.from({ length: 8 }, (_, i) => ({
130
+ rule: `rule-${i}`,
131
+ severity: 'medium',
132
+ message: `Finding ${i}`,
133
+ fix: `Fix ${i}`,
134
+ }));
135
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, dockerfileFindings: manyFindings }));
136
+ expect(lastFrame()).toContain('Dockerfile Findings (8)');
137
+ expect(lastFrame()).toContain('...and 3 more');
138
+ });
139
+ it('handles zero findings gracefully', () => {
140
+ const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, dockerfileFindings: [], runtimeFindings: [] }));
141
+ expect(lastFrame()).toContain('Container Security');
142
+ expect(lastFrame()).not.toContain('Dockerfile Findings');
143
+ expect(lastFrame()).not.toContain('Runtime Findings');
144
+ });
145
+ it('calls onRescan when r is pressed', async () => {
146
+ const onRescan = vi.fn();
147
+ const { stdin } = render(_jsx(ContainerSecurityPane, { ...defaultProps, isActive: true, onRescan: onRescan }));
148
+ await vi.advanceTimersByTimeAsync(0);
149
+ stdin.write('r');
150
+ await vi.advanceTimersByTimeAsync(0);
151
+ expect(onRescan).toHaveBeenCalledOnce();
152
+ });
153
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "1.6.4",
3
+ "version": "1.7.0",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
6
  "tlc": "./bin/tlc.js",
@@ -4,7 +4,7 @@
4
4
  * Authorization patterns for secure code generation
5
5
  */
6
6
 
7
- const { describe, it, beforeEach } = require('node:test');
7
+ import { describe, it, beforeEach } from 'vitest';
8
8
  const assert = require('node:assert');
9
9
 
10
10
  const {
@@ -1,4 +1,4 @@
1
- const { describe, it, beforeEach, mock } = require('node:test');
1
+ import { describe, it, beforeEach, vi } from 'vitest';
2
2
  const assert = require('node:assert');
3
3
  const {
4
4
  execute,
@@ -1,4 +1,4 @@
1
- const { describe, it, beforeEach, mock } = require('node:test');
1
+ import { describe, it, beforeEach, vi } from 'vitest';
2
2
  const assert = require('node:assert');
3
3
  const {
4
4
  execute,
@@ -1,4 +1,4 @@
1
- const { describe, it, beforeEach, mock } = require('node:test');
1
+ import { describe, it, beforeEach, vi } from 'vitest';
2
2
  const assert = require('node:assert');
3
3
  const {
4
4
  execute,
@@ -1,4 +1,4 @@
1
- const { describe, it, beforeEach, mock } = require('node:test');
1
+ import { describe, it, beforeEach, vi } from 'vitest';
2
2
  const assert = require('node:assert');
3
3
  const {
4
4
  execute,
@@ -1,4 +1,4 @@
1
- const { describe, it, beforeEach, mock } = require('node:test');
1
+ import { describe, it, beforeEach, vi } from 'vitest';
2
2
  const assert = require('node:assert');
3
3
  const {
4
4
  execute,
@@ -4,7 +4,7 @@
4
4
  * Configurable budget limits with enforcement
5
5
  */
6
6
 
7
- const { describe, it, beforeEach } = require('node:test');
7
+ import { describe, it, beforeEach } from 'vitest';
8
8
  const assert = require('node:assert');
9
9
 
10
10
  const {
@@ -53,7 +53,7 @@ describe('Budget Limits', () => {
53
53
  it('returns ok under limit', () => {
54
54
  setBudget(manager, { type: 'daily', limit: 10.00 });
55
55
 
56
- const result = checkBudget(manager, { currentSpend: 5.00 });
56
+ const result = checkBudget(manager, { currentSpend: 3.00 });
57
57
 
58
58
  assert.strictEqual(result.status, 'ok');
59
59
  });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Bypass Logger
3
+ *
4
+ * Logs when someone uses --no-verify to bypass the code gate.
5
+ * Maintains an append-only JSONL audit trail.
6
+ *
7
+ * @module code-gate/bypass-logger
8
+ */
9
+
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+
13
+ /** Default audit file path relative to project root */
14
+ const AUDIT_FILE = '.tlc/audit/gate-bypasses.jsonl';
15
+
16
+ /**
17
+ * Log a gate bypass event to the audit trail.
18
+ *
19
+ * @param {Object} event
20
+ * @param {string} event.user - Who bypassed
21
+ * @param {string} event.commitHash - Commit hash
22
+ * @param {string[]} event.filesChanged - Files in the commit
23
+ * @param {string} [event.hookType] - Which hook was bypassed
24
+ * @param {Object} [options]
25
+ * @param {Object} [options.fs] - File system module
26
+ * @param {string} [options.projectPath] - Project root
27
+ */
28
+ function logBypass(event, options = {}) {
29
+ const fsModule = options.fs || fs;
30
+ const projectPath = options.projectPath || process.cwd();
31
+ const auditPath = path.join(projectPath, AUDIT_FILE);
32
+ const auditDir = path.dirname(auditPath);
33
+
34
+ if (!fsModule.existsSync(auditDir)) {
35
+ fsModule.mkdirSync(auditDir, { recursive: true });
36
+ }
37
+
38
+ const record = {
39
+ timestamp: new Date().toISOString(),
40
+ user: event.user,
41
+ commitHash: event.commitHash,
42
+ filesChanged: event.filesChanged,
43
+ hookType: event.hookType || 'unknown',
44
+ };
45
+
46
+ fsModule.appendFileSync(auditPath, JSON.stringify(record) + '\n');
47
+ }
48
+
49
+ /**
50
+ * Read the bypass audit history.
51
+ *
52
+ * @param {string} projectPath
53
+ * @param {Object} [options]
54
+ * @param {Object} [options.fs] - File system module
55
+ * @returns {Array<Object>} Array of bypass events
56
+ */
57
+ function readBypassHistory(projectPath, options = {}) {
58
+ const fsModule = options.fs || fs;
59
+ const auditPath = path.join(projectPath, AUDIT_FILE);
60
+
61
+ if (!fsModule.existsSync(auditPath)) {
62
+ return [];
63
+ }
64
+
65
+ const content = fsModule.readFileSync(auditPath, 'utf-8');
66
+ const lines = content.split('\n').filter(line => line.trim());
67
+ const entries = [];
68
+
69
+ for (const line of lines) {
70
+ try {
71
+ entries.push(JSON.parse(line));
72
+ } catch {
73
+ // Skip malformed lines
74
+ }
75
+ }
76
+
77
+ return entries;
78
+ }
79
+
80
+ /**
81
+ * Calculate bypass count per user.
82
+ *
83
+ * @param {Array<{user: string}>} history
84
+ * @returns {Object.<string, number>} User to bypass count map
85
+ */
86
+ function getBypassRate(history) {
87
+ const rates = {};
88
+ for (const entry of history) {
89
+ rates[entry.user] = (rates[entry.user] || 0) + 1;
90
+ }
91
+ return rates;
92
+ }
93
+
94
+ /**
95
+ * Format bypass history as a readable report.
96
+ *
97
+ * @param {Array} history
98
+ * @returns {string}
99
+ */
100
+ function formatBypassReport(history) {
101
+ if (history.length === 0) {
102
+ return 'No bypasses recorded. Gate compliance is 100%.';
103
+ }
104
+
105
+ let report = 'Gate Bypass Report\n';
106
+ report += '─'.repeat(42) + '\n\n';
107
+
108
+ const rates = getBypassRate(history);
109
+ report += 'Bypasses by user:\n';
110
+ for (const [user, count] of Object.entries(rates)) {
111
+ report += ` ${user}: ${count} bypass${count > 1 ? 'es' : ''}\n`;
112
+ }
113
+
114
+ report += `\nRecent bypasses:\n`;
115
+ const recent = history.slice(-5);
116
+ for (const entry of recent) {
117
+ const date = entry.timestamp ? entry.timestamp.split('T')[0] : 'unknown';
118
+ report += ` ${date} ${entry.user} (${entry.commitHash})\n`;
119
+ }
120
+
121
+ return report;
122
+ }
123
+
124
+ module.exports = {
125
+ logBypass,
126
+ readBypassHistory,
127
+ getBypassRate,
128
+ formatBypassReport,
129
+ };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Bypass Logger Tests
3
+ *
4
+ * Logs when someone uses --no-verify to bypass the gate.
5
+ * Tracks bypass frequency per user.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+
9
+ const {
10
+ logBypass,
11
+ readBypassHistory,
12
+ getBypassRate,
13
+ formatBypassReport,
14
+ } = require('./bypass-logger.js');
15
+
16
+ describe('Bypass Logger', () => {
17
+ describe('logBypass', () => {
18
+ it('appends bypass event to audit file', () => {
19
+ let written = '';
20
+ const mockFs = {
21
+ existsSync: vi.fn().mockReturnValue(true),
22
+ mkdirSync: vi.fn(),
23
+ appendFileSync: vi.fn((path, data) => { written = data; }),
24
+ };
25
+
26
+ logBypass({
27
+ user: 'alice',
28
+ commitHash: 'abc123',
29
+ filesChanged: ['src/app.js'],
30
+ hookType: 'pre-commit',
31
+ }, { fs: mockFs });
32
+
33
+ const parsed = JSON.parse(written.trim());
34
+ expect(parsed.user).toBe('alice');
35
+ expect(parsed.commitHash).toBe('abc123');
36
+ expect(parsed.filesChanged).toEqual(['src/app.js']);
37
+ expect(parsed.timestamp).toBeDefined();
38
+ });
39
+
40
+ it('creates audit directory if missing', () => {
41
+ const mockFs = {
42
+ existsSync: vi.fn().mockReturnValue(false),
43
+ mkdirSync: vi.fn(),
44
+ appendFileSync: vi.fn(),
45
+ };
46
+
47
+ logBypass({ user: 'bob', commitHash: 'def456', filesChanged: [] }, { fs: mockFs });
48
+ expect(mockFs.mkdirSync).toHaveBeenCalled();
49
+ });
50
+
51
+ it('writes to .tlc/audit/gate-bypasses.jsonl', () => {
52
+ let writtenPath = '';
53
+ const mockFs = {
54
+ existsSync: vi.fn().mockReturnValue(true),
55
+ mkdirSync: vi.fn(),
56
+ appendFileSync: vi.fn((path) => { writtenPath = path; }),
57
+ };
58
+
59
+ logBypass({ user: 'x', commitHash: 'y', filesChanged: [] }, { fs: mockFs });
60
+ expect(writtenPath).toContain('gate-bypasses.jsonl');
61
+ });
62
+ });
63
+
64
+ describe('readBypassHistory', () => {
65
+ it('reads and parses JSONL file', () => {
66
+ const lines = [
67
+ JSON.stringify({ user: 'alice', timestamp: '2024-01-01T00:00:00Z', commitHash: 'a1' }),
68
+ JSON.stringify({ user: 'bob', timestamp: '2024-01-02T00:00:00Z', commitHash: 'b2' }),
69
+ ].join('\n');
70
+
71
+ const mockFs = {
72
+ existsSync: vi.fn().mockReturnValue(true),
73
+ readFileSync: vi.fn().mockReturnValue(lines),
74
+ };
75
+
76
+ const history = readBypassHistory('/project', { fs: mockFs });
77
+ expect(history).toHaveLength(2);
78
+ expect(history[0].user).toBe('alice');
79
+ });
80
+
81
+ it('returns empty array when no file exists', () => {
82
+ const mockFs = {
83
+ existsSync: vi.fn().mockReturnValue(false),
84
+ };
85
+
86
+ const history = readBypassHistory('/project', { fs: mockFs });
87
+ expect(history).toEqual([]);
88
+ });
89
+
90
+ it('skips malformed lines', () => {
91
+ const lines = [
92
+ JSON.stringify({ user: 'alice', commitHash: 'a1' }),
93
+ 'not valid json',
94
+ JSON.stringify({ user: 'bob', commitHash: 'b2' }),
95
+ ].join('\n');
96
+
97
+ const mockFs = {
98
+ existsSync: vi.fn().mockReturnValue(true),
99
+ readFileSync: vi.fn().mockReturnValue(lines),
100
+ };
101
+
102
+ const history = readBypassHistory('/project', { fs: mockFs });
103
+ expect(history).toHaveLength(2);
104
+ });
105
+ });
106
+
107
+ describe('getBypassRate', () => {
108
+ it('calculates bypasses per user', () => {
109
+ const history = [
110
+ { user: 'alice', timestamp: '2024-01-01' },
111
+ { user: 'alice', timestamp: '2024-01-02' },
112
+ { user: 'bob', timestamp: '2024-01-03' },
113
+ ];
114
+
115
+ const rates = getBypassRate(history);
116
+ expect(rates.alice).toBe(2);
117
+ expect(rates.bob).toBe(1);
118
+ });
119
+
120
+ it('returns empty object for empty history', () => {
121
+ const rates = getBypassRate([]);
122
+ expect(Object.keys(rates)).toHaveLength(0);
123
+ });
124
+ });
125
+
126
+ describe('formatBypassReport', () => {
127
+ it('formats bypass history as readable report', () => {
128
+ const history = [
129
+ { user: 'alice', timestamp: '2024-01-01T00:00:00Z', commitHash: 'abc123', hookType: 'pre-commit' },
130
+ ];
131
+
132
+ const report = formatBypassReport(history);
133
+ expect(report).toContain('alice');
134
+ expect(report).toContain('abc123');
135
+ });
136
+
137
+ it('shows all-clear when no bypasses', () => {
138
+ const report = formatBypassReport([]);
139
+ expect(report).toContain('No bypasses');
140
+ });
141
+ });
142
+ });