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.
Files changed (105) 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/first-commit-audit.js +138 -0
  16. package/server/lib/code-gate/first-commit-audit.test.js +203 -0
  17. package/server/lib/code-gate/gate-command.js +114 -0
  18. package/server/lib/code-gate/gate-command.test.js +111 -0
  19. package/server/lib/code-gate/gate-config.js +163 -0
  20. package/server/lib/code-gate/gate-config.test.js +181 -0
  21. package/server/lib/code-gate/gate-engine.js +193 -0
  22. package/server/lib/code-gate/gate-engine.test.js +258 -0
  23. package/server/lib/code-gate/gate-reporter.js +123 -0
  24. package/server/lib/code-gate/gate-reporter.test.js +159 -0
  25. package/server/lib/code-gate/hooks-generator.js +149 -0
  26. package/server/lib/code-gate/hooks-generator.test.js +142 -0
  27. package/server/lib/code-gate/llm-reviewer.js +176 -0
  28. package/server/lib/code-gate/llm-reviewer.test.js +161 -0
  29. package/server/lib/code-gate/multi-model-reviewer.js +172 -0
  30. package/server/lib/code-gate/multi-model-reviewer.test.js +217 -0
  31. package/server/lib/code-gate/push-gate.js +133 -0
  32. package/server/lib/code-gate/push-gate.test.js +190 -0
  33. package/server/lib/code-gate/rules/architecture-rules.js +228 -0
  34. package/server/lib/code-gate/rules/architecture-rules.test.js +155 -0
  35. package/server/lib/code-gate/rules/client-rules.js +120 -0
  36. package/server/lib/code-gate/rules/client-rules.test.js +121 -0
  37. package/server/lib/code-gate/rules/config-rules.js +140 -0
  38. package/server/lib/code-gate/rules/config-rules.test.js +103 -0
  39. package/server/lib/code-gate/rules/database-rules.js +158 -0
  40. package/server/lib/code-gate/rules/database-rules.test.js +119 -0
  41. package/server/lib/code-gate/rules/docker-rules.js +201 -0
  42. package/server/lib/code-gate/rules/docker-rules.test.js +104 -0
  43. package/server/lib/code-gate/rules/quality-rules.js +304 -0
  44. package/server/lib/code-gate/rules/quality-rules.test.js +199 -0
  45. package/server/lib/code-gate/rules/security-rules.js +228 -0
  46. package/server/lib/code-gate/rules/security-rules.test.js +131 -0
  47. package/server/lib/code-gate/rules/structure-rules.js +155 -0
  48. package/server/lib/code-gate/rules/structure-rules.test.js +107 -0
  49. package/server/lib/code-gate/rules/test-rules.js +93 -0
  50. package/server/lib/code-gate/rules/test-rules.test.js +97 -0
  51. package/server/lib/code-gate/typescript-gate.js +128 -0
  52. package/server/lib/code-gate/typescript-gate.test.js +131 -0
  53. package/server/lib/code-generator.test.js +1 -1
  54. package/server/lib/cost-command.test.js +1 -1
  55. package/server/lib/cost-optimizer.test.js +1 -1
  56. package/server/lib/cost-projections.test.js +1 -1
  57. package/server/lib/cost-reports.test.js +1 -1
  58. package/server/lib/cost-tracker.test.js +1 -1
  59. package/server/lib/crypto-patterns.test.js +1 -1
  60. package/server/lib/design-command.test.js +1 -1
  61. package/server/lib/design-parser.test.js +1 -1
  62. package/server/lib/gemini-vision.test.js +1 -1
  63. package/server/lib/infra/infra-generator.js +331 -0
  64. package/server/lib/infra/infra-generator.test.js +146 -0
  65. package/server/lib/input-validator.test.js +1 -1
  66. package/server/lib/litellm-client.test.js +1 -1
  67. package/server/lib/litellm-command.test.js +1 -1
  68. package/server/lib/litellm-config.test.js +1 -1
  69. package/server/lib/llm/adapters/api-adapter.js +95 -0
  70. package/server/lib/llm/adapters/api-adapter.test.js +81 -0
  71. package/server/lib/llm/adapters/codex-adapter.js +85 -0
  72. package/server/lib/llm/adapters/codex-adapter.test.js +54 -0
  73. package/server/lib/llm/adapters/gemini-adapter.js +100 -0
  74. package/server/lib/llm/adapters/gemini-adapter.test.js +54 -0
  75. package/server/lib/llm/index.js +109 -0
  76. package/server/lib/llm/index.test.js +147 -0
  77. package/server/lib/llm/provider-executor.js +168 -0
  78. package/server/lib/llm/provider-executor.test.js +244 -0
  79. package/server/lib/llm/provider-registry.js +104 -0
  80. package/server/lib/llm/provider-registry.test.js +157 -0
  81. package/server/lib/llm/review-service.js +222 -0
  82. package/server/lib/llm/review-service.test.js +220 -0
  83. package/server/lib/model-pricing.test.js +1 -1
  84. package/server/lib/models-command.test.js +1 -1
  85. package/server/lib/optimize-command.test.js +1 -1
  86. package/server/lib/orchestration-integration.test.js +1 -1
  87. package/server/lib/output-encoder.test.js +1 -1
  88. package/server/lib/quality-evaluator.test.js +1 -1
  89. package/server/lib/quality-gate-command.test.js +1 -1
  90. package/server/lib/quality-gate-scorer.test.js +1 -1
  91. package/server/lib/quality-history.test.js +1 -1
  92. package/server/lib/quality-presets.test.js +1 -1
  93. package/server/lib/quality-retry.test.js +1 -1
  94. package/server/lib/quality-thresholds.test.js +1 -1
  95. package/server/lib/secure-auth.test.js +1 -1
  96. package/server/lib/secure-code-command.test.js +1 -1
  97. package/server/lib/secure-errors.test.js +1 -1
  98. package/server/lib/security/auth-security.test.js +4 -3
  99. package/server/lib/shame/shame-registry.js +224 -0
  100. package/server/lib/shame/shame-registry.test.js +202 -0
  101. package/server/lib/standards/cleanup-dry-run.js +254 -0
  102. package/server/lib/standards/cleanup-dry-run.test.js +220 -0
  103. package/server/lib/vision-command.test.js +1 -1
  104. package/server/lib/visual-command.test.js +1 -1
  105. 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
+ });