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,161 @@
1
+ /**
2
+ * LLM Reviewer Tests
3
+ *
4
+ * Mandatory LLM code review before every push, using multi-model router.
5
+ * Collects diff, sends to LLM, parses structured review result.
6
+ */
7
+ import { describe, it, expect, vi } from 'vitest';
8
+
9
+ const {
10
+ createReviewer,
11
+ buildReviewPrompt,
12
+ parseReviewResponse,
13
+ collectDiff,
14
+ shouldSkipReview,
15
+ storeReviewResult,
16
+ } = require('./llm-reviewer.js');
17
+
18
+ describe('LLM Reviewer', () => {
19
+ describe('createReviewer', () => {
20
+ it('creates reviewer with default options', () => {
21
+ const reviewer = createReviewer();
22
+ expect(reviewer).toBeDefined();
23
+ expect(reviewer.options.timeout).toBeDefined();
24
+ });
25
+
26
+ it('accepts custom timeout', () => {
27
+ const reviewer = createReviewer({ timeout: 30000 });
28
+ expect(reviewer.options.timeout).toBe(30000);
29
+ });
30
+ });
31
+
32
+ describe('buildReviewPrompt', () => {
33
+ it('includes diff in prompt', () => {
34
+ const prompt = buildReviewPrompt('--- a/file.js\n+++ b/file.js\n+const x = 1;', '');
35
+ expect(prompt).toContain('file.js');
36
+ expect(prompt).toContain('const x = 1');
37
+ });
38
+
39
+ it('includes coding standards in prompt', () => {
40
+ const standards = '# Coding Standards\n- No hardcoded URLs';
41
+ const prompt = buildReviewPrompt('diff content', standards);
42
+ expect(prompt).toContain('No hardcoded URLs');
43
+ });
44
+
45
+ it('instructs strict review', () => {
46
+ const prompt = buildReviewPrompt('diff', '');
47
+ expect(prompt.toLowerCase()).toContain('strict');
48
+ });
49
+
50
+ it('requests structured JSON output', () => {
51
+ const prompt = buildReviewPrompt('diff', '');
52
+ expect(prompt).toContain('JSON');
53
+ });
54
+ });
55
+
56
+ describe('parseReviewResponse', () => {
57
+ it('parses valid JSON review', () => {
58
+ const response = JSON.stringify({
59
+ findings: [
60
+ { severity: 'high', file: 'src/app.js', line: 10, rule: 'security', message: 'XSS risk', fix: 'Sanitize' },
61
+ ],
62
+ summary: 'Found 1 issue',
63
+ });
64
+ const result = parseReviewResponse(response);
65
+ expect(result.findings).toHaveLength(1);
66
+ expect(result.findings[0].severity).toBe('block'); // high normalizes to block
67
+ });
68
+
69
+ it('handles response with JSON in markdown code block', () => {
70
+ const response = '```json\n{"findings": [], "summary": "All clear"}\n```';
71
+ const result = parseReviewResponse(response);
72
+ expect(result.findings).toEqual([]);
73
+ });
74
+
75
+ it('returns error finding on unparseable response', () => {
76
+ const result = parseReviewResponse('I could not review this code because...');
77
+ expect(result.findings).toHaveLength(1);
78
+ expect(result.findings[0].severity).toBe('warn');
79
+ expect(result.findings[0].rule).toBe('llm-parse-error');
80
+ });
81
+
82
+ it('normalizes severity levels', () => {
83
+ const response = JSON.stringify({
84
+ findings: [
85
+ { severity: 'critical', file: 'x.js', message: 'Bad', fix: 'Fix' },
86
+ { severity: 'low', file: 'y.js', message: 'Minor', fix: 'Maybe' },
87
+ ],
88
+ });
89
+ const result = parseReviewResponse(response);
90
+ // critical and high map to 'block', medium and low map to 'warn'
91
+ expect(result.findings[0].severity).toBe('block');
92
+ expect(result.findings[1].severity).toBe('info');
93
+ });
94
+ });
95
+
96
+ describe('collectDiff', () => {
97
+ it('returns diff from exec', async () => {
98
+ const mockExec = vi.fn().mockResolvedValue('diff --git a/file.js\n+line');
99
+ const diff = await collectDiff({ exec: mockExec });
100
+ expect(diff).toContain('file.js');
101
+ expect(mockExec).toHaveBeenCalled();
102
+ });
103
+
104
+ it('returns empty string when no changes', async () => {
105
+ const mockExec = vi.fn().mockResolvedValue('');
106
+ const diff = await collectDiff({ exec: mockExec });
107
+ expect(diff).toBe('');
108
+ });
109
+ });
110
+
111
+ describe('shouldSkipReview', () => {
112
+ it('skips docs-only changes', () => {
113
+ const files = ['README.md', 'docs/guide.md', 'CHANGELOG.md'];
114
+ expect(shouldSkipReview(files)).toBe(true);
115
+ });
116
+
117
+ it('does not skip code changes', () => {
118
+ const files = ['src/app.js', 'README.md'];
119
+ expect(shouldSkipReview(files)).toBe(false);
120
+ });
121
+
122
+ it('does not skip config changes', () => {
123
+ const files = ['package.json', '.env.example'];
124
+ expect(shouldSkipReview(files)).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe('storeReviewResult', () => {
129
+ it('writes review to .tlc/reviews/{hash}.json', () => {
130
+ let writtenPath = '';
131
+ let writtenData = '';
132
+ const mockFs = {
133
+ existsSync: vi.fn().mockReturnValue(true),
134
+ mkdirSync: vi.fn(),
135
+ writeFileSync: vi.fn((path, data) => {
136
+ writtenPath = path;
137
+ writtenData = data;
138
+ }),
139
+ };
140
+
141
+ const result = { findings: [], summary: 'Clean' };
142
+ storeReviewResult('abc123', result, { fs: mockFs });
143
+
144
+ expect(writtenPath).toContain('abc123.json');
145
+ expect(writtenPath).toContain('reviews');
146
+ const parsed = JSON.parse(writtenData);
147
+ expect(parsed.summary).toBe('Clean');
148
+ });
149
+
150
+ it('creates reviews directory if missing', () => {
151
+ const mockFs = {
152
+ existsSync: vi.fn().mockReturnValue(false),
153
+ mkdirSync: vi.fn(),
154
+ writeFileSync: vi.fn(),
155
+ };
156
+
157
+ storeReviewResult('xyz', { findings: [] }, { fs: mockFs });
158
+ expect(mockFs.mkdirSync).toHaveBeenCalled();
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Push Gate
3
+ *
4
+ * Wires the static gate engine + LLM reviewer into the pre-push flow.
5
+ * Runs static first (fast feedback), then LLM if static passes.
6
+ * Falls back to static-only on LLM timeout.
7
+ *
8
+ * @module code-gate/push-gate
9
+ */
10
+
11
+ /** Default LLM review timeout in ms */
12
+ const DEFAULT_LLM_TIMEOUT = 60000;
13
+
14
+ /**
15
+ * Create a push gate instance.
16
+ *
17
+ * @param {Object} [options]
18
+ * @param {number} [options.llmTimeout] - LLM review timeout in ms
19
+ * @returns {{ options: Object }}
20
+ */
21
+ function createPushGate(options = {}) {
22
+ return {
23
+ options: {
24
+ llmTimeout: options.llmTimeout || DEFAULT_LLM_TIMEOUT,
25
+ },
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Run the full push gate: static analysis then LLM review.
31
+ *
32
+ * @param {Object} params
33
+ * @param {Function} params.staticGate - Static gate runner (returns gate result)
34
+ * @param {Function} params.llmReview - LLM reviewer (returns review result)
35
+ * @param {Array} params.files - Changed files
36
+ * @param {boolean} [params.override] - Team lead override (TLC_GATE_OVERRIDE)
37
+ * @returns {Promise<Object>} Combined gate result
38
+ */
39
+ async function runPushGate(params) {
40
+ const { staticGate, llmReview, files, override = false } = params;
41
+
42
+ // Phase 1: Static gate (fast)
43
+ const staticResult = await staticGate(files);
44
+
45
+ // Team lead override
46
+ if (!staticResult.passed && override) {
47
+ return {
48
+ passed: true,
49
+ overridden: true,
50
+ staticResult,
51
+ llmResult: null,
52
+ findings: staticResult.findings,
53
+ summary: staticResult.summary,
54
+ };
55
+ }
56
+
57
+ // Static failure blocks immediately — no LLM call
58
+ if (!staticResult.passed) {
59
+ return {
60
+ passed: false,
61
+ overridden: false,
62
+ llmSkipped: true,
63
+ staticResult,
64
+ llmResult: null,
65
+ findings: staticResult.findings,
66
+ summary: staticResult.summary,
67
+ };
68
+ }
69
+
70
+ // Phase 2: LLM review (only if static passes)
71
+ let llmResult = null;
72
+ let llmSkipped = false;
73
+
74
+ try {
75
+ llmResult = await llmReview(files);
76
+ } catch {
77
+ // LLM timeout or error — fall back to static-only
78
+ llmSkipped = true;
79
+ }
80
+
81
+ // Merge results
82
+ const merged = mergeResults(staticResult, llmResult);
83
+
84
+ return {
85
+ passed: merged.passed,
86
+ overridden: false,
87
+ llmSkipped,
88
+ staticResult,
89
+ llmResult,
90
+ findings: merged.findings,
91
+ summary: merged.summary,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Merge static gate result with LLM review result.
97
+ * Tags each finding with its source for traceability.
98
+ *
99
+ * @param {Object} staticResult - Static gate result
100
+ * @param {Object|null} llmResult - LLM review result (null if skipped)
101
+ * @returns {{ passed: boolean, findings: Array, summary: Object }}
102
+ */
103
+ function mergeResults(staticResult, llmResult) {
104
+ const staticFindings = (staticResult.findings || []).map(f => ({
105
+ ...f,
106
+ source: 'static',
107
+ }));
108
+
109
+ const llmFindings = llmResult
110
+ ? (llmResult.findings || []).map(f => ({ ...f, source: 'llm' }))
111
+ : [];
112
+
113
+ const allFindings = [...staticFindings, ...llmFindings];
114
+
115
+ const summary = {
116
+ total: allFindings.length,
117
+ block: allFindings.filter(f => f.severity === 'block').length,
118
+ warn: allFindings.filter(f => f.severity === 'warn').length,
119
+ info: allFindings.filter(f => f.severity === 'info').length,
120
+ };
121
+
122
+ return {
123
+ passed: summary.block === 0,
124
+ findings: allFindings,
125
+ summary,
126
+ };
127
+ }
128
+
129
+ module.exports = {
130
+ runPushGate,
131
+ mergeResults,
132
+ createPushGate,
133
+ };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Push Gate Tests
3
+ *
4
+ * Wires the static gate engine + LLM reviewer into pre-push hook.
5
+ * Runs static first, then LLM if static passes.
6
+ */
7
+ import { describe, it, expect, vi } from 'vitest';
8
+
9
+ const {
10
+ runPushGate,
11
+ mergeResults,
12
+ createPushGate,
13
+ } = require('./push-gate.js');
14
+
15
+ describe('Push Gate', () => {
16
+ describe('createPushGate', () => {
17
+ it('creates push gate with default options', () => {
18
+ const gate = createPushGate();
19
+ expect(gate).toBeDefined();
20
+ expect(gate.options).toBeDefined();
21
+ });
22
+
23
+ it('accepts custom timeout', () => {
24
+ const gate = createPushGate({ llmTimeout: 30000 });
25
+ expect(gate.options.llmTimeout).toBe(30000);
26
+ });
27
+ });
28
+
29
+ describe('runPushGate', () => {
30
+ it('runs static gate then LLM review', async () => {
31
+ const callOrder = [];
32
+ const mockStaticGate = vi.fn(async () => {
33
+ callOrder.push('static');
34
+ return { passed: true, findings: [], summary: { total: 0, block: 0, warn: 0, info: 0 } };
35
+ });
36
+ const mockLlmReview = vi.fn(async () => {
37
+ callOrder.push('llm');
38
+ return { findings: [], summary: 'Clean' };
39
+ });
40
+
41
+ const result = await runPushGate({
42
+ staticGate: mockStaticGate,
43
+ llmReview: mockLlmReview,
44
+ files: [{ path: 'src/app.js', content: 'code' }],
45
+ });
46
+
47
+ expect(callOrder).toEqual(['static', 'llm']);
48
+ expect(result.passed).toBe(true);
49
+ });
50
+
51
+ it('blocks immediately on static gate failure', async () => {
52
+ const mockStaticGate = vi.fn(async () => ({
53
+ passed: false,
54
+ findings: [{ severity: 'block', rule: 'r1', file: 'x.js', message: 'Bad', fix: 'Fix' }],
55
+ summary: { total: 1, block: 1, warn: 0, info: 0 },
56
+ }));
57
+ const mockLlmReview = vi.fn();
58
+
59
+ const result = await runPushGate({
60
+ staticGate: mockStaticGate,
61
+ llmReview: mockLlmReview,
62
+ files: [],
63
+ });
64
+
65
+ expect(result.passed).toBe(false);
66
+ expect(mockLlmReview).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it('blocks on LLM review critical findings', async () => {
70
+ const mockStaticGate = vi.fn(async () => ({
71
+ passed: true,
72
+ findings: [],
73
+ summary: { total: 0, block: 0, warn: 0, info: 0 },
74
+ }));
75
+ const mockLlmReview = vi.fn(async () => ({
76
+ findings: [
77
+ { severity: 'block', rule: 'logic-error', file: 'x.js', message: 'Bug', fix: 'Fix' },
78
+ ],
79
+ }));
80
+
81
+ const result = await runPushGate({
82
+ staticGate: mockStaticGate,
83
+ llmReview: mockLlmReview,
84
+ files: [],
85
+ });
86
+
87
+ expect(result.passed).toBe(false);
88
+ });
89
+
90
+ it('handles LLM timeout gracefully', async () => {
91
+ const mockStaticGate = vi.fn(async () => ({
92
+ passed: true,
93
+ findings: [],
94
+ summary: { total: 0, block: 0, warn: 0, info: 0 },
95
+ }));
96
+ const mockLlmReview = vi.fn(async () => {
97
+ throw new Error('Timeout');
98
+ });
99
+
100
+ const result = await runPushGate({
101
+ staticGate: mockStaticGate,
102
+ llmReview: mockLlmReview,
103
+ files: [],
104
+ });
105
+
106
+ // Falls back to static-only - should pass
107
+ expect(result.passed).toBe(true);
108
+ expect(result.llmSkipped).toBe(true);
109
+ });
110
+
111
+ it('passes when both static and LLM pass', async () => {
112
+ const mockStaticGate = vi.fn(async () => ({
113
+ passed: true,
114
+ findings: [{ severity: 'warn', rule: 'r1', file: 'x.js', message: 'Minor', fix: 'Maybe' }],
115
+ summary: { total: 1, block: 0, warn: 1, info: 0 },
116
+ }));
117
+ const mockLlmReview = vi.fn(async () => ({
118
+ findings: [{ severity: 'info', rule: 'style', file: 'x.js', message: 'Style', fix: 'Format' }],
119
+ }));
120
+
121
+ const result = await runPushGate({
122
+ staticGate: mockStaticGate,
123
+ llmReview: mockLlmReview,
124
+ files: [],
125
+ });
126
+
127
+ expect(result.passed).toBe(true);
128
+ });
129
+
130
+ it('records override when TLC_GATE_OVERRIDE is set', async () => {
131
+ const mockStaticGate = vi.fn(async () => ({
132
+ passed: false,
133
+ findings: [{ severity: 'block', rule: 'r1', file: 'x.js', message: 'Bad', fix: 'Fix' }],
134
+ summary: { total: 1, block: 1, warn: 0, info: 0 },
135
+ }));
136
+
137
+ const result = await runPushGate({
138
+ staticGate: mockStaticGate,
139
+ llmReview: vi.fn(),
140
+ files: [],
141
+ override: true,
142
+ });
143
+
144
+ expect(result.passed).toBe(true);
145
+ expect(result.overridden).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe('mergeResults', () => {
150
+ it('combines static and LLM findings', () => {
151
+ const staticResult = {
152
+ findings: [{ severity: 'warn', rule: 'r1', file: 'a.js', message: 'A' }],
153
+ summary: { total: 1, block: 0, warn: 1, info: 0 },
154
+ };
155
+ const llmResult = {
156
+ findings: [{ severity: 'block', rule: 'r2', file: 'b.js', message: 'B' }],
157
+ };
158
+
159
+ const merged = mergeResults(staticResult, llmResult);
160
+ expect(merged.findings).toHaveLength(2);
161
+ expect(merged.summary.total).toBe(2);
162
+ expect(merged.summary.block).toBe(1);
163
+ expect(merged.summary.warn).toBe(1);
164
+ });
165
+
166
+ it('handles null LLM result', () => {
167
+ const staticResult = {
168
+ findings: [{ severity: 'warn', rule: 'r1', file: 'a.js', message: 'A' }],
169
+ summary: { total: 1, block: 0, warn: 1, info: 0 },
170
+ };
171
+
172
+ const merged = mergeResults(staticResult, null);
173
+ expect(merged.findings).toHaveLength(1);
174
+ });
175
+
176
+ it('tags findings with source', () => {
177
+ const staticResult = {
178
+ findings: [{ severity: 'warn', rule: 'r1', file: 'a.js', message: 'A' }],
179
+ summary: { total: 1, block: 0, warn: 1, info: 0 },
180
+ };
181
+ const llmResult = {
182
+ findings: [{ severity: 'warn', rule: 'r2', file: 'b.js', message: 'B' }],
183
+ };
184
+
185
+ const merged = mergeResults(staticResult, llmResult);
186
+ expect(merged.findings[0].source).toBe('static');
187
+ expect(merged.findings[1].source).toBe('llm');
188
+ });
189
+ });
190
+ });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Architecture Rules
3
+ *
4
+ * Detects single-writer pattern violations, fake API calls,
5
+ * stale re-export files, and raw API request bypass.
6
+ *
7
+ * Derived from 34 real-world bugs in production projects.
8
+ * See: TLC-BEST-PRACTICES.md, WALL_OF_SHAME.md
9
+ *
10
+ * @module code-gate/rules/architecture-rules
11
+ */
12
+
13
+ /**
14
+ * @param {string} filePath
15
+ * @returns {boolean}
16
+ */
17
+ function isTestFile(filePath) {
18
+ return /\.(test|spec)\.[jt]sx?$/.test(filePath) || filePath.includes('__tests__');
19
+ }
20
+
21
+ /**
22
+ * Common plural-to-singular and singular-to-plural mappings.
23
+ * Used to match table names to service file names.
24
+ * @param {string} tableName - e.g. "users", "companies"
25
+ * @returns {string[]} Possible service file stems
26
+ */
27
+ function tableNameVariants(tableName) {
28
+ const variants = [tableName];
29
+ // Plural → singular
30
+ if (tableName.endsWith('ies')) {
31
+ variants.push(tableName.slice(0, -3) + 'y'); // companies → company
32
+ } else if (tableName.endsWith('ses')) {
33
+ variants.push(tableName.slice(0, -2)); // addresses → address (approx)
34
+ } else if (tableName.endsWith('s')) {
35
+ variants.push(tableName.slice(0, -1)); // users → user
36
+ }
37
+ // Singular → plural (for reverse matching)
38
+ if (!tableName.endsWith('s')) {
39
+ variants.push(tableName + 's');
40
+ }
41
+ return variants;
42
+ }
43
+
44
+ /**
45
+ * Detect db.insert(X) or db.update(X) outside the service that owns table X.
46
+ *
47
+ * The owning service is determined by file name: X.service.* or Xs.service.*
48
+ * This prevents the #1 architectural anti-pattern from BEST-PRACTICES.md.
49
+ *
50
+ * @param {string} filePath
51
+ * @param {string} content
52
+ * @returns {Array<{severity: string, rule: string, line: number, message: string, fix: string}>}
53
+ */
54
+ function checkSingleWriter(filePath, content) {
55
+ if (isTestFile(filePath)) return [];
56
+ const findings = [];
57
+ const lines = content.split('\n');
58
+
59
+ const dbWritePattern = /\bdb\.(insert|update)\s*\(\s*(\w+)\s*\)/;
60
+
61
+ for (let i = 0; i < lines.length; i++) {
62
+ const line = lines[i];
63
+ const trimmed = line.trim();
64
+ if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
65
+
66
+ const match = line.match(dbWritePattern);
67
+ if (match) {
68
+ const operation = match[1];
69
+ const tableName = match[2];
70
+ const variants = tableNameVariants(tableName);
71
+
72
+ // Check if current file is the owning service
73
+ const fileBase = filePath.toLowerCase();
74
+ const isOwner = variants.some(v =>
75
+ fileBase.includes(`${v}.service`) || fileBase.includes(`${v}s.service`)
76
+ );
77
+
78
+ if (!isOwner) {
79
+ findings.push({
80
+ severity: 'block',
81
+ rule: 'single-writer',
82
+ line: i + 1,
83
+ message: `db.${operation}(${tableName}) outside owning service — violates single-writer pattern`,
84
+ fix: `Move this write to the ${tableName} service file (e.g. ${variants[0]}.service.ts)`,
85
+ });
86
+ }
87
+ }
88
+ }
89
+
90
+ return findings;
91
+ }
92
+
93
+ /**
94
+ * Detect setTimeout + resolve patterns that fake API calls.
95
+ * AI code generators use this to simulate async behavior instead
96
+ * of making real API calls.
97
+ *
98
+ * @param {string} filePath
99
+ * @param {string} content
100
+ * @returns {Array}
101
+ */
102
+ function checkFakeApiCalls(filePath, content) {
103
+ if (isTestFile(filePath)) return [];
104
+ const findings = [];
105
+ const lines = content.split('\n');
106
+
107
+ for (let i = 0; i < lines.length; i++) {
108
+ const line = lines[i];
109
+ const trimmed = line.trim();
110
+ if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
111
+
112
+ // setTimeout(() => resolve(...), number)
113
+ if (/setTimeout\s*\(\s*\(\)\s*=>\s*resolve\s*\(/.test(line)) {
114
+ findings.push({
115
+ severity: 'block',
116
+ rule: 'no-fake-api',
117
+ line: i + 1,
118
+ message: 'Fake API call using setTimeout + resolve — use a real API endpoint',
119
+ fix: 'Replace with actual API call using fetch() or an API client',
120
+ });
121
+ }
122
+ }
123
+
124
+ return findings;
125
+ }
126
+
127
+ /**
128
+ * Detect files that contain only a re-export statement.
129
+ * These accumulate as backwards-compatibility shims and become dead code.
130
+ *
131
+ * @param {string} filePath
132
+ * @param {string} content
133
+ * @returns {Array}
134
+ */
135
+ function checkStaleReexports(filePath, content) {
136
+ const trimmed = content.trim();
137
+ if (!trimmed) return [];
138
+
139
+ // Strip comments
140
+ const stripped = trimmed
141
+ .replace(/\/\/.*$/gm, '')
142
+ .replace(/\/\*[\s\S]*?\*\//g, '')
143
+ .trim();
144
+
145
+ // CommonJS re-export only
146
+ if (/^module\.exports\s*=\s*require\s*\([^)]+\)\s*;?\s*$/.test(stripped)) {
147
+ return [{
148
+ severity: 'warn',
149
+ rule: 'stale-reexport',
150
+ line: 1,
151
+ message: 'File contains only a re-export — likely a deprecated shim',
152
+ fix: 'Update all imports to point to the new location and delete this file',
153
+ }];
154
+ }
155
+
156
+ // ESM re-export only
157
+ if (/^export\s*\{[^}]*\}\s*from\s*['"][^'"]+['"]\s*;?\s*$/.test(stripped)) {
158
+ return [{
159
+ severity: 'warn',
160
+ rule: 'stale-reexport',
161
+ line: 1,
162
+ message: 'File contains only a re-export — likely a deprecated shim',
163
+ fix: 'Update all imports to point to the new location and delete this file',
164
+ }];
165
+ }
166
+
167
+ return [];
168
+ }
169
+
170
+ /**
171
+ * Detect raw apiRequest() or fetch('/api/...') calls in UI components.
172
+ * When API helpers exist (companiesApi.create, leadsApi.update),
173
+ * using raw requests bypasses shared logic.
174
+ *
175
+ * @param {string} filePath
176
+ * @param {string} content
177
+ * @returns {Array}
178
+ */
179
+ function checkRawApiRequests(filePath, content) {
180
+ if (isTestFile(filePath)) return [];
181
+
182
+ // Skip API helper files themselves
183
+ const fileBase = filePath.toLowerCase();
184
+ if (fileBase.includes('/api.') || fileBase.includes('/api/') ||
185
+ fileBase.endsWith('api.ts') || fileBase.endsWith('api.js')) {
186
+ return [];
187
+ }
188
+
189
+ const findings = [];
190
+ const lines = content.split('\n');
191
+
192
+ for (let i = 0; i < lines.length; i++) {
193
+ const line = lines[i];
194
+ const trimmed = line.trim();
195
+ if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
196
+
197
+ // apiRequest("METHOD", "/api/...")
198
+ if (/apiRequest\s*\(\s*['"`](?:GET|POST|PUT|PATCH|DELETE)['"`]\s*,\s*['"`]\/api\//.test(line)) {
199
+ findings.push({
200
+ severity: 'warn',
201
+ rule: 'no-raw-api',
202
+ line: i + 1,
203
+ message: 'Raw apiRequest() call — use entity-specific API helper instead',
204
+ fix: 'Use the shared API helper (e.g. companiesApi.create()) for type safety and consistency',
205
+ });
206
+ }
207
+
208
+ // fetch("/api/...")
209
+ if (/fetch\s*\(\s*['"`]\/api\//.test(line)) {
210
+ findings.push({
211
+ severity: 'warn',
212
+ rule: 'no-raw-api',
213
+ line: i + 1,
214
+ message: 'Raw fetch() to /api/ — use entity-specific API helper instead',
215
+ fix: 'Use the shared API helper for centralized error handling and auth',
216
+ });
217
+ }
218
+ }
219
+
220
+ return findings;
221
+ }
222
+
223
+ module.exports = {
224
+ checkSingleWriter,
225
+ checkFakeApiCalls,
226
+ checkStaleReexports,
227
+ checkRawApiRequests,
228
+ };