tlc-claude-code 1.6.4 → 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/dashboard/dist/components/ContainerSecurityPane.d.ts +45 -0
- package/dashboard/dist/components/ContainerSecurityPane.js +44 -0
- package/dashboard/dist/components/ContainerSecurityPane.test.d.ts +1 -0
- package/dashboard/dist/components/ContainerSecurityPane.test.js +153 -0
- package/package.json +1 -1
- package/server/lib/access-control.test.js +1 -1
- package/server/lib/agents-cancel-command.test.js +1 -1
- package/server/lib/agents-get-command.test.js +1 -1
- package/server/lib/agents-list-command.test.js +1 -1
- package/server/lib/agents-logs-command.test.js +1 -1
- package/server/lib/agents-retry-command.test.js +1 -1
- package/server/lib/budget-limits.test.js +2 -2
- package/server/lib/code-gate/bypass-logger.js +129 -0
- package/server/lib/code-gate/bypass-logger.test.js +142 -0
- package/server/lib/code-gate/first-commit-audit.js +138 -0
- package/server/lib/code-gate/first-commit-audit.test.js +203 -0
- package/server/lib/code-gate/gate-command.js +114 -0
- package/server/lib/code-gate/gate-command.test.js +111 -0
- package/server/lib/code-gate/gate-config.js +163 -0
- package/server/lib/code-gate/gate-config.test.js +181 -0
- package/server/lib/code-gate/gate-engine.js +193 -0
- package/server/lib/code-gate/gate-engine.test.js +258 -0
- package/server/lib/code-gate/gate-reporter.js +123 -0
- package/server/lib/code-gate/gate-reporter.test.js +159 -0
- package/server/lib/code-gate/hooks-generator.js +149 -0
- package/server/lib/code-gate/hooks-generator.test.js +142 -0
- package/server/lib/code-gate/llm-reviewer.js +176 -0
- package/server/lib/code-gate/llm-reviewer.test.js +161 -0
- package/server/lib/code-gate/multi-model-reviewer.js +172 -0
- package/server/lib/code-gate/multi-model-reviewer.test.js +217 -0
- package/server/lib/code-gate/push-gate.js +133 -0
- package/server/lib/code-gate/push-gate.test.js +190 -0
- package/server/lib/code-gate/rules/architecture-rules.js +228 -0
- package/server/lib/code-gate/rules/architecture-rules.test.js +155 -0
- package/server/lib/code-gate/rules/client-rules.js +120 -0
- package/server/lib/code-gate/rules/client-rules.test.js +121 -0
- package/server/lib/code-gate/rules/config-rules.js +140 -0
- package/server/lib/code-gate/rules/config-rules.test.js +103 -0
- package/server/lib/code-gate/rules/database-rules.js +158 -0
- package/server/lib/code-gate/rules/database-rules.test.js +119 -0
- package/server/lib/code-gate/rules/docker-rules.js +201 -0
- package/server/lib/code-gate/rules/docker-rules.test.js +104 -0
- package/server/lib/code-gate/rules/quality-rules.js +304 -0
- package/server/lib/code-gate/rules/quality-rules.test.js +199 -0
- package/server/lib/code-gate/rules/security-rules.js +228 -0
- package/server/lib/code-gate/rules/security-rules.test.js +131 -0
- package/server/lib/code-gate/rules/structure-rules.js +155 -0
- package/server/lib/code-gate/rules/structure-rules.test.js +107 -0
- package/server/lib/code-gate/rules/test-rules.js +93 -0
- package/server/lib/code-gate/rules/test-rules.test.js +97 -0
- package/server/lib/code-gate/typescript-gate.js +128 -0
- package/server/lib/code-gate/typescript-gate.test.js +131 -0
- package/server/lib/code-generator.test.js +1 -1
- package/server/lib/cost-command.test.js +1 -1
- package/server/lib/cost-optimizer.test.js +1 -1
- package/server/lib/cost-projections.test.js +1 -1
- package/server/lib/cost-reports.test.js +1 -1
- package/server/lib/cost-tracker.test.js +1 -1
- package/server/lib/crypto-patterns.test.js +1 -1
- package/server/lib/design-command.test.js +1 -1
- package/server/lib/design-parser.test.js +1 -1
- package/server/lib/gemini-vision.test.js +1 -1
- package/server/lib/infra/infra-generator.js +331 -0
- package/server/lib/infra/infra-generator.test.js +146 -0
- package/server/lib/input-validator.test.js +1 -1
- package/server/lib/litellm-client.test.js +1 -1
- package/server/lib/litellm-command.test.js +1 -1
- package/server/lib/litellm-config.test.js +1 -1
- package/server/lib/llm/adapters/api-adapter.js +95 -0
- package/server/lib/llm/adapters/api-adapter.test.js +81 -0
- package/server/lib/llm/adapters/codex-adapter.js +85 -0
- package/server/lib/llm/adapters/codex-adapter.test.js +54 -0
- package/server/lib/llm/adapters/gemini-adapter.js +100 -0
- package/server/lib/llm/adapters/gemini-adapter.test.js +54 -0
- package/server/lib/llm/index.js +109 -0
- package/server/lib/llm/index.test.js +147 -0
- package/server/lib/llm/provider-executor.js +168 -0
- package/server/lib/llm/provider-executor.test.js +244 -0
- package/server/lib/llm/provider-registry.js +104 -0
- package/server/lib/llm/provider-registry.test.js +157 -0
- package/server/lib/llm/review-service.js +222 -0
- package/server/lib/llm/review-service.test.js +220 -0
- package/server/lib/model-pricing.test.js +1 -1
- package/server/lib/models-command.test.js +1 -1
- package/server/lib/optimize-command.test.js +1 -1
- package/server/lib/orchestration-integration.test.js +1 -1
- package/server/lib/output-encoder.test.js +1 -1
- package/server/lib/quality-evaluator.test.js +1 -1
- package/server/lib/quality-gate-command.test.js +1 -1
- package/server/lib/quality-gate-scorer.test.js +1 -1
- package/server/lib/quality-history.test.js +1 -1
- package/server/lib/quality-presets.test.js +1 -1
- package/server/lib/quality-retry.test.js +1 -1
- package/server/lib/quality-thresholds.test.js +1 -1
- package/server/lib/secure-auth.test.js +1 -1
- package/server/lib/secure-code-command.test.js +1 -1
- package/server/lib/secure-errors.test.js +1 -1
- package/server/lib/security/auth-security.test.js +4 -3
- package/server/lib/shame/shame-registry.js +224 -0
- package/server/lib/shame/shame-registry.test.js +202 -0
- package/server/lib/standards/cleanup-dry-run.js +254 -0
- package/server/lib/standards/cleanup-dry-run.test.js +220 -0
- package/server/lib/vision-command.test.js +1 -1
- package/server/lib/visual-command.test.js +1 -1
- package/server/lib/visual-testing.test.js +1 -1
|
@@ -0,0 +1,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
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture Rules Tests
|
|
3
|
+
*
|
|
4
|
+
* Detects single-writer violations, fake API calls,
|
|
5
|
+
* stale re-exports, and raw API bypass patterns.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
checkSingleWriter,
|
|
11
|
+
checkFakeApiCalls,
|
|
12
|
+
checkStaleReexports,
|
|
13
|
+
checkRawApiRequests,
|
|
14
|
+
} = require('./architecture-rules.js');
|
|
15
|
+
|
|
16
|
+
describe('Architecture Rules', () => {
|
|
17
|
+
describe('checkSingleWriter', () => {
|
|
18
|
+
it('detects db.insert(users) outside users.service', () => {
|
|
19
|
+
const findings = checkSingleWriter(
|
|
20
|
+
'src/leads/leads.service.ts',
|
|
21
|
+
'const result = await db.insert(users).values(data);'
|
|
22
|
+
);
|
|
23
|
+
expect(findings).toHaveLength(1);
|
|
24
|
+
expect(findings[0].severity).toBe('block');
|
|
25
|
+
expect(findings[0].rule).toBe('single-writer');
|
|
26
|
+
expect(findings[0].message).toContain('users');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('passes when inside correct service file', () => {
|
|
30
|
+
const findings = checkSingleWriter(
|
|
31
|
+
'src/users/users.service.ts',
|
|
32
|
+
'const result = await db.insert(users).values(data);'
|
|
33
|
+
);
|
|
34
|
+
expect(findings).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('detects db.update(companies) outside company.service', () => {
|
|
38
|
+
const findings = checkSingleWriter(
|
|
39
|
+
'src/api/controller.ts',
|
|
40
|
+
'await db.update(companies).set({ status: "active" });'
|
|
41
|
+
);
|
|
42
|
+
expect(findings).toHaveLength(1);
|
|
43
|
+
expect(findings[0].message).toContain('companies');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles singular service name for plural table', () => {
|
|
47
|
+
// company.service.ts should be allowed to write to companies table
|
|
48
|
+
const findings = checkSingleWriter(
|
|
49
|
+
'src/company/company.service.ts',
|
|
50
|
+
'await db.insert(companies).values(data);'
|
|
51
|
+
);
|
|
52
|
+
expect(findings).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('skips test files', () => {
|
|
56
|
+
const findings = checkSingleWriter(
|
|
57
|
+
'src/api/controller.test.ts',
|
|
58
|
+
'await db.insert(users).values(data);'
|
|
59
|
+
);
|
|
60
|
+
expect(findings).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('checkFakeApiCalls', () => {
|
|
65
|
+
it('detects setTimeout + resolve mock pattern', () => {
|
|
66
|
+
const code = `
|
|
67
|
+
function getUsers() {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
setTimeout(() => resolve([{ id: 1, name: 'Test' }]), 500);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
const findings = checkFakeApiCalls('src/api/users.ts', code);
|
|
74
|
+
expect(findings).toHaveLength(1);
|
|
75
|
+
expect(findings[0].severity).toBe('block');
|
|
76
|
+
expect(findings[0].rule).toBe('no-fake-api');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('allows real setTimeout with function callback', () => {
|
|
80
|
+
const code = `
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
refreshDashboard();
|
|
83
|
+
}, 1000);
|
|
84
|
+
`;
|
|
85
|
+
const findings = checkFakeApiCalls('src/ui/dashboard.ts', code);
|
|
86
|
+
expect(findings).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('skips test files', () => {
|
|
90
|
+
const code = 'setTimeout(() => resolve(mockData), 500);';
|
|
91
|
+
const findings = checkFakeApiCalls('src/api/users.test.ts', code);
|
|
92
|
+
expect(findings).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('checkStaleReexports', () => {
|
|
97
|
+
it('detects file with only module.exports = require(...)', () => {
|
|
98
|
+
const code = "module.exports = require('./new-location');";
|
|
99
|
+
const findings = checkStaleReexports('src/old-module.js', code);
|
|
100
|
+
expect(findings).toHaveLength(1);
|
|
101
|
+
expect(findings[0].severity).toBe('warn');
|
|
102
|
+
expect(findings[0].rule).toBe('stale-reexport');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('passes file with real logic alongside export', () => {
|
|
106
|
+
const code = `
|
|
107
|
+
const helper = require('./utils');
|
|
108
|
+
function doWork() { return helper.process(); }
|
|
109
|
+
module.exports = { doWork };
|
|
110
|
+
`;
|
|
111
|
+
const findings = checkStaleReexports('src/module.js', code);
|
|
112
|
+
expect(findings).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('detects export default re-export', () => {
|
|
116
|
+
const code = "export { default } from './new-location';";
|
|
117
|
+
const findings = checkStaleReexports('src/old-module.ts', code);
|
|
118
|
+
expect(findings).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('checkRawApiRequests', () => {
|
|
123
|
+
it('detects apiRequest("POST", "/api/...")', () => {
|
|
124
|
+
const code = 'const result = await apiRequest("POST", "/api/companies", data);';
|
|
125
|
+
const findings = checkRawApiRequests('src/components/form.tsx', code);
|
|
126
|
+
expect(findings).toHaveLength(1);
|
|
127
|
+
expect(findings[0].severity).toBe('warn');
|
|
128
|
+
expect(findings[0].rule).toBe('no-raw-api');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('detects raw fetch("/api/...")', () => {
|
|
132
|
+
const code = 'const res = await fetch("/api/leads", { method: "POST" });';
|
|
133
|
+
const findings = checkRawApiRequests('src/components/leads.tsx', code);
|
|
134
|
+
expect(findings).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('allows non-API fetch calls', () => {
|
|
138
|
+
const code = 'const res = await fetch("https://cdn.example.com/data.json");';
|
|
139
|
+
const findings = checkRawApiRequests('src/utils/loader.ts', code);
|
|
140
|
+
expect(findings).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('allows fetch in API helper files', () => {
|
|
144
|
+
const code = 'const res = await fetch("/api/companies");';
|
|
145
|
+
const findings = checkRawApiRequests('src/lib/api.ts', code);
|
|
146
|
+
expect(findings).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('skips test files', () => {
|
|
150
|
+
const code = 'await apiRequest("POST", "/api/test", data);';
|
|
151
|
+
const findings = checkRawApiRequests('src/components/form.test.tsx', code);
|
|
152
|
+
expect(findings).toHaveLength(0);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-Side Pattern Rules
|
|
3
|
+
*
|
|
4
|
+
* Detects Zustand stores without persistence middleware
|
|
5
|
+
* and Zod schemas using z.date() instead of z.coerce.date().
|
|
6
|
+
*
|
|
7
|
+
* Derived from Bug #13 (state lost on refresh) and Bug #12 (date coercion).
|
|
8
|
+
*
|
|
9
|
+
* @module code-gate/rules/client-rules
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} filePath
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
function isTestFile(filePath) {
|
|
17
|
+
return /\.(test|spec)\.[jt]sx?$/.test(filePath) || filePath.includes('__tests__');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Store names that are intentionally ephemeral (UI state, not data) */
|
|
21
|
+
const EPHEMERAL_PATTERNS = [
|
|
22
|
+
'ui-store', 'ui.store', 'uiStore',
|
|
23
|
+
'modal-store', 'modalStore',
|
|
24
|
+
'toast-store', 'toastStore',
|
|
25
|
+
'theme-store', 'themeStore',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect Zustand create() without persist middleware.
|
|
30
|
+
* Stores that hold user data should use persist() to survive page refreshes.
|
|
31
|
+
* UI-only stores (modals, toasts) are exempt.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} filePath
|
|
34
|
+
* @param {string} content
|
|
35
|
+
* @returns {Array<{severity: string, rule: string, line: number, message: string, fix: string}>}
|
|
36
|
+
*/
|
|
37
|
+
function checkZustandPersistence(filePath, content) {
|
|
38
|
+
if (isTestFile(filePath)) return [];
|
|
39
|
+
|
|
40
|
+
// Only check store files
|
|
41
|
+
if (!filePath.includes('store')) return [];
|
|
42
|
+
|
|
43
|
+
// Skip ephemeral stores (UI state)
|
|
44
|
+
const fileBase = filePath.toLowerCase();
|
|
45
|
+
if (EPHEMERAL_PATTERNS.some(p => fileBase.includes(p))) return [];
|
|
46
|
+
|
|
47
|
+
// Check if file imports/uses zustand create
|
|
48
|
+
const hasZustandCreate = /\bcreate\s*[<(]/.test(content) &&
|
|
49
|
+
(content.includes("from 'zustand'") || content.includes('from "zustand"') ||
|
|
50
|
+
content.includes("require('zustand')") || content.includes('require("zustand")'));
|
|
51
|
+
|
|
52
|
+
if (!hasZustandCreate) return [];
|
|
53
|
+
|
|
54
|
+
// Check if persist is used
|
|
55
|
+
const hasPersist = content.includes('persist(') || content.includes('persist<');
|
|
56
|
+
|
|
57
|
+
if (!hasPersist) {
|
|
58
|
+
const findings = [];
|
|
59
|
+
const lines = content.split('\n');
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
if (/\bcreate\s*[<(]/.test(lines[i])) {
|
|
62
|
+
findings.push({
|
|
63
|
+
severity: 'warn',
|
|
64
|
+
rule: 'zustand-needs-persist',
|
|
65
|
+
line: i + 1,
|
|
66
|
+
message: 'Zustand store without persist middleware — state lost on page refresh',
|
|
67
|
+
fix: "Wrap with persist(): create(persist((set) => ({...}), { name: 'store-name' }))",
|
|
68
|
+
});
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detect z.date() in schema files that should use z.coerce.date().
|
|
80
|
+
* When API clients send ISO strings, z.date() rejects them.
|
|
81
|
+
* z.coerce.date() handles both Date objects and ISO strings.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} filePath
|
|
84
|
+
* @param {string} content
|
|
85
|
+
* @returns {Array}
|
|
86
|
+
*/
|
|
87
|
+
function checkZodDateCoercion(filePath, content) {
|
|
88
|
+
if (isTestFile(filePath)) return [];
|
|
89
|
+
|
|
90
|
+
// Only check schema-related files
|
|
91
|
+
const fileBase = filePath.toLowerCase();
|
|
92
|
+
if (!fileBase.includes('schema') && !fileBase.includes('schemas')) return [];
|
|
93
|
+
|
|
94
|
+
const findings = [];
|
|
95
|
+
const lines = content.split('\n');
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < lines.length; i++) {
|
|
98
|
+
const line = lines[i];
|
|
99
|
+
const trimmed = line.trim();
|
|
100
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
|
101
|
+
|
|
102
|
+
// z.date() without coerce
|
|
103
|
+
if (/z\.date\s*\(/.test(line) && !line.includes('z.coerce.date')) {
|
|
104
|
+
findings.push({
|
|
105
|
+
severity: 'warn',
|
|
106
|
+
rule: 'zod-use-coerce-date',
|
|
107
|
+
line: i + 1,
|
|
108
|
+
message: 'z.date() rejects ISO strings from API clients — use z.coerce.date()',
|
|
109
|
+
fix: 'Replace z.date() with z.coerce.date() to accept both Date objects and ISO strings',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return findings;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
checkZustandPersistence,
|
|
119
|
+
checkZodDateCoercion,
|
|
120
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-Side Pattern Rules Tests
|
|
3
|
+
*
|
|
4
|
+
* Detects Zustand stores without persistence and
|
|
5
|
+
* Zod schemas with z.date() instead of z.coerce.date().
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
checkZustandPersistence,
|
|
11
|
+
checkZodDateCoercion,
|
|
12
|
+
} = require('./client-rules.js');
|
|
13
|
+
|
|
14
|
+
describe('Client Rules', () => {
|
|
15
|
+
describe('checkZustandPersistence', () => {
|
|
16
|
+
it('detects create() without persist', () => {
|
|
17
|
+
const code = `
|
|
18
|
+
import { create } from 'zustand';
|
|
19
|
+
const useFormStore = create((set) => ({
|
|
20
|
+
data: null,
|
|
21
|
+
setData: (data) => set({ data }),
|
|
22
|
+
}));
|
|
23
|
+
`;
|
|
24
|
+
const findings = checkZustandPersistence('src/stores/form-store.ts', code);
|
|
25
|
+
expect(findings).toHaveLength(1);
|
|
26
|
+
expect(findings[0].severity).toBe('warn');
|
|
27
|
+
expect(findings[0].rule).toBe('zustand-needs-persist');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('passes create(persist(...))', () => {
|
|
31
|
+
const code = `
|
|
32
|
+
import { create } from 'zustand';
|
|
33
|
+
import { persist } from 'zustand/middleware';
|
|
34
|
+
const useFormStore = create(persist((set) => ({
|
|
35
|
+
data: null,
|
|
36
|
+
}), { name: 'form-store' }));
|
|
37
|
+
`;
|
|
38
|
+
const findings = checkZustandPersistence('src/stores/form-store.ts', code);
|
|
39
|
+
expect(findings).toHaveLength(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('allows stores in ephemeral files', () => {
|
|
43
|
+
const code = `
|
|
44
|
+
import { create } from 'zustand';
|
|
45
|
+
const useUIStore = create((set) => ({ open: false }));
|
|
46
|
+
`;
|
|
47
|
+
const findings = checkZustandPersistence('src/stores/ui-store.ts', code);
|
|
48
|
+
expect(findings).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('skips test files', () => {
|
|
52
|
+
const code = `
|
|
53
|
+
import { create } from 'zustand';
|
|
54
|
+
const useTestStore = create((set) => ({ x: 1 }));
|
|
55
|
+
`;
|
|
56
|
+
const findings = checkZustandPersistence('src/stores/form.test.ts', code);
|
|
57
|
+
expect(findings).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles create<Type>() generic syntax', () => {
|
|
61
|
+
const code = `
|
|
62
|
+
import { create } from 'zustand';
|
|
63
|
+
interface FormState { data: string | null; }
|
|
64
|
+
const useFormStore = create<FormState>((set) => ({
|
|
65
|
+
data: null,
|
|
66
|
+
}));
|
|
67
|
+
`;
|
|
68
|
+
const findings = checkZustandPersistence('src/stores/form-store.ts', code);
|
|
69
|
+
expect(findings).toHaveLength(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('checkZodDateCoercion', () => {
|
|
74
|
+
it('detects z.date() in schema files', () => {
|
|
75
|
+
const code = `
|
|
76
|
+
const insertLeadSchema = z.object({
|
|
77
|
+
name: z.string(),
|
|
78
|
+
createdAt: z.date(),
|
|
79
|
+
});
|
|
80
|
+
`;
|
|
81
|
+
const findings = checkZodDateCoercion('src/db/schema/leads.ts', code);
|
|
82
|
+
expect(findings).toHaveLength(1);
|
|
83
|
+
expect(findings[0].severity).toBe('warn');
|
|
84
|
+
expect(findings[0].rule).toBe('zod-use-coerce-date');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('passes z.coerce.date()', () => {
|
|
88
|
+
const code = `
|
|
89
|
+
const insertLeadSchema = z.object({
|
|
90
|
+
name: z.string(),
|
|
91
|
+
createdAt: z.coerce.date(),
|
|
92
|
+
});
|
|
93
|
+
`;
|
|
94
|
+
const findings = checkZodDateCoercion('src/db/schema/leads.ts', code);
|
|
95
|
+
expect(findings).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('allows z.date() in non-schema files', () => {
|
|
99
|
+
const code = 'const validator = z.date();';
|
|
100
|
+
const findings = checkZodDateCoercion('src/utils/helpers.ts', code);
|
|
101
|
+
expect(findings).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('skips test files', () => {
|
|
105
|
+
const code = 'const schema = z.object({ at: z.date() });';
|
|
106
|
+
const findings = checkZodDateCoercion('src/db/schema/leads.test.ts', code);
|
|
107
|
+
expect(findings).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('detects in insertSchema definitions', () => {
|
|
111
|
+
const code = `
|
|
112
|
+
export const insertQuoteSchema = z.object({
|
|
113
|
+
validUntil: z.date(),
|
|
114
|
+
amount: z.number(),
|
|
115
|
+
});
|
|
116
|
+
`;
|
|
117
|
+
const findings = checkZodDateCoercion('server/schemas/quotes.ts', code);
|
|
118
|
+
expect(findings).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|