@soulbatical/tetra-dev-toolkit 1.1.1 → 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/{vca-audit.js → tetra-audit.js} +16 -12
- package/bin/{vca-dev-token.js → tetra-dev-token.js} +7 -7
- package/bin/{vca-setup.js → tetra-setup.js} +54 -20
- package/lib/checks/health/file-organization.js +389 -0
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/quality-toolkit.js +12 -8
- package/lib/checks/health/repo-visibility.js +1 -1
- 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/stability/husky-hooks.js +1 -1
- package/lib/checks/supabase/rpc-param-mismatch.js +453 -0
- package/lib/commands/dev-token.js +4 -4
- package/lib/config.js +16 -12
- package/lib/index.js +3 -3
- package/lib/reporters/terminal.js +1 -1
- package/lib/runner.js +16 -3
- package/package.json +6 -8
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tetra Dev Toolkit - Dev Token Manager
|
|
3
3
|
*
|
|
4
|
-
* Centralized dev token management for all
|
|
4
|
+
* Centralized dev token management for all Tetra/Supabase projects.
|
|
5
5
|
* Auto-detects project name, finds Supabase config, manages token lifecycle.
|
|
6
6
|
*
|
|
7
7
|
* Replaces per-project generate-dev-token.js scripts.
|
|
@@ -279,7 +279,7 @@ export async function runDevToken({ forceLogin = false, showStatus = false, proj
|
|
|
279
279
|
if (showStatus) {
|
|
280
280
|
if (!cache) {
|
|
281
281
|
console.log(chalk.red('No cached token.'))
|
|
282
|
-
console.log(chalk.dim(`Run:
|
|
282
|
+
console.log(chalk.dim(`Run: tetra-dev-token --login`))
|
|
283
283
|
process.exit(1)
|
|
284
284
|
}
|
|
285
285
|
const payload = decodeJWT(cache.access_token)
|
|
@@ -337,6 +337,6 @@ export async function runDevToken({ forceLogin = false, showStatus = false, proj
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
console.log(chalk.red('No valid token.'))
|
|
340
|
-
console.log(chalk.dim(`Run:
|
|
340
|
+
console.log(chalk.dim(`Run: tetra-dev-token --login`))
|
|
341
341
|
process.exit(1)
|
|
342
342
|
}
|
package/lib/config.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tetra Dev Toolkit - Configuration
|
|
3
3
|
*
|
|
4
4
|
* Default configuration that can be overridden per project via:
|
|
5
|
-
* - .
|
|
6
|
-
* -
|
|
5
|
+
* - .tetra-quality.json in project root (also checks legacy .vca-quality.json)
|
|
6
|
+
* - tetra-quality key in package.json (also checks legacy vca-quality key)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { readFileSync, existsSync } from 'fs'
|
|
@@ -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
|
|
@@ -135,23 +136,26 @@ export const DEFAULT_CONFIG = {
|
|
|
135
136
|
export function loadConfig(projectRoot = process.cwd()) {
|
|
136
137
|
let projectConfig = {}
|
|
137
138
|
|
|
138
|
-
// Check for .vca-quality.json
|
|
139
|
-
const configFile = join(projectRoot, '.
|
|
140
|
-
|
|
139
|
+
// Check for .tetra-quality.json (with legacy .vca-quality.json fallback)
|
|
140
|
+
const configFile = join(projectRoot, '.tetra-quality.json')
|
|
141
|
+
const legacyConfigFile = join(projectRoot, '.vca-quality.json')
|
|
142
|
+
const activeConfigFile = existsSync(configFile) ? configFile : (existsSync(legacyConfigFile) ? legacyConfigFile : null)
|
|
143
|
+
if (activeConfigFile) {
|
|
141
144
|
try {
|
|
142
|
-
projectConfig = JSON.parse(readFileSync(
|
|
145
|
+
projectConfig = JSON.parse(readFileSync(activeConfigFile, 'utf-8'))
|
|
143
146
|
} catch (e) {
|
|
144
|
-
console.warn(`Warning: Could not parse ${
|
|
147
|
+
console.warn(`Warning: Could not parse ${activeConfigFile}`)
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
// Check for
|
|
151
|
+
// Check for tetra-quality in package.json (with legacy vca-quality fallback)
|
|
149
152
|
const packageFile = join(projectRoot, 'package.json')
|
|
150
153
|
if (existsSync(packageFile)) {
|
|
151
154
|
try {
|
|
152
155
|
const pkg = JSON.parse(readFileSync(packageFile, 'utf-8'))
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
const pkgConfig = pkg['tetra-quality'] || pkg['vca-quality']
|
|
157
|
+
if (pkgConfig) {
|
|
158
|
+
projectConfig = { ...projectConfig, ...pkgConfig }
|
|
155
159
|
}
|
|
156
160
|
} catch (e) {
|
|
157
161
|
// Ignore
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tetra Dev Toolkit
|
|
3
3
|
*
|
|
4
|
-
* Unified quality checks for all
|
|
4
|
+
* Unified quality checks for all Tetra projects.
|
|
5
5
|
* Consolidates security, stability, and code quality checks
|
|
6
|
-
*
|
|
6
|
+
* into a single npm package.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export { loadConfig, detectSupabase, DEFAULT_CONFIG } from './config.js'
|
|
@@ -24,7 +24,7 @@ export function formatResults(results, options = {}) {
|
|
|
24
24
|
// Header
|
|
25
25
|
lines.push('')
|
|
26
26
|
lines.push(chalk.bold('═══════════════════════════════════════════════════════════════'))
|
|
27
|
-
lines.push(chalk.bold.cyan(' 🔍
|
|
27
|
+
lines.push(chalk.bold.cyan(' 🔍 Tetra Dev Toolkit - Audit Results'))
|
|
28
28
|
lines.push(chalk.bold('═══════════════════════════════════════════════════════════════'))
|
|
29
29
|
lines.push('')
|
|
30
30
|
|
package/lib/runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tetra Dev Toolkit - Check Runner
|
|
3
3
|
*
|
|
4
4
|
* Orchestrates running all checks and collecting results
|
|
5
5
|
*/
|
|
@@ -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,15 +1,15 @@
|
|
|
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
|
},
|
|
7
|
-
"description": "Developer toolkit for
|
|
7
|
+
"description": "Developer toolkit for Tetra projects - audit, dev-token, quality checks",
|
|
8
8
|
"author": "Albert Barth <albertbarth@gmail.com>",
|
|
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",
|
|
@@ -25,11 +25,9 @@
|
|
|
25
25
|
"type": "module",
|
|
26
26
|
"main": "lib/index.js",
|
|
27
27
|
"bin": {
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"vca-setup": "./bin/vca-setup.js",
|
|
32
|
-
"vca-dev-token": "./bin/vca-dev-token.js"
|
|
28
|
+
"tetra-audit": "./bin/tetra-audit.js",
|
|
29
|
+
"tetra-setup": "./bin/tetra-setup.js",
|
|
30
|
+
"tetra-dev-token": "./bin/tetra-dev-token.js"
|
|
33
31
|
},
|
|
34
32
|
"files": [
|
|
35
33
|
"bin/",
|