@soulbatical/tetra-dev-toolkit 1.2.0 → 1.3.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/bin/cleanup-repos.sh +287 -0
- package/bin/tetra-audit.js +5 -1
- package/bin/tetra-setup.js +35 -1
- package/lib/checks/health/file-organization.js +389 -0
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/scanner.js +3 -1
- package/lib/checks/health/stella-integration.js +7 -7
- package/lib/checks/health/types.js +1 -1
- package/lib/checks/hygiene/file-organization.js +105 -0
- package/lib/checks/index.js +4 -0
- package/lib/checks/stability/ci-pipeline.js +1 -1
- package/lib/checks/supabase/rpc-param-mismatch.js +453 -0
- package/lib/config.js +2 -1
- package/lib/runner.js +15 -2
- package/package.json +2 -2
|
@@ -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
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.
|
|
3
|
+
"version": "1.3.0",
|
|
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/
|
|
12
|
+
"url": "https://github.com/mralbertzwolle/tetra"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"quality",
|