@soulbatical/tetra-dev-toolkit 1.8.0 → 1.8.2
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/lib/checks/codeQuality/file-size.js +48 -11
- package/lib/checks/codeQuality/naming-conventions.js +2 -1
- package/lib/checks/codeQuality/route-separation.js +233 -0
- package/lib/checks/health/naming-conventions.js +92 -3
- package/lib/config.js +19 -2
- package/lib/runner.js +3 -1
- package/package.json +1 -1
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
* - Indicate missing separation of concerns
|
|
7
7
|
* - Grow silently until they become unmovable
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Supports differentiated limits per file type via config.codeQuality.fileSizeLimits:
|
|
10
|
+
* route: 200 (routes = wiring, no logic)
|
|
11
|
+
* controller: 400 (request/response + error handling)
|
|
12
|
+
* service: 600 (business logic, allowed to be complex)
|
|
13
|
+
* default: 400 (everything else)
|
|
14
|
+
*
|
|
15
|
+
* Falls back to config.codeQuality.maxFileLines (legacy, flat limit for all files).
|
|
10
16
|
* Scans backend + frontend source directories.
|
|
11
17
|
*/
|
|
12
18
|
|
|
@@ -18,18 +24,47 @@ export const meta = {
|
|
|
18
24
|
name: 'File Size / God File Detection',
|
|
19
25
|
category: 'codeQuality',
|
|
20
26
|
severity: 'high',
|
|
21
|
-
description: 'Detects source files exceeding
|
|
27
|
+
description: 'Detects source files exceeding size limits — god files that need splitting'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Determine file type and applicable line limit based on path/name patterns.
|
|
32
|
+
*/
|
|
33
|
+
function getFileTypeAndLimit(filePath, limits) {
|
|
34
|
+
// Route files: in routes/ directory or named *Routes.ts
|
|
35
|
+
if (/\/routes\//.test(filePath) || /Routes\.\w+$/.test(filePath)) {
|
|
36
|
+
return { type: 'route', limit: limits.route }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Controller files: named *Controller.ts
|
|
40
|
+
if (/Controller\.\w+$/.test(filePath)) {
|
|
41
|
+
return { type: 'controller', limit: limits.controller }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Service files: in services/ directory or named *Service.ts
|
|
45
|
+
if (/\/services\//.test(filePath) || /Service\.\w+$/.test(filePath)) {
|
|
46
|
+
return { type: 'service', limit: limits.service }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { type: 'default', limit: limits.default }
|
|
22
50
|
}
|
|
23
51
|
|
|
24
52
|
export async function run(config, projectRoot) {
|
|
25
|
-
|
|
53
|
+
// Support new fileSizeLimits config, with maxFileLines as legacy fallback
|
|
54
|
+
const legacyMax = config.codeQuality?.maxFileLines || 500
|
|
55
|
+
const limits = {
|
|
56
|
+
route: config.codeQuality?.fileSizeLimits?.route ?? 200,
|
|
57
|
+
controller: config.codeQuality?.fileSizeLimits?.controller ?? 400,
|
|
58
|
+
service: config.codeQuality?.fileSizeLimits?.service ?? 600,
|
|
59
|
+
default: config.codeQuality?.fileSizeLimits?.default ?? legacyMax,
|
|
60
|
+
}
|
|
26
61
|
|
|
27
62
|
const results = {
|
|
28
63
|
passed: true,
|
|
29
64
|
findings: [],
|
|
30
65
|
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
31
66
|
info: {
|
|
32
|
-
|
|
67
|
+
fileSizeLimits: limits,
|
|
33
68
|
filesScanned: 0,
|
|
34
69
|
violations: 0,
|
|
35
70
|
largest: null
|
|
@@ -93,6 +128,7 @@ export async function run(config, projectRoot) {
|
|
|
93
128
|
results.info.filesScanned++
|
|
94
129
|
|
|
95
130
|
const lineCount = content.split('\n').length
|
|
131
|
+
const { type, limit } = getFileTypeAndLimit(file, limits)
|
|
96
132
|
|
|
97
133
|
// Track largest file
|
|
98
134
|
if (lineCount > largestLines) {
|
|
@@ -100,17 +136,17 @@ export async function run(config, projectRoot) {
|
|
|
100
136
|
largestFile = file
|
|
101
137
|
}
|
|
102
138
|
|
|
103
|
-
if (lineCount <=
|
|
139
|
+
if (lineCount <= limit) continue
|
|
104
140
|
|
|
105
141
|
// Determine severity based on how far over the limit
|
|
106
|
-
const ratio = lineCount /
|
|
142
|
+
const ratio = lineCount / limit
|
|
107
143
|
let severity
|
|
108
144
|
if (ratio >= 5) {
|
|
109
|
-
severity = 'critical' // 5x over limit
|
|
145
|
+
severity = 'critical' // 5x over limit
|
|
110
146
|
} else if (ratio >= 3) {
|
|
111
|
-
severity = 'high' // 3x over limit
|
|
147
|
+
severity = 'high' // 3x over limit
|
|
112
148
|
} else if (ratio >= 2) {
|
|
113
|
-
severity = 'medium' // 2x over limit
|
|
149
|
+
severity = 'medium' // 2x over limit
|
|
114
150
|
} else {
|
|
115
151
|
severity = 'low' // Just over limit
|
|
116
152
|
}
|
|
@@ -119,9 +155,10 @@ export async function run(config, projectRoot) {
|
|
|
119
155
|
file,
|
|
120
156
|
line: 1,
|
|
121
157
|
type: 'GOD_FILE',
|
|
158
|
+
fileType: type,
|
|
122
159
|
severity,
|
|
123
|
-
message: `${file} has ${lineCount} lines (max: ${
|
|
124
|
-
fix: `Break this file into focused modules of <${
|
|
160
|
+
message: `${file} has ${lineCount} lines (${type} max: ${limit}) — split into smaller modules`,
|
|
161
|
+
fix: `Break this ${type} file into focused modules of <${limit} lines each`
|
|
125
162
|
})
|
|
126
163
|
|
|
127
164
|
results.summary[severity]++
|
|
@@ -28,7 +28,8 @@ export async function run(config, projectRoot) {
|
|
|
28
28
|
details: {}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const
|
|
31
|
+
const ignoreMigrations = config.codeQuality?.namingConventions?.ignoreMigrations || []
|
|
32
|
+
const health = await healthCheck(projectRoot, { ignoreMigrations })
|
|
32
33
|
result.details = health.details
|
|
33
34
|
|
|
34
35
|
const dbViolations = health.details.database?.violations || []
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check: Route Separation (ZERO LOGIC in routes)
|
|
3
|
+
*
|
|
4
|
+
* Detects business logic leaking into route files. Based on Express.js best practices
|
|
5
|
+
* and the "thin routes, fat services" principle.
|
|
6
|
+
*
|
|
7
|
+
* Routes should only do:
|
|
8
|
+
* - Wire HTTP methods to controller/handler functions
|
|
9
|
+
* - Apply middleware (auth, validation)
|
|
10
|
+
* - Define path parameters
|
|
11
|
+
*
|
|
12
|
+
* Routes should NOT contain:
|
|
13
|
+
* - Direct database queries (Supabase .from().select() etc.)
|
|
14
|
+
* - RPC calls (.rpc())
|
|
15
|
+
* - Helper function declarations with logic
|
|
16
|
+
* - Data transformation chains (.map + .filter/.reduce)
|
|
17
|
+
*
|
|
18
|
+
* Config:
|
|
19
|
+
* routeSeparation.enabled: true (default)
|
|
20
|
+
* routeSeparation.allowDbInRoutes: false (default, set true to not block on DB findings)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { glob } from 'glob'
|
|
24
|
+
import { readFileSync } from 'fs'
|
|
25
|
+
|
|
26
|
+
export const meta = {
|
|
27
|
+
id: 'route-separation',
|
|
28
|
+
name: 'Route Separation (Zero Logic)',
|
|
29
|
+
category: 'codeQuality',
|
|
30
|
+
severity: 'high',
|
|
31
|
+
description: 'Detects business logic in route files — routes should delegate to controllers/services'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function run(config, projectRoot) {
|
|
35
|
+
const routeConfig = config.codeQuality?.routeSeparation || {}
|
|
36
|
+
|
|
37
|
+
if (routeConfig.enabled === false) {
|
|
38
|
+
return {
|
|
39
|
+
passed: true,
|
|
40
|
+
skipped: true,
|
|
41
|
+
skipReason: 'Route separation check disabled in config',
|
|
42
|
+
findings: [],
|
|
43
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const allowDbInRoutes = routeConfig.allowDbInRoutes || false
|
|
48
|
+
|
|
49
|
+
const results = {
|
|
50
|
+
passed: true,
|
|
51
|
+
findings: [],
|
|
52
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
53
|
+
info: {
|
|
54
|
+
filesScanned: 0,
|
|
55
|
+
violations: 0,
|
|
56
|
+
allowDbInRoutes
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Find route files (excl. generated)
|
|
61
|
+
const patterns = [
|
|
62
|
+
'backend/src/**/routes/**/*.ts',
|
|
63
|
+
'src/**/routes/**/*.ts',
|
|
64
|
+
'backend/src/**/*Routes.ts',
|
|
65
|
+
'src/**/*Routes.ts',
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
let files = []
|
|
69
|
+
for (const pattern of patterns) {
|
|
70
|
+
const found = await glob(pattern, {
|
|
71
|
+
cwd: projectRoot,
|
|
72
|
+
ignore: [
|
|
73
|
+
...(config.ignore || []),
|
|
74
|
+
'node_modules/**',
|
|
75
|
+
'**/node_modules/**',
|
|
76
|
+
'**/generated/**',
|
|
77
|
+
'**/*.test.*',
|
|
78
|
+
'**/*.spec.*',
|
|
79
|
+
'**/*.d.ts',
|
|
80
|
+
]
|
|
81
|
+
})
|
|
82
|
+
files.push(...found)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
files = [...new Set(files)]
|
|
86
|
+
|
|
87
|
+
if (files.length === 0) {
|
|
88
|
+
results.skipped = true
|
|
89
|
+
results.skipReason = 'No route files found'
|
|
90
|
+
return results
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const filePath = `${projectRoot}/${file}`
|
|
95
|
+
let content
|
|
96
|
+
try {
|
|
97
|
+
content = readFileSync(filePath, 'utf-8')
|
|
98
|
+
} catch {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
results.info.filesScanned++
|
|
103
|
+
const lines = content.split('\n')
|
|
104
|
+
|
|
105
|
+
// Signal 1: DB queries in routes — .from( followed by .select/.insert/.update/.delete
|
|
106
|
+
const dbQueryPattern = /\.from\s*\(/
|
|
107
|
+
const dbOpPattern = /\.(select|insert|update|delete|upsert)\s*\(/
|
|
108
|
+
let hasDbQuery = false
|
|
109
|
+
for (let i = 0; i < lines.length; i++) {
|
|
110
|
+
if (dbQueryPattern.test(lines[i]) && (
|
|
111
|
+
dbOpPattern.test(lines[i]) ||
|
|
112
|
+
(i + 1 < lines.length && dbOpPattern.test(lines[i + 1])) ||
|
|
113
|
+
(i + 2 < lines.length && dbOpPattern.test(lines[i + 2]))
|
|
114
|
+
)) {
|
|
115
|
+
hasDbQuery = true
|
|
116
|
+
results.findings.push({
|
|
117
|
+
file,
|
|
118
|
+
line: i + 1,
|
|
119
|
+
type: 'DB_QUERY_IN_ROUTE',
|
|
120
|
+
severity: 'high',
|
|
121
|
+
message: `DB query in route file (line ${i + 1}) — move to service layer`,
|
|
122
|
+
fix: 'Extract database queries into a service class'
|
|
123
|
+
})
|
|
124
|
+
results.summary.high++
|
|
125
|
+
results.summary.total++
|
|
126
|
+
results.info.violations++
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Signal 2: Supabase/DB client imports/usage in routes
|
|
131
|
+
const dbClientPatterns = [
|
|
132
|
+
/systemDB\s*\(/,
|
|
133
|
+
/adminDB\s*\(/,
|
|
134
|
+
/userDB\s*\(/,
|
|
135
|
+
/createClient\s*\(/,
|
|
136
|
+
/supabaseAdmin/,
|
|
137
|
+
]
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
// Skip import lines — we check usage, not imports
|
|
140
|
+
if (/^\s*(import|from)\b/.test(lines[i])) continue
|
|
141
|
+
for (const pattern of dbClientPatterns) {
|
|
142
|
+
if (pattern.test(lines[i])) {
|
|
143
|
+
results.findings.push({
|
|
144
|
+
file,
|
|
145
|
+
line: i + 1,
|
|
146
|
+
type: 'DB_CLIENT_IN_ROUTE',
|
|
147
|
+
severity: 'high',
|
|
148
|
+
message: `DB client usage in route file (line ${i + 1}) — routes should not create DB connections`,
|
|
149
|
+
fix: 'Pass DB client through controller/service, not in route definitions'
|
|
150
|
+
})
|
|
151
|
+
results.summary.high++
|
|
152
|
+
results.summary.total++
|
|
153
|
+
results.info.violations++
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Signal 3: RPC calls in routes
|
|
160
|
+
for (let i = 0; i < lines.length; i++) {
|
|
161
|
+
if (/^\s*(import|from)\b/.test(lines[i])) continue
|
|
162
|
+
if (/\.rpc\s*\(/.test(lines[i])) {
|
|
163
|
+
results.findings.push({
|
|
164
|
+
file,
|
|
165
|
+
line: i + 1,
|
|
166
|
+
type: 'RPC_IN_ROUTE',
|
|
167
|
+
severity: 'high',
|
|
168
|
+
message: `RPC call in route file (line ${i + 1}) — move to service layer`,
|
|
169
|
+
fix: 'Extract RPC calls into a service class'
|
|
170
|
+
})
|
|
171
|
+
results.summary.high++
|
|
172
|
+
results.summary.total++
|
|
173
|
+
results.info.violations++
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Signal 4: Helper function declarations (not router method)
|
|
178
|
+
for (let i = 0; i < lines.length; i++) {
|
|
179
|
+
const line = lines[i]
|
|
180
|
+
if (/^\s*(export\s+)?(async\s+)?function\s+\w+/.test(line) && !/router\./.test(line)) {
|
|
181
|
+
// Skip if it's a middleware function (common pattern)
|
|
182
|
+
if (/middleware/i.test(line)) continue
|
|
183
|
+
results.findings.push({
|
|
184
|
+
file,
|
|
185
|
+
line: i + 1,
|
|
186
|
+
type: 'HELPER_IN_ROUTE',
|
|
187
|
+
severity: 'medium',
|
|
188
|
+
message: `Helper function in route file (line ${i + 1}) — move to service or util`,
|
|
189
|
+
fix: 'Extract helper functions to a service or utility module'
|
|
190
|
+
})
|
|
191
|
+
results.summary.medium++
|
|
192
|
+
results.summary.total++
|
|
193
|
+
results.info.violations++
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Signal 5: Data transformation chains (.map + .filter or .reduce)
|
|
198
|
+
let hasMap = false
|
|
199
|
+
let hasFilterOrReduce = false
|
|
200
|
+
for (let i = 0; i < lines.length; i++) {
|
|
201
|
+
if (/^\s*(import|from)\b/.test(lines[i])) continue
|
|
202
|
+
if (/\.map\s*\(/.test(lines[i])) hasMap = true
|
|
203
|
+
if (/\.(filter|reduce)\s*\(/.test(lines[i])) hasFilterOrReduce = true
|
|
204
|
+
}
|
|
205
|
+
if (hasMap && hasFilterOrReduce) {
|
|
206
|
+
results.findings.push({
|
|
207
|
+
file,
|
|
208
|
+
line: 1,
|
|
209
|
+
type: 'TRANSFORMATION_IN_ROUTE',
|
|
210
|
+
severity: 'low',
|
|
211
|
+
message: `Data transformation chain in route file — move to service layer`,
|
|
212
|
+
fix: 'Extract data transformations into a service method'
|
|
213
|
+
})
|
|
214
|
+
results.summary.low++
|
|
215
|
+
results.summary.total++
|
|
216
|
+
results.info.violations++
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Fail on high findings, unless allowDbInRoutes is set
|
|
221
|
+
if (allowDbInRoutes) {
|
|
222
|
+
// Only fail on critical (none defined currently, but future-proof)
|
|
223
|
+
results.passed = results.findings.filter(f =>
|
|
224
|
+
f.severity === 'critical'
|
|
225
|
+
).length === 0
|
|
226
|
+
} else {
|
|
227
|
+
results.passed = results.findings.filter(f =>
|
|
228
|
+
f.severity === 'critical' || f.severity === 'high'
|
|
229
|
+
).length === 0
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return results
|
|
233
|
+
}
|
|
@@ -17,7 +17,7 @@ const BOOL_PREFIX = /^(is_|has_|can_|should_|allow_|enable_|use_|include_)/
|
|
|
17
17
|
const JSON_SUFFIX = /_(data|json|meta|config|settings|options|payload|context|params|attributes|properties|extra|raw)$/
|
|
18
18
|
const JSON_STANDALONE = /^(metadata|settings|config|configuration|options|preferences|tags|labels|permissions|context|payload|attributes|properties|extras|params|parameters)$/
|
|
19
19
|
|
|
20
|
-
function scanDatabaseNaming(projectPath) {
|
|
20
|
+
function scanDatabaseNaming(projectPath, options = {}) {
|
|
21
21
|
const violations = []
|
|
22
22
|
let totalFields = 0
|
|
23
23
|
let compliant = 0
|
|
@@ -27,11 +27,14 @@ function scanDatabaseNaming(projectPath) {
|
|
|
27
27
|
join(projectPath, 'backend', 'supabase', 'migrations')
|
|
28
28
|
]
|
|
29
29
|
|
|
30
|
+
const ignoreMigrations = options.ignoreMigrations || []
|
|
31
|
+
|
|
30
32
|
const sqlFiles = []
|
|
31
33
|
for (const dir of migrationDirs) {
|
|
32
34
|
if (!existsSync(dir)) continue
|
|
33
35
|
try {
|
|
34
36
|
for (const f of readdirSync(dir).filter(f => f.endsWith('.sql'))) {
|
|
37
|
+
if (ignoreMigrations.includes(f)) continue
|
|
35
38
|
sqlFiles.push(join(dir, f))
|
|
36
39
|
}
|
|
37
40
|
} catch { /* ignore */ }
|
|
@@ -39,6 +42,39 @@ function scanDatabaseNaming(projectPath) {
|
|
|
39
42
|
|
|
40
43
|
if (sqlFiles.length === 0) return { totalFields: 0, compliant: 0, violations: [], compliancePercent: 100 }
|
|
41
44
|
|
|
45
|
+
// Sort migration files chronologically so we can track renames
|
|
46
|
+
sqlFiles.sort()
|
|
47
|
+
|
|
48
|
+
// First pass: collect all renames and late-added columns from ALL migration files.
|
|
49
|
+
// These are used to suppress violations from earlier migrations.
|
|
50
|
+
const renamedColumns = new Map() // "table.old_col" → "new_col"
|
|
51
|
+
const renamedIndexes = new Map() // "old_index" → "new_index"
|
|
52
|
+
const lateAddedColumns = new Map() // "table.col" → true (columns added via ALTER TABLE ADD COLUMN)
|
|
53
|
+
|
|
54
|
+
for (const filePath of sqlFiles) {
|
|
55
|
+
let content
|
|
56
|
+
try { content = readFileSync(filePath, 'utf-8') } catch { continue }
|
|
57
|
+
|
|
58
|
+
// Track RENAME COLUMN: ALTER TABLE x RENAME COLUMN old TO new
|
|
59
|
+
const renameColRegex = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?\s+RENAME\s+COLUMN\s+["']?(\w+)["']?\s+TO\s+["']?(\w+)["']?/gi
|
|
60
|
+
let m
|
|
61
|
+
while ((m = renameColRegex.exec(content)) !== null) {
|
|
62
|
+
renamedColumns.set(`${m[1]}.${m[2]}`, m[3])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Track RENAME INDEX: ALTER INDEX old RENAME TO new
|
|
66
|
+
const renameIdxRegex = /ALTER\s+INDEX\s+(?:IF\s+EXISTS\s+)?["']?(\w+)["']?\s+RENAME\s+TO\s+["']?(\w+)["']?/gi
|
|
67
|
+
while ((m = renameIdxRegex.exec(content)) !== null) {
|
|
68
|
+
renamedIndexes.set(m[1], m[2])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Track ADD COLUMN (timestamps added later fix "missing timestamps" violations)
|
|
72
|
+
const addColRegex = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?\s+ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?\s+\w+/gi
|
|
73
|
+
while ((m = addColRegex.exec(content)) !== null) {
|
|
74
|
+
lateAddedColumns.set(`${m[1]}.${m[2]}`, true)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
42
78
|
const globalColumns = {}
|
|
43
79
|
|
|
44
80
|
const checkColumn = (tableName, colName, colType, body, fileName) => {
|
|
@@ -137,6 +173,59 @@ function scanDatabaseNaming(projectPath) {
|
|
|
137
173
|
}
|
|
138
174
|
}
|
|
139
175
|
|
|
176
|
+
// ── Filter violations resolved by later migrations ──
|
|
177
|
+
|
|
178
|
+
// Helper: check if a column was renamed to a compliant name in a later migration
|
|
179
|
+
const isColumnRenamed = (table, col) => {
|
|
180
|
+
const key = `${table}.${col}`
|
|
181
|
+
return renamedColumns.has(key)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Helper: check if an index was renamed to a compliant name in a later migration
|
|
185
|
+
const isIndexRenamed = (indexName) => {
|
|
186
|
+
return renamedIndexes.has(indexName)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Helper: check if a missing timestamp was added in a later migration
|
|
190
|
+
const isTimestampAddedLater = (table, col) => {
|
|
191
|
+
return lateAddedColumns.has(`${table}.${col}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Filter out resolved violations
|
|
195
|
+
const unresolvedViolations = violations.filter(v => {
|
|
196
|
+
// Column violations: "Boolean/JSON/Column "table.col" ..."
|
|
197
|
+
const colMatch = v.match(/(?:Boolean|JSON|Column|PK|FK)\s+"(\w+)\.(\w+)"/)
|
|
198
|
+
if (colMatch && isColumnRenamed(colMatch[1], colMatch[2])) {
|
|
199
|
+
compliant++ // was a violation, now fixed
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Index violations: 'Index "name" should use idx_...'
|
|
204
|
+
const idxMatch = v.match(/Index\s+"(\w+)"\s+should/)
|
|
205
|
+
if (idxMatch && isIndexRenamed(idxMatch[1])) {
|
|
206
|
+
compliant++
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Missing timestamp violations: 'Table "x" missing created_at, updated_at'
|
|
211
|
+
const tsMatch = v.match(/Table\s+"(\w+)"\s+missing\s+(.+?)(?:\s+\()/)
|
|
212
|
+
if (tsMatch) {
|
|
213
|
+
const table = tsMatch[1]
|
|
214
|
+
const missingCols = tsMatch[2].split(/,\s*/)
|
|
215
|
+
const allFixed = missingCols.every(col => isTimestampAddedLater(table, col.trim()))
|
|
216
|
+
if (allFixed) {
|
|
217
|
+
compliant++
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return true
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// Replace violations array with filtered version
|
|
226
|
+
violations.length = 0
|
|
227
|
+
violations.push(...unresolvedViolations)
|
|
228
|
+
|
|
140
229
|
// Consistency checks
|
|
141
230
|
const consistencyGroups = [
|
|
142
231
|
{ concept: 'creation timestamp', preferred: 'created_at', alternatives: ['created_on', 'creation_date', 'inserted_at', 'date_created'] },
|
|
@@ -265,14 +354,14 @@ function scanCodeNaming(projectPath) {
|
|
|
265
354
|
}
|
|
266
355
|
}
|
|
267
356
|
|
|
268
|
-
export async function check(projectPath) {
|
|
357
|
+
export async function check(projectPath, options = {}) {
|
|
269
358
|
const result = createCheck('naming-conventions', 3, {
|
|
270
359
|
database: { totalFields: 0, compliant: 0, violations: [], compliancePercent: 0 },
|
|
271
360
|
code: { totalChecked: 0, compliant: 0, violations: [], compliancePercent: 0 },
|
|
272
361
|
overallCompliancePercent: 0
|
|
273
362
|
})
|
|
274
363
|
|
|
275
|
-
const dbResult = scanDatabaseNaming(projectPath)
|
|
364
|
+
const dbResult = scanDatabaseNaming(projectPath, options)
|
|
276
365
|
result.details.database = dbResult
|
|
277
366
|
if (dbResult.totalFields > 0) {
|
|
278
367
|
if (dbResult.compliancePercent >= 80) result.score += 1
|
package/lib/config.js
CHANGED
|
@@ -78,10 +78,27 @@ export const DEFAULT_CONFIG = {
|
|
|
78
78
|
|
|
79
79
|
// Code quality checks
|
|
80
80
|
codeQuality: {
|
|
81
|
-
// Architecture
|
|
82
|
-
maxFileLines: 500,
|
|
81
|
+
// Architecture — file size limits per type (lines)
|
|
82
|
+
maxFileLines: 500, // legacy fallback
|
|
83
|
+
fileSizeLimits: {
|
|
84
|
+
route: 200, // routes = wiring, no logic
|
|
85
|
+
controller: 400, // request/response + error handling
|
|
86
|
+
service: 600, // business logic, allowed to be complex
|
|
87
|
+
default: 400, // everything else
|
|
88
|
+
},
|
|
83
89
|
checkCircularDeps: true,
|
|
84
90
|
|
|
91
|
+
// Route separation — detect business logic in route files
|
|
92
|
+
routeSeparation: {
|
|
93
|
+
enabled: true,
|
|
94
|
+
allowDbInRoutes: false,
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// Naming conventions
|
|
98
|
+
namingConventions: {
|
|
99
|
+
ignoreMigrations: [], // migration filenames to skip (already applied, can't rename)
|
|
100
|
+
},
|
|
101
|
+
|
|
85
102
|
// Dead code
|
|
86
103
|
runKnip: true,
|
|
87
104
|
|
package/lib/runner.js
CHANGED
|
@@ -17,6 +17,7 @@ import * as npmAudit from './checks/stability/npm-audit.js'
|
|
|
17
17
|
import * as apiResponseFormat from './checks/codeQuality/api-response-format.js'
|
|
18
18
|
import * as fileSize from './checks/codeQuality/file-size.js'
|
|
19
19
|
import * as namingConventions from './checks/codeQuality/naming-conventions.js'
|
|
20
|
+
import * as routeSeparation from './checks/codeQuality/route-separation.js'
|
|
20
21
|
import * as gitignoreValidation from './checks/security/gitignore-validation.js'
|
|
21
22
|
import * as rlsPolicyAudit from './checks/supabase/rls-policy-audit.js'
|
|
22
23
|
import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
|
|
@@ -40,7 +41,8 @@ const ALL_CHECKS = {
|
|
|
40
41
|
codeQuality: [
|
|
41
42
|
apiResponseFormat,
|
|
42
43
|
fileSize,
|
|
43
|
-
namingConventions
|
|
44
|
+
namingConventions,
|
|
45
|
+
routeSeparation
|
|
44
46
|
],
|
|
45
47
|
supabase: [
|
|
46
48
|
rlsPolicyAudit,
|