@soulbatical/tetra-dev-toolkit 1.7.0 → 1.8.1

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.
@@ -6,7 +6,13 @@
6
6
  * - Indicate missing separation of concerns
7
7
  * - Grow silently until they become unmovable
8
8
  *
9
- * Uses config.codeQuality.maxFileLines (default: 500).
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 maxFileLines — god files that need splitting'
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
- const maxLines = config.codeQuality?.maxFileLines || 500
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
- maxFileLines: maxLines,
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 <= maxLines) continue
139
+ if (lineCount <= limit) continue
104
140
 
105
141
  // Determine severity based on how far over the limit
106
- const ratio = lineCount / maxLines
142
+ const ratio = lineCount / limit
107
143
  let severity
108
144
  if (ratio >= 5) {
109
- severity = 'critical' // 5x over limit (e.g. 2500+ lines with 500 limit)
145
+ severity = 'critical' // 5x over limit
110
146
  } else if (ratio >= 3) {
111
- severity = 'high' // 3x over limit (e.g. 1500+ lines)
147
+ severity = 'high' // 3x over limit
112
148
  } else if (ratio >= 2) {
113
- severity = 'medium' // 2x over limit (e.g. 1000+ lines)
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: ${maxLines}) — split into smaller modules`,
124
- fix: `Break this file into focused modules of <${maxLines} lines each`
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]++
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Code Quality Check: Naming Conventions (DB + Code)
3
+ *
4
+ * Wraps the health check naming-conventions as a tetra-audit check.
5
+ * Enforces snake_case in DB, camelCase/PascalCase in code, lowercase dirs.
6
+ *
7
+ * DB violations: table/column naming, PK/FK conventions, bool prefixes, JSON suffixes
8
+ * Code violations: variable/type naming, file naming, directory casing
9
+ *
10
+ * Severity: high — inconsistent naming creates confusion and bugs
11
+ */
12
+
13
+ import { check as healthCheck } from '../health/naming-conventions.js'
14
+
15
+ export const meta = {
16
+ id: 'naming-conventions',
17
+ name: 'Naming Conventions',
18
+ category: 'codeQuality',
19
+ severity: 'high',
20
+ description: 'Enforces snake_case in DB schema, camelCase/PascalCase in TypeScript, lowercase directories'
21
+ }
22
+
23
+ export async function run(config, projectRoot) {
24
+ const result = {
25
+ passed: true,
26
+ findings: [],
27
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
28
+ details: {}
29
+ }
30
+
31
+ const ignoreMigrations = config.codeQuality?.namingConventions?.ignoreMigrations || []
32
+ const health = await healthCheck(projectRoot, { ignoreMigrations })
33
+ result.details = health.details
34
+
35
+ const dbViolations = health.details.database?.violations || []
36
+ const codeViolations = health.details.code?.violations || []
37
+ const dbCompliance = health.details.database?.compliancePercent ?? 100
38
+ const codeCompliance = health.details.code?.compliancePercent ?? 100
39
+
40
+ // DB naming violations
41
+ if (dbViolations.length > 0) {
42
+ // Separate critical (PK/FK structural) from high (naming style)
43
+ const structural = dbViolations.filter(v =>
44
+ v.includes('should be named "id"') ||
45
+ v.includes('should use _id suffix') ||
46
+ v.includes('should be "deleted_at"') ||
47
+ v.includes('missing created_at') ||
48
+ v.includes('missing updated_at') ||
49
+ v.includes('Inconsistent') ||
50
+ v.includes('Non-standard')
51
+ )
52
+ const style = dbViolations.filter(v => !structural.includes(v))
53
+
54
+ if (structural.length > 0) {
55
+ result.findings.push({
56
+ type: 'DB structural naming violations',
57
+ severity: 'critical',
58
+ message: `${structural.length} structural naming issue(s) in DB schema (PK/FK/timestamps/consistency)`,
59
+ files: structural.slice(0, 20)
60
+ })
61
+ result.summary.critical++
62
+ result.summary.total++
63
+ }
64
+
65
+ if (style.length > 0) {
66
+ result.findings.push({
67
+ type: 'DB naming style violations',
68
+ severity: 'high',
69
+ message: `${style.length} naming style issue(s) in DB schema (snake_case, bool prefix, JSON suffix)`,
70
+ files: style.slice(0, 20)
71
+ })
72
+ result.summary.high++
73
+ result.summary.total++
74
+ }
75
+ }
76
+
77
+ // Code naming violations
78
+ if (codeViolations.length > 0) {
79
+ const dirViolations = codeViolations.filter(v => v.includes('should be lowercase'))
80
+ const fileViolations = codeViolations.filter(v => v.includes('naming issue'))
81
+ const varViolations = codeViolations.filter(v =>
82
+ v.includes('should be camelCase') ||
83
+ v.includes('should be PascalCase') ||
84
+ v.includes('should not use I-prefix')
85
+ )
86
+
87
+ if (dirViolations.length > 0) {
88
+ result.findings.push({
89
+ type: 'Directory naming violations',
90
+ severity: 'high',
91
+ message: `${dirViolations.length} director(ies) not lowercase`,
92
+ files: dirViolations.slice(0, 10)
93
+ })
94
+ result.summary.high++
95
+ result.summary.total++
96
+ }
97
+
98
+ if (fileViolations.length > 0) {
99
+ result.findings.push({
100
+ type: 'File naming violations',
101
+ severity: 'medium',
102
+ message: `${fileViolations.length} file(s) with naming issues`,
103
+ files: fileViolations.slice(0, 10)
104
+ })
105
+ result.summary.medium++
106
+ result.summary.total++
107
+ }
108
+
109
+ if (varViolations.length > 0) {
110
+ result.findings.push({
111
+ type: 'Variable/type naming violations',
112
+ severity: 'medium',
113
+ message: `${varViolations.length} variable(s) or type(s) with naming issues`,
114
+ files: varViolations.slice(0, 10)
115
+ })
116
+ result.summary.medium++
117
+ result.summary.total++
118
+ }
119
+ }
120
+
121
+ // Overall compliance summary
122
+ result.details.dbCompliance = dbCompliance
123
+ result.details.codeCompliance = codeCompliance
124
+
125
+ // Fail if DB compliance < 80% or code compliance < 70%, or any critical findings
126
+ result.passed = result.summary.critical === 0 &&
127
+ dbCompliance >= 80 &&
128
+ codeCompliance >= 70
129
+
130
+ return result
131
+ }
@@ -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 */ }
@@ -265,14 +268,14 @@ function scanCodeNaming(projectPath) {
265
268
  }
266
269
  }
267
270
 
268
- export async function check(projectPath) {
271
+ export async function check(projectPath, options = {}) {
269
272
  const result = createCheck('naming-conventions', 3, {
270
273
  database: { totalFields: 0, compliant: 0, violations: [], compliancePercent: 0 },
271
274
  code: { totalChecked: 0, compliant: 0, violations: [], compliancePercent: 0 },
272
275
  overallCompliancePercent: 0
273
276
  })
274
277
 
275
- const dbResult = scanDatabaseNaming(projectPath)
278
+ const dbResult = scanDatabaseNaming(projectPath, options)
276
279
  result.details.database = dbResult
277
280
  if (dbResult.totalFields > 0) {
278
281
  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
@@ -16,6 +16,8 @@ import * as ciPipeline from './checks/stability/ci-pipeline.js'
16
16
  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
+ import * as namingConventions from './checks/codeQuality/naming-conventions.js'
20
+ import * as routeSeparation from './checks/codeQuality/route-separation.js'
19
21
  import * as gitignoreValidation from './checks/security/gitignore-validation.js'
20
22
  import * as rlsPolicyAudit from './checks/supabase/rls-policy-audit.js'
21
23
  import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
@@ -38,7 +40,9 @@ const ALL_CHECKS = {
38
40
  ],
39
41
  codeQuality: [
40
42
  apiResponseFormat,
41
- fileSize
43
+ fileSize,
44
+ namingConventions,
45
+ routeSeparation
42
46
  ],
43
47
  supabase: [
44
48
  rlsPolicyAudit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },