@soulbatical/tetra-dev-toolkit 1.2.0 → 1.3.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.
@@ -13,5 +13,9 @@ export * as huskyHooks from './stability/husky-hooks.js'
13
13
  export * as ciPipeline from './stability/ci-pipeline.js'
14
14
  export * as npmAudit from './stability/npm-audit.js'
15
15
 
16
+ // Supabase checks
17
+ export * as rlsPolicyAudit from './supabase/rls-policy-audit.js'
18
+ export * as rpcParamMismatch from './supabase/rpc-param-mismatch.js'
19
+
16
20
  // Health checks (project ecosystem)
17
21
  export * as health from './health/index.js'
@@ -91,7 +91,7 @@ export async function run(config, projectRoot) {
91
91
  },
92
92
  {
93
93
  name: 'security-audit',
94
- patterns: ['npm audit', 'tetra-audit', 'vca-audit', 'security-check', 'snyk', 'CodeQL'],
94
+ patterns: ['npm audit', 'tetra-audit', 'security-check', 'snyk', 'CodeQL'],
95
95
  severity: 'medium'
96
96
  }
97
97
  ]
@@ -0,0 +1,453 @@
1
+ /**
2
+ * RPC Parameter Mismatch Check
3
+ *
4
+ * Statically compares .rpc() calls in TypeScript with SQL function definitions
5
+ * in migration files to detect parameter name mismatches that cause PGRST202
6
+ * errors at runtime.
7
+ *
8
+ * Origin: Soulbatical checkout was broken because MollieService.ts called
9
+ * .rpc('get_products_by_ids', { product_ids: ... }) while the SQL function
10
+ * expected parameter name `p_ids`. This mismatch is invisible until runtime.
11
+ */
12
+
13
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs'
14
+ import { join, relative } from 'path'
15
+
16
+ export const meta = {
17
+ id: 'rpc-param-mismatch',
18
+ name: 'RPC Parameter Mismatch',
19
+ category: 'supabase',
20
+ severity: 'critical',
21
+ description: 'Detects parameter name mismatches between .rpc() calls and SQL function definitions'
22
+ }
23
+
24
+ /**
25
+ * Recursively collect files with a given extension
26
+ */
27
+ function collectFiles(dir, ext, files = []) {
28
+ if (!existsSync(dir)) return files
29
+ for (const entry of readdirSync(dir)) {
30
+ const full = join(dir, entry)
31
+ try {
32
+ const stat = statSync(full)
33
+ if (stat.isDirectory()) {
34
+ // Skip node_modules and dist
35
+ if (entry === 'node_modules' || entry === 'dist' || entry === '.next') continue
36
+ collectFiles(full, ext, files)
37
+ } else if (entry.endsWith(ext)) {
38
+ files.push(full)
39
+ }
40
+ } catch {
41
+ // Permission errors etc - skip
42
+ }
43
+ }
44
+ return files
45
+ }
46
+
47
+ /**
48
+ * Parse SQL migration files to build a map of function_name -> [param_names]
49
+ * Takes the LAST definition for each function (migrations are sorted by timestamp)
50
+ */
51
+ function parseSqlFunctions(sqlFiles) {
52
+ const functions = new Map() // functionName -> { params: string[], file: string }
53
+
54
+ for (const filePath of sqlFiles) {
55
+ let content
56
+ try {
57
+ content = readFileSync(filePath, 'utf-8')
58
+ } catch { continue }
59
+
60
+ const fileName = filePath.split('/').pop()
61
+
62
+ // Track DROP FUNCTION to remove from map
63
+ const dropRe = /DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?/gi
64
+ let match
65
+ while ((match = dropRe.exec(content)) !== null) {
66
+ functions.delete(match[1].toLowerCase())
67
+ }
68
+
69
+ // Track CREATE OR REPLACE FUNCTION
70
+ // Handles: CREATE OR REPLACE FUNCTION public.func_name(p_id uuid, p_name text)
71
+ // Also handles: CREATE FUNCTION func_name(p_id uuid DEFAULT NULL)
72
+ // Multiline parameter lists are common
73
+ const createRe = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?["']?(\w+)["']?\s*\(([^)]*)\)/gi
74
+ while ((match = createRe.exec(content)) !== null) {
75
+ const funcName = match[1].toLowerCase()
76
+ const paramBlock = match[2].trim()
77
+
78
+ if (!paramBlock) {
79
+ // No parameters
80
+ functions.set(funcName, { params: [], file: fileName })
81
+ continue
82
+ }
83
+
84
+ // Parse parameter names from the parameter block
85
+ // Each param looks like: p_name type [DEFAULT value]
86
+ // Split by comma, but be careful with commas inside DEFAULT expressions
87
+ const params = parseParamNames(paramBlock)
88
+ functions.set(funcName, { params, file: fileName })
89
+ }
90
+ }
91
+
92
+ return functions
93
+ }
94
+
95
+ /**
96
+ * Parse parameter names from a SQL function parameter block
97
+ * Handles: "p_id uuid, p_name text DEFAULT NULL, p_data jsonb DEFAULT '{}'"
98
+ * Returns: ["p_id", "p_name", "p_data"]
99
+ */
100
+ function parseParamNames(paramBlock) {
101
+ const params = []
102
+
103
+ // Split by comma, but track parenthesis depth to avoid splitting inside DEFAULT expressions
104
+ let depth = 0
105
+ let current = ''
106
+
107
+ for (const char of paramBlock) {
108
+ if (char === '(') depth++
109
+ else if (char === ')') depth--
110
+ else if (char === ',' && depth === 0) {
111
+ const name = extractParamName(current.trim())
112
+ if (name) params.push(name)
113
+ current = ''
114
+ continue
115
+ }
116
+ current += char
117
+ }
118
+
119
+ // Last parameter
120
+ if (current.trim()) {
121
+ const name = extractParamName(current.trim())
122
+ if (name) params.push(name)
123
+ }
124
+
125
+ return params
126
+ }
127
+
128
+ /**
129
+ * Extract parameter name from a single parameter declaration
130
+ * "p_id uuid" -> "p_id"
131
+ * "p_name text DEFAULT NULL" -> "p_name"
132
+ * "IN p_id uuid" -> "p_id"
133
+ * "INOUT p_id uuid" -> "p_id"
134
+ * "OUT p_result jsonb" -> null (OUT params are not passed by caller)
135
+ */
136
+ function extractParamName(paramDecl) {
137
+ if (!paramDecl) return null
138
+
139
+ // Remove leading IN/INOUT/OUT modifiers
140
+ let decl = paramDecl.replace(/^\s*(INOUT|OUT|IN)\s+/i, '')
141
+
142
+ // Check if it was an OUT parameter - callers don't pass these
143
+ if (/^\s*OUT\s+/i.test(paramDecl)) return null
144
+
145
+ // First word is the parameter name
146
+ const nameMatch = decl.match(/^(\w+)/)
147
+ if (!nameMatch) return null
148
+
149
+ const name = nameMatch[1].toLowerCase()
150
+
151
+ // Skip if it looks like a type without a name (e.g., just "uuid" or "integer")
152
+ // SQL types that could appear without a param name in weird edge cases
153
+ const sqlTypes = new Set([
154
+ 'uuid', 'text', 'integer', 'int', 'bigint', 'smallint', 'boolean', 'bool',
155
+ 'numeric', 'decimal', 'real', 'float', 'double', 'varchar', 'char',
156
+ 'timestamp', 'timestamptz', 'date', 'time', 'interval', 'json', 'jsonb',
157
+ 'bytea', 'void', 'record', 'trigger', 'setof', 'table'
158
+ ])
159
+
160
+ // If the declaration has only one word and it's a type, it's an unnamed param
161
+ const words = decl.trim().split(/\s+/)
162
+ if (words.length === 1 && sqlTypes.has(name)) return null
163
+
164
+ return name
165
+ }
166
+
167
+ /**
168
+ * Extract object keys from a JS object literal string, ignoring ternary colons.
169
+ * Strategy: walk through the string tracking context. A real object key is
170
+ * an identifier followed by `:` where the `:` is NOT part of a ternary expression.
171
+ * We track `?` depth: after a `?` we expect a ternary `:`, not a key-value `:`.
172
+ *
173
+ * Simpler approach: split by comma (respecting nesting), then take the first
174
+ * identifier before `:` from each segment.
175
+ */
176
+ function extractObjectKeys(paramBlock) {
177
+ const keys = []
178
+
179
+ // Split by comma at top level (respect parentheses, brackets, braces)
180
+ const segments = []
181
+ let depth = 0
182
+ let current = ''
183
+
184
+ for (const char of paramBlock) {
185
+ if (char === '(' || char === '[' || char === '{') depth++
186
+ else if (char === ')' || char === ']' || char === '}') depth--
187
+ else if (char === ',' && depth === 0) {
188
+ segments.push(current.trim())
189
+ current = ''
190
+ continue
191
+ }
192
+ current += char
193
+ }
194
+ if (current.trim()) segments.push(current.trim())
195
+
196
+ // From each segment, extract the key (first identifier before first `:`)
197
+ for (const segment of segments) {
198
+ // Match: optional whitespace, then identifier, then optional whitespace, then `:`
199
+ const keyMatch = segment.match(/^\s*(\w+)\s*:/)
200
+ if (keyMatch) {
201
+ keys.push(keyMatch[1].toLowerCase())
202
+ }
203
+ }
204
+
205
+ return keys
206
+ }
207
+
208
+ /**
209
+ * Parse TypeScript files for .rpc() calls
210
+ * Returns array of { funcName, params: string[], file, line }
211
+ */
212
+ function parseRpcCalls(tsFiles, projectRoot) {
213
+ const calls = []
214
+
215
+ for (const filePath of tsFiles) {
216
+ let content
217
+ try {
218
+ content = readFileSync(filePath, 'utf-8')
219
+ } catch { continue }
220
+
221
+ const relPath = relative(projectRoot, filePath)
222
+ const lines = content.split('\n')
223
+
224
+ // Strategy: find .rpc( on each line, then extract the full call
225
+ // This handles both single-line and multi-line .rpc() calls
226
+ for (let i = 0; i < lines.length; i++) {
227
+ const line = lines[i]
228
+
229
+ // Quick check: does this line contain .rpc(
230
+ if (!line.includes('.rpc(')) continue
231
+
232
+ // Skip comments
233
+ const trimmed = line.trim()
234
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue
235
+
236
+ // Build the full expression by collecting lines until we have balanced braces
237
+ // or hit a reasonable limit
238
+ let fullExpr = ''
239
+ let braceDepth = 0
240
+ let foundOpenBrace = false
241
+ let parenDepth = 0
242
+ let foundRpc = false
243
+
244
+ for (let j = i; j < Math.min(i + 30, lines.length); j++) {
245
+ fullExpr += lines[j] + '\n'
246
+
247
+ for (const ch of lines[j]) {
248
+ if (ch === '(') parenDepth++
249
+ if (ch === ')') parenDepth--
250
+ if (ch === '{') { braceDepth++; foundOpenBrace = true }
251
+ if (ch === '}') braceDepth--
252
+ }
253
+
254
+ // If we found the opening { and braces are balanced, we have the full call
255
+ if (foundOpenBrace && braceDepth === 0) break
256
+
257
+ // If we've closed all parens after .rpc( without finding a {, it's .rpc('name') with no params
258
+ if (parenDepth <= 0 && j > i) break
259
+ }
260
+
261
+ // Extract function name - must be a string literal (skip dynamic names)
262
+ const funcNameMatch = fullExpr.match(/\.rpc\(\s*['"](\w+)['"]/)
263
+ if (!funcNameMatch) continue // Dynamic name - skip
264
+
265
+ const funcName = funcNameMatch[1].toLowerCase()
266
+
267
+ // Extract parameter object keys
268
+ // Look for the second argument: , { key1: ..., key2: ... }
269
+ const paramObjMatch = fullExpr.match(/\.rpc\(\s*['"](\w+)['"]\s*,\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/)
270
+ if (!paramObjMatch) {
271
+ // No params object - that's fine, the call has no params
272
+ continue
273
+ }
274
+
275
+ const paramBlock = paramObjMatch[2]
276
+
277
+ // Extract keys from the object literal
278
+ // We need to distinguish real object keys from ternary : operators
279
+ // Real keys appear: at start, after comma, after newline
280
+ // Ternary colons appear: after ? ... expression
281
+ const paramNames = extractObjectKeys(paramBlock)
282
+
283
+ if (paramNames.length > 0) {
284
+ calls.push({
285
+ funcName,
286
+ params: paramNames,
287
+ file: relPath,
288
+ line: i + 1
289
+ })
290
+ }
291
+ }
292
+ }
293
+
294
+ return calls
295
+ }
296
+
297
+ export async function run(config, projectRoot) {
298
+ const results = {
299
+ passed: true,
300
+ findings: [],
301
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
302
+ details: {
303
+ sqlFunctionsFound: 0,
304
+ rpcCallsFound: 0,
305
+ rpcCallsChecked: 0,
306
+ rpcCallsSkipped: 0,
307
+ mismatches: 0
308
+ }
309
+ }
310
+
311
+ // --- Step 1: Collect SQL migration files ---
312
+ const migrationPaths = config.paths?.migrations || ['supabase/migrations', 'migrations']
313
+ const extraMigrationPaths = ['backend/supabase/migrations']
314
+ const allMigrationPaths = [...migrationPaths, ...extraMigrationPaths]
315
+
316
+ const sqlFiles = []
317
+ for (const relPath of allMigrationPaths) {
318
+ const dir = join(projectRoot, relPath)
319
+ if (!existsSync(dir)) continue
320
+ try {
321
+ const files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort()
322
+ for (const f of files) {
323
+ sqlFiles.push(join(dir, f))
324
+ }
325
+ } catch {
326
+ // ignore
327
+ }
328
+ }
329
+
330
+ if (sqlFiles.length === 0) {
331
+ results.skipped = true
332
+ results.skipReason = 'No SQL migration files found'
333
+ return results
334
+ }
335
+
336
+ // --- Step 2: Collect TypeScript files ---
337
+ const backendPaths = config.paths?.backend || ['backend/src', 'src']
338
+ const tsFiles = []
339
+ for (const relPath of backendPaths) {
340
+ const dir = join(projectRoot, relPath)
341
+ collectFiles(dir, '.ts', tsFiles)
342
+ }
343
+
344
+ if (tsFiles.length === 0) {
345
+ results.skipped = true
346
+ results.skipReason = 'No TypeScript files found in backend paths'
347
+ return results
348
+ }
349
+
350
+ // --- Step 3: Parse SQL functions ---
351
+ const sqlFunctions = parseSqlFunctions(sqlFiles)
352
+ results.details.sqlFunctionsFound = sqlFunctions.size
353
+
354
+ // --- Step 4: Parse .rpc() calls ---
355
+ const rpcCalls = parseRpcCalls(tsFiles, projectRoot)
356
+ results.details.rpcCallsFound = rpcCalls.length
357
+
358
+ // --- Step 5: Compare ---
359
+ for (const call of rpcCalls) {
360
+ const sqlFunc = sqlFunctions.get(call.funcName)
361
+
362
+ if (!sqlFunc) {
363
+ // Function not found in migrations - might be defined elsewhere (e.g., live-only, extensions)
364
+ results.details.rpcCallsSkipped++
365
+ continue
366
+ }
367
+
368
+ results.details.rpcCallsChecked++
369
+
370
+ const sqlParamSet = new Set(sqlFunc.params)
371
+ const tsParamSet = new Set(call.params)
372
+
373
+ // Find params in TS that don't exist in SQL
374
+ const extraInTs = call.params.filter(p => !sqlParamSet.has(p))
375
+
376
+ // Find params in SQL that don't exist in TS (these might have defaults)
377
+ const missingInTs = sqlFunc.params.filter(p => !tsParamSet.has(p))
378
+
379
+ if (extraInTs.length > 0) {
380
+ results.passed = false
381
+ results.details.mismatches++
382
+
383
+ // Check if it looks like a naming mismatch (similar param names)
384
+ const suggestion = findSuggestions(extraInTs, sqlFunc.params)
385
+
386
+ results.findings.push({
387
+ file: call.file,
388
+ line: call.line,
389
+ type: 'RPC parameter mismatch',
390
+ severity: 'critical',
391
+ message: `${call.file}:${call.line} — .rpc('${call.funcName}') passes parameter(s) [${extraInTs.join(', ')}] but SQL function expects [${sqlFunc.params.join(', ')}]` +
392
+ (suggestion ? ` (did you mean: ${suggestion}?)` : ''),
393
+ rpcFunction: call.funcName,
394
+ tsParams: call.params,
395
+ sqlParams: sqlFunc.params,
396
+ extraInTs,
397
+ sqlFile: sqlFunc.file
398
+ })
399
+ results.summary.critical++
400
+ results.summary.total++
401
+ }
402
+
403
+ // Report missing required params as info (they might have DEFAULTs)
404
+ if (missingInTs.length > 0 && extraInTs.length === 0) {
405
+ // Only report if ALL SQL params are missing from TS - likely a significant gap
406
+ // If some match, the missing ones probably have DEFAULT values
407
+ const matchRatio = call.params.length / sqlFunc.params.length
408
+ if (matchRatio < 0.5 && sqlFunc.params.length > 1) {
409
+ results.findings.push({
410
+ file: call.file,
411
+ line: call.line,
412
+ type: 'RPC missing parameters',
413
+ severity: 'low',
414
+ message: `${call.file}:${call.line} — .rpc('${call.funcName}') passes only ${call.params.length}/${sqlFunc.params.length} parameters. Missing: [${missingInTs.join(', ')}]`,
415
+ rpcFunction: call.funcName,
416
+ tsParams: call.params,
417
+ sqlParams: sqlFunc.params
418
+ })
419
+ results.summary.low++
420
+ results.summary.total++
421
+ }
422
+ }
423
+ }
424
+
425
+ return results
426
+ }
427
+
428
+ /**
429
+ * Try to find suggestions for mismatched param names
430
+ * e.g., "product_ids" in TS but "p_ids" in SQL → suggest "p_ids"
431
+ */
432
+ function findSuggestions(extraInTs, sqlParams) {
433
+ const suggestions = []
434
+
435
+ for (const tsParam of extraInTs) {
436
+ for (const sqlParam of sqlParams) {
437
+ // Check if one is a substring/suffix of the other
438
+ if (sqlParam.endsWith(tsParam) || tsParam.endsWith(sqlParam)) {
439
+ suggestions.push(`${tsParam} → ${sqlParam}`)
440
+ }
441
+ // Check if adding/removing p_ prefix matches
442
+ else if (sqlParam === `p_${tsParam}` || tsParam === `p_${sqlParam}`) {
443
+ suggestions.push(`${tsParam} → ${sqlParam}`)
444
+ }
445
+ // Check Levenshtein-like similarity (simple: same without underscores)
446
+ else if (tsParam.replace(/_/g, '') === sqlParam.replace(/_/g, '')) {
447
+ suggestions.push(`${tsParam} → ${sqlParam}`)
448
+ }
449
+ }
450
+ }
451
+
452
+ return suggestions.length > 0 ? suggestions.join(', ') : null
453
+ }
package/lib/config.js CHANGED
@@ -15,7 +15,8 @@ export const DEFAULT_CONFIG = {
15
15
  security: true,
16
16
  stability: true,
17
17
  codeQuality: true,
18
- supabase: 'auto' // true, false, or 'auto' (detect)
18
+ supabase: 'auto', // true, false, or 'auto' (detect)
19
+ hygiene: true
19
20
  },
20
21
 
21
22
  // Security checks
package/lib/runner.js CHANGED
@@ -17,6 +17,8 @@ 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 gitignoreValidation from './checks/security/gitignore-validation.js'
19
19
  import * as rlsPolicyAudit from './checks/supabase/rls-policy-audit.js'
20
+ import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
21
+ import * as fileOrganization from './checks/hygiene/file-organization.js'
20
22
 
21
23
  // Register all checks
22
24
  const ALL_CHECKS = {
@@ -36,7 +38,11 @@ const ALL_CHECKS = {
36
38
  apiResponseFormat
37
39
  ],
38
40
  supabase: [
39
- rlsPolicyAudit
41
+ rlsPolicyAudit,
42
+ rpcParamMismatch
43
+ ],
44
+ hygiene: [
45
+ fileOrganization
40
46
  ]
41
47
  }
42
48
 
@@ -46,7 +52,7 @@ const ALL_CHECKS = {
46
52
  export async function runAllChecks(options = {}) {
47
53
  const projectRoot = options.projectRoot || process.cwd()
48
54
  const config = loadConfig(projectRoot)
49
- const suites = options.suites || ['security', 'stability', 'codeQuality', 'supabase']
55
+ const suites = options.suites || ['security', 'stability', 'codeQuality', 'supabase', 'hygiene']
50
56
 
51
57
  // Auto-detect Supabase
52
58
  if (config.suites.supabase === 'auto') {
@@ -134,6 +140,13 @@ export async function runCodeQualityChecks(options = {}) {
134
140
  return runAllChecks({ ...options, suites: ['codeQuality'] })
135
141
  }
136
142
 
143
+ /**
144
+ * Run only hygiene checks (file organization, repo cleanliness)
145
+ */
146
+ export async function runHygieneChecks(options = {}) {
147
+ return runAllChecks({ ...options, suites: ['hygiene'] })
148
+ }
149
+
137
150
  /**
138
151
  * Quick check - only critical checks
139
152
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
@@ -9,7 +9,7 @@
9
9
  "license": "MIT",
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "https://github.com/mralbertzwolle/vca-quality-toolkit"
12
+ "url": "https://github.com/mralbertzwolle/tetra"
13
13
  },
14
14
  "keywords": [
15
15
  "quality",