tlc-claude-code 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
6
  "tlc": "./bin/tlc.js",
@@ -0,0 +1,138 @@
1
+ /**
2
+ * First-Commit Audit Hook
3
+ *
4
+ * Auto-runs architectural audit on first commit to catch
5
+ * AI-generated code issues before they accumulate.
6
+ * The "2-hour audit on day 1 saves 10 days" lesson.
7
+ *
8
+ * @module code-gate/first-commit-audit
9
+ */
10
+
11
+ const path = require('path');
12
+ const defaultFs = require('fs').promises;
13
+
14
+ /** Marker file path relative to project root */
15
+ const MARKER_FILE = '.tlc/first-audit-done';
16
+
17
+ /** Fix suggestions by audit issue type */
18
+ const FIX_SUGGESTIONS = {
19
+ 'hardcoded-url': 'Extract to environment variable using process.env',
20
+ 'hardcoded-port': 'Extract port to environment variable',
21
+ 'flat-folder': 'Reorganize into entity-based folder structure (src/{entity}/)',
22
+ 'inline-interface': 'Extract interface to separate types file',
23
+ 'magic-string': 'Replace with named constant',
24
+ 'flat-seeds': 'Move seeds into per-entity seed folders',
25
+ 'missing-jsdoc': 'Add JSDoc comment to exported function',
26
+ 'deep-import': 'Use path aliases or restructure to reduce nesting',
27
+ 'missing': 'Create required standards file',
28
+ };
29
+
30
+ /**
31
+ * Check if the first audit has already run
32
+ * @param {string} projectPath - Path to project root
33
+ * @param {Object} options - Injectable dependencies
34
+ * @param {Object} options.fs - File system module
35
+ * @returns {Promise<boolean>} True if marker exists
36
+ */
37
+ async function hasFirstAuditRun(projectPath, options = {}) {
38
+ const fsModule = options.fs || defaultFs;
39
+ const markerPath = path.join(projectPath, MARKER_FILE);
40
+ try {
41
+ await fsModule.access(markerPath);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Convert audit results to gate findings
50
+ * @param {Object} auditResults - Results from auditProject()
51
+ * @returns {Array<Object>} Gate findings with severity: warn
52
+ */
53
+ function convertAuditToFindings(auditResults) {
54
+ const findings = [];
55
+
56
+ const categories = [
57
+ 'standardsFiles', 'flatFolders', 'inlineInterfaces',
58
+ 'hardcodedUrls', 'magicStrings', 'seedOrganization',
59
+ 'jsDocCoverage', 'importStyle',
60
+ ];
61
+
62
+ for (const category of categories) {
63
+ const result = auditResults[category];
64
+ if (!result || !result.issues) continue;
65
+
66
+ for (const issue of result.issues) {
67
+ findings.push({
68
+ severity: 'warn',
69
+ rule: `first-audit/${issue.type}`,
70
+ file: issue.file || issue.folder || 'project',
71
+ line: 0,
72
+ message: `First-commit audit: ${issue.type}${issue.value ? ` (${issue.value})` : ''}`,
73
+ fix: FIX_SUGGESTIONS[issue.type] || 'Review and fix manually',
74
+ });
75
+ }
76
+ }
77
+
78
+ return findings;
79
+ }
80
+
81
+ /**
82
+ * Run the first-commit audit
83
+ * @param {string} projectPath - Path to project root
84
+ * @param {Object} options - Injectable dependencies
85
+ * @param {Object} options.fs - File system module
86
+ * @param {Function} options.auditProject - Audit function from audit-checker
87
+ * @param {Object} options.config - Gate config
88
+ * @returns {Promise<Object>} Result with findings or skipped flag
89
+ */
90
+ async function runFirstCommitAudit(projectPath, options = {}) {
91
+ const fsModule = options.fs || defaultFs;
92
+ const { auditProject, config } = options;
93
+
94
+ // Check if disabled via config
95
+ if (config && config.firstCommitAudit === false) {
96
+ return { skipped: true, reason: 'disabled' };
97
+ }
98
+
99
+ // Check if already run
100
+ const alreadyRun = await hasFirstAuditRun(projectPath, { fs: fsModule });
101
+ if (alreadyRun) {
102
+ return { skipped: true, reason: 'already-run' };
103
+ }
104
+
105
+ // Run audit
106
+ const auditResults = await auditProject(projectPath, { fs: fsModule });
107
+ const findings = convertAuditToFindings(auditResults);
108
+
109
+ // Create marker file
110
+ const markerPath = path.join(projectPath, MARKER_FILE);
111
+ const markerDir = path.dirname(markerPath);
112
+ await fsModule.mkdir(markerDir, { recursive: true });
113
+ await fsModule.writeFile(markerPath, `First audit completed at ${new Date().toISOString()}\n`);
114
+
115
+ return {
116
+ skipped: false,
117
+ findings,
118
+ auditResults,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Create a first-commit audit instance with dependencies
124
+ * @param {Object} deps - Injectable dependencies
125
+ * @returns {Object} Audit instance with run method
126
+ */
127
+ function createFirstCommitAudit(deps = {}) {
128
+ return {
129
+ run: (projectPath, config) => runFirstCommitAudit(projectPath, { ...deps, config }),
130
+ };
131
+ }
132
+
133
+ module.exports = {
134
+ createFirstCommitAudit,
135
+ hasFirstAuditRun,
136
+ convertAuditToFindings,
137
+ runFirstCommitAudit,
138
+ };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * First-Commit Audit Hook Tests
3
+ *
4
+ * Auto-runs architectural audit on first commit to catch
5
+ * AI-generated code issues before they accumulate.
6
+ */
7
+ import { describe, it, expect, vi } from 'vitest';
8
+
9
+ const {
10
+ createFirstCommitAudit,
11
+ hasFirstAuditRun,
12
+ convertAuditToFindings,
13
+ runFirstCommitAudit,
14
+ } = require('./first-commit-audit.js');
15
+
16
+ describe('First-Commit Audit Hook', () => {
17
+ describe('hasFirstAuditRun', () => {
18
+ it('returns false when no marker exists', async () => {
19
+ const mockFs = {
20
+ access: vi.fn().mockRejectedValue(new Error('ENOENT')),
21
+ };
22
+ const result = await hasFirstAuditRun('/project', { fs: mockFs });
23
+ expect(result).toBe(false);
24
+ });
25
+
26
+ it('returns true when marker exists', async () => {
27
+ const mockFs = {
28
+ access: vi.fn().mockResolvedValue(undefined),
29
+ };
30
+ const result = await hasFirstAuditRun('/project', { fs: mockFs });
31
+ expect(result).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe('convertAuditToFindings', () => {
36
+ it('converts audit issues to gate findings with severity warn', () => {
37
+ const auditResults = {
38
+ hardcodedUrls: {
39
+ passed: false,
40
+ issues: [
41
+ { type: 'hardcoded-url', file: 'src/api.js', value: 'http://localhost:3000' },
42
+ ],
43
+ },
44
+ flatFolders: {
45
+ passed: false,
46
+ issues: [
47
+ { type: 'flat-folder', folder: 'services' },
48
+ ],
49
+ },
50
+ summary: { totalIssues: 2, passed: false },
51
+ };
52
+
53
+ const findings = convertAuditToFindings(auditResults);
54
+ expect(findings).toHaveLength(2);
55
+ expect(findings[0].severity).toBe('warn');
56
+ expect(findings[1].severity).toBe('warn');
57
+ });
58
+
59
+ it('returns correct severity for all findings', () => {
60
+ const auditResults = {
61
+ magicStrings: {
62
+ passed: false,
63
+ issues: [
64
+ { type: 'magic-string', file: 'src/auth.js', value: 'admin' },
65
+ ],
66
+ },
67
+ summary: { totalIssues: 1, passed: false },
68
+ };
69
+
70
+ const findings = convertAuditToFindings(auditResults);
71
+ expect(findings.every(f => f.severity === 'warn')).toBe(true);
72
+ });
73
+
74
+ it('includes fix suggestions from audit', () => {
75
+ const auditResults = {
76
+ hardcodedUrls: {
77
+ passed: false,
78
+ issues: [
79
+ { type: 'hardcoded-url', file: 'src/api.js', value: 'http://localhost:3000' },
80
+ ],
81
+ },
82
+ summary: { totalIssues: 1, passed: false },
83
+ };
84
+
85
+ const findings = convertAuditToFindings(auditResults);
86
+ expect(findings[0].fix).toBeDefined();
87
+ expect(findings[0].fix.length).toBeGreaterThan(0);
88
+ });
89
+
90
+ it('returns empty array for clean audit', () => {
91
+ const auditResults = {
92
+ hardcodedUrls: { passed: true, issues: [] },
93
+ flatFolders: { passed: true, issues: [] },
94
+ summary: { totalIssues: 0, passed: true },
95
+ };
96
+
97
+ const findings = convertAuditToFindings(auditResults);
98
+ expect(findings).toHaveLength(0);
99
+ });
100
+
101
+ it('handles multiple issues from same category', () => {
102
+ const auditResults = {
103
+ hardcodedUrls: {
104
+ passed: false,
105
+ issues: [
106
+ { type: 'hardcoded-url', file: 'src/api.js', value: 'http://localhost:3000' },
107
+ { type: 'hardcoded-url', file: 'src/config.js', value: 'http://localhost:5000' },
108
+ { type: 'hardcoded-port', file: 'src/server.js', value: '8080' },
109
+ ],
110
+ },
111
+ summary: { totalIssues: 3, passed: false },
112
+ };
113
+
114
+ const findings = convertAuditToFindings(auditResults);
115
+ expect(findings).toHaveLength(3);
116
+ });
117
+ });
118
+
119
+ describe('runFirstCommitAudit', () => {
120
+ it('runs audit when no marker exists', async () => {
121
+ const mockFs = {
122
+ access: vi.fn().mockRejectedValue(new Error('ENOENT')),
123
+ mkdir: vi.fn().mockResolvedValue(undefined),
124
+ writeFile: vi.fn().mockResolvedValue(undefined),
125
+ };
126
+ const mockAuditProject = vi.fn().mockResolvedValue({
127
+ hardcodedUrls: { passed: true, issues: [] },
128
+ summary: { totalIssues: 0, passed: true },
129
+ });
130
+
131
+ const result = await runFirstCommitAudit('/project', {
132
+ fs: mockFs,
133
+ auditProject: mockAuditProject,
134
+ });
135
+
136
+ expect(mockAuditProject).toHaveBeenCalledWith('/project', { fs: mockFs });
137
+ expect(result.findings).toBeDefined();
138
+ });
139
+
140
+ it('skips audit when marker exists', async () => {
141
+ const mockFs = {
142
+ access: vi.fn().mockResolvedValue(undefined),
143
+ };
144
+ const mockAuditProject = vi.fn();
145
+
146
+ const result = await runFirstCommitAudit('/project', {
147
+ fs: mockFs,
148
+ auditProject: mockAuditProject,
149
+ });
150
+
151
+ expect(mockAuditProject).not.toHaveBeenCalled();
152
+ expect(result.skipped).toBe(true);
153
+ });
154
+
155
+ it('creates marker after successful run', async () => {
156
+ const mockFs = {
157
+ access: vi.fn().mockRejectedValue(new Error('ENOENT')),
158
+ mkdir: vi.fn().mockResolvedValue(undefined),
159
+ writeFile: vi.fn().mockResolvedValue(undefined),
160
+ };
161
+ const mockAuditProject = vi.fn().mockResolvedValue({
162
+ summary: { totalIssues: 0, passed: true },
163
+ });
164
+
165
+ await runFirstCommitAudit('/project', {
166
+ fs: mockFs,
167
+ auditProject: mockAuditProject,
168
+ });
169
+
170
+ expect(mockFs.writeFile).toHaveBeenCalledWith(
171
+ expect.stringContaining('first-audit-done'),
172
+ expect.any(String)
173
+ );
174
+ });
175
+
176
+ it('respects enabled/disabled config', async () => {
177
+ const mockFs = {
178
+ access: vi.fn().mockRejectedValue(new Error('ENOENT')),
179
+ };
180
+ const mockAuditProject = vi.fn();
181
+
182
+ const result = await runFirstCommitAudit('/project', {
183
+ fs: mockFs,
184
+ auditProject: mockAuditProject,
185
+ config: { firstCommitAudit: false },
186
+ });
187
+
188
+ expect(mockAuditProject).not.toHaveBeenCalled();
189
+ expect(result.skipped).toBe(true);
190
+ });
191
+ });
192
+
193
+ describe('createFirstCommitAudit', () => {
194
+ it('works with injectable dependencies', () => {
195
+ const audit = createFirstCommitAudit({
196
+ fs: {},
197
+ auditProject: vi.fn(),
198
+ });
199
+ expect(audit).toBeDefined();
200
+ expect(audit.run).toBeDefined();
201
+ });
202
+ });
203
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Multi-Model Reviewer
3
+ *
4
+ * Sends code reviews to 2+ LLM models and aggregates findings.
5
+ * Different models catch different bugs — consensus scoring
6
+ * highlights issues multiple models agree on.
7
+ *
8
+ * @module code-gate/multi-model-reviewer
9
+ */
10
+
11
+ /** Severity priority for conflict resolution (higher wins) */
12
+ const SEVERITY_PRIORITY = { block: 3, warn: 2, info: 1 };
13
+
14
+ /**
15
+ * Send diff to multiple models in parallel
16
+ * @param {string} diff - Git diff content
17
+ * @param {string[]} models - List of model names
18
+ * @param {Object} options - Options
19
+ * @param {Function} options.reviewFn - Function(diff, model) => { findings, summary }
20
+ * @param {number} options.timeout - Per-model timeout in ms
21
+ * @returns {Promise<Array>} Results from successful models
22
+ */
23
+ async function sendToModels(diff, models, options = {}) {
24
+ const { reviewFn, timeout } = options;
25
+ const results = [];
26
+
27
+ const promises = models.map(async (model) => {
28
+ try {
29
+ let reviewPromise = reviewFn(diff, model);
30
+
31
+ // Apply per-model timeout if specified
32
+ if (timeout) {
33
+ reviewPromise = Promise.race([
34
+ reviewPromise,
35
+ new Promise((_, reject) =>
36
+ setTimeout(() => reject(new Error(`Timeout for ${model}`)), timeout)
37
+ ),
38
+ ]);
39
+ }
40
+
41
+ const result = await reviewPromise;
42
+ return { model, ...result };
43
+ } catch {
44
+ return null; // Model failed, will be filtered out
45
+ }
46
+ });
47
+
48
+ const settled = await Promise.all(promises);
49
+ for (const result of settled) {
50
+ if (result) results.push(result);
51
+ }
52
+
53
+ return results;
54
+ }
55
+
56
+ /**
57
+ * Aggregate and deduplicate findings from multiple models
58
+ * @param {Array} modelResults - Results from sendToModels
59
+ * @returns {Object} Aggregated result with deduplicated findings
60
+ */
61
+ function aggregateFindings(modelResults) {
62
+ // Collect all findings with model attribution
63
+ const allFindings = [];
64
+
65
+ for (const result of modelResults) {
66
+ for (const finding of (result.findings || [])) {
67
+ allFindings.push({
68
+ ...finding,
69
+ flaggedBy: [result.model],
70
+ });
71
+ }
72
+ }
73
+
74
+ // Deduplicate
75
+ const deduped = deduplicateFindings(allFindings);
76
+
77
+ return {
78
+ findings: deduped,
79
+ modelCount: modelResults.length,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Deduplicate findings by file+line+rule, merging flaggedBy lists
85
+ * @param {Array} findings - All findings with flaggedBy
86
+ * @returns {Array} Deduplicated findings
87
+ */
88
+ function deduplicateFindings(findings) {
89
+ const map = new Map();
90
+
91
+ for (const finding of findings) {
92
+ const key = `${finding.file}:${finding.line}:${finding.rule}`;
93
+
94
+ if (map.has(key)) {
95
+ const existing = map.get(key);
96
+ // Merge flaggedBy
97
+ for (const model of finding.flaggedBy) {
98
+ if (!existing.flaggedBy.includes(model)) {
99
+ existing.flaggedBy.push(model);
100
+ }
101
+ }
102
+ // Higher severity wins
103
+ const existingPriority = SEVERITY_PRIORITY[existing.severity] || 0;
104
+ const newPriority = SEVERITY_PRIORITY[finding.severity] || 0;
105
+ if (newPriority > existingPriority) {
106
+ existing.severity = finding.severity;
107
+ existing.message = finding.message;
108
+ }
109
+ } else {
110
+ map.set(key, { ...finding });
111
+ }
112
+ }
113
+
114
+ return Array.from(map.values());
115
+ }
116
+
117
+ /**
118
+ * Calculate consensus percentage for a finding
119
+ * @param {Object} finding - Finding with flaggedBy array
120
+ * @param {number} totalModels - Total models queried
121
+ * @returns {number} Consensus percentage (0-100)
122
+ */
123
+ function calculateConsensus(finding, totalModels) {
124
+ if (totalModels === 0) return 0;
125
+ return (finding.flaggedBy.length / totalModels) * 100;
126
+ }
127
+
128
+ /**
129
+ * Merge summaries from all model results
130
+ * @param {Array} modelResults - Results with model and summary fields
131
+ * @returns {string} Merged summary
132
+ */
133
+ function mergeSummaries(modelResults) {
134
+ return modelResults
135
+ .filter(r => r.summary)
136
+ .map(r => `[${r.model}]: ${r.summary}`)
137
+ .join('\n');
138
+ }
139
+
140
+ /**
141
+ * Create a multi-model reviewer instance
142
+ * @param {Object} options - Configuration
143
+ * @param {string[]} options.models - Model names to use
144
+ * @param {Function} options.reviewFn - Review function
145
+ * @param {number} options.timeout - Per-model timeout
146
+ * @returns {Object} Reviewer instance
147
+ */
148
+ function createMultiModelReviewer(options = {}) {
149
+ const { models = [], reviewFn, timeout } = options;
150
+
151
+ return {
152
+ models,
153
+ review: async (diff) => {
154
+ const results = await sendToModels(diff, models, { reviewFn, timeout });
155
+ if (results.length === 0) {
156
+ return { findings: [], summary: 'All models failed — static-only fallback', modelCount: 0 };
157
+ }
158
+ const aggregated = aggregateFindings(results);
159
+ const summary = mergeSummaries(results);
160
+ return { ...aggregated, summary };
161
+ },
162
+ };
163
+ }
164
+
165
+ module.exports = {
166
+ createMultiModelReviewer,
167
+ sendToModels,
168
+ aggregateFindings,
169
+ deduplicateFindings,
170
+ calculateConsensus,
171
+ mergeSummaries,
172
+ };