@soulbatical/tetra-dev-toolkit 1.5.1 → 1.6.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/lib/checks/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export * as npmAudit from './stability/npm-audit.js'
|
|
|
16
16
|
// Supabase checks
|
|
17
17
|
export * as rlsPolicyAudit from './supabase/rls-policy-audit.js'
|
|
18
18
|
export * as rpcParamMismatch from './supabase/rpc-param-mismatch.js'
|
|
19
|
+
export * as rpcGeneratorOrigin from './supabase/rpc-generator-origin.js'
|
|
19
20
|
|
|
20
21
|
// Health checks (project ecosystem)
|
|
21
22
|
export * as health from './health/index.js'
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC Generator Origin Check
|
|
3
|
+
*
|
|
4
|
+
* Ensures that filter/count RPC functions (get_*_results, get_*_counts) are
|
|
5
|
+
* ONLY created via the SQL Generator, never hand-written.
|
|
6
|
+
*
|
|
7
|
+
* Origin: February 2026, SparkBuddy. Hand-editing SQL caused 30+ minutes of
|
|
8
|
+
* debugging when security blocks didn't match, search_path broke, and
|
|
9
|
+
* DO blocks/regex replacements on production failed silently. The only
|
|
10
|
+
* reliable path is: fix the config → regenerate via SQL Generator → deploy.
|
|
11
|
+
*
|
|
12
|
+
* Modes:
|
|
13
|
+
* - Pre-commit (default): Only checks git-staged files matching the pattern.
|
|
14
|
+
* This avoids false positives on legacy files.
|
|
15
|
+
* - Full audit (config.rpcGeneratorOrigin.checkAll = true): Checks ALL files.
|
|
16
|
+
* Use for comprehensive audits.
|
|
17
|
+
*
|
|
18
|
+
* This prevents:
|
|
19
|
+
* - Hand-written RPCs with wrong security patterns (accessLevel mismatch)
|
|
20
|
+
* - Missing public. prefix in search_path="" functions
|
|
21
|
+
* - Security blocks that don't match the generator's output
|
|
22
|
+
* - Silent breakage when generator overwrites manual edits on next run
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync, readdirSync } from 'fs'
|
|
26
|
+
import { join, relative } from 'path'
|
|
27
|
+
import { execSync } from 'child_process'
|
|
28
|
+
|
|
29
|
+
export const meta = {
|
|
30
|
+
id: 'rpc-generator-origin',
|
|
31
|
+
name: 'RPC Generator Origin',
|
|
32
|
+
category: 'supabase',
|
|
33
|
+
severity: 'critical',
|
|
34
|
+
description: 'Ensures get_*_results and get_*_counts SQL files are generated by the SQL Generator, not hand-written'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pattern for files that MUST come from the SQL Generator
|
|
39
|
+
*/
|
|
40
|
+
const GENERATED_FILE_PATTERN = /get_\w+_(results|counts)\.sql$/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Required header that the SQL Generator always includes
|
|
44
|
+
*/
|
|
45
|
+
const GENERATOR_HEADER = '-- Generator: SQL Generator'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Alternative headers from older generator versions
|
|
49
|
+
*/
|
|
50
|
+
const LEGACY_HEADERS = [
|
|
51
|
+
'-- Generated by SQL Generator',
|
|
52
|
+
'-- Auto-generated by RPCGenerator',
|
|
53
|
+
'-- Generator: RPC Generator'
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Try to get git-staged files matching our pattern.
|
|
58
|
+
* Returns null if git is not available or not in a repo.
|
|
59
|
+
*/
|
|
60
|
+
function getStagedFiles(projectRoot) {
|
|
61
|
+
try {
|
|
62
|
+
const output = execSync('git diff --cached --name-only --diff-filter=ACM', {
|
|
63
|
+
cwd: projectRoot,
|
|
64
|
+
encoding: 'utf-8',
|
|
65
|
+
timeout: 5000
|
|
66
|
+
}).trim()
|
|
67
|
+
|
|
68
|
+
if (!output) return []
|
|
69
|
+
|
|
70
|
+
return output.split('\n')
|
|
71
|
+
.filter(f => GENERATED_FILE_PATTERN.test(f))
|
|
72
|
+
.map(f => join(projectRoot, f))
|
|
73
|
+
} catch {
|
|
74
|
+
return null // Not a git repo or git not available
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get ALL matching files from migration directories
|
|
80
|
+
*/
|
|
81
|
+
function getAllFiles(config, projectRoot) {
|
|
82
|
+
const migrationPaths = [
|
|
83
|
+
...(config.paths?.migrations || ['supabase/migrations', 'migrations']),
|
|
84
|
+
'backend/supabase/migrations'
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const sqlFiles = []
|
|
88
|
+
for (const relPath of migrationPaths) {
|
|
89
|
+
const dir = join(projectRoot, relPath)
|
|
90
|
+
if (!existsSync(dir)) continue
|
|
91
|
+
try {
|
|
92
|
+
const files = readdirSync(dir)
|
|
93
|
+
.filter(f => GENERATED_FILE_PATTERN.test(f))
|
|
94
|
+
.sort()
|
|
95
|
+
for (const f of files) {
|
|
96
|
+
sqlFiles.push(join(dir, f))
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// De-duplicate: keep only latest per function name
|
|
104
|
+
const latestByFunction = new Map()
|
|
105
|
+
for (const filePath of sqlFiles) {
|
|
106
|
+
const fileName = filePath.split('/').pop()
|
|
107
|
+
const funcMatch = fileName.match(/\d+_(get_\w+(?:_results|_counts))\.sql$/)
|
|
108
|
+
if (!funcMatch) continue
|
|
109
|
+
latestByFunction.set(funcMatch[1], filePath)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return [...latestByFunction.values()]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check a single file for the generator header
|
|
117
|
+
*/
|
|
118
|
+
function checkFile(filePath, projectRoot) {
|
|
119
|
+
const relPath = relative(projectRoot, filePath)
|
|
120
|
+
|
|
121
|
+
let content
|
|
122
|
+
try {
|
|
123
|
+
content = readFileSync(filePath, 'utf-8').substring(0, 500)
|
|
124
|
+
} catch {
|
|
125
|
+
return null // Can't read
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hasCurrentHeader = content.includes(GENERATOR_HEADER)
|
|
129
|
+
const hasLegacyHeader = LEGACY_HEADERS.some(h => content.includes(h))
|
|
130
|
+
|
|
131
|
+
if (hasCurrentHeader || hasLegacyHeader) {
|
|
132
|
+
return { ok: true, file: relPath }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
file: relPath,
|
|
138
|
+
finding: {
|
|
139
|
+
file: relPath,
|
|
140
|
+
line: 1,
|
|
141
|
+
type: 'Hand-written RPC function',
|
|
142
|
+
severity: 'critical',
|
|
143
|
+
message: `${relPath} — Missing "-- Generator: SQL Generator" header. ` +
|
|
144
|
+
`This file appears to be hand-written. ` +
|
|
145
|
+
`RPC filter/count functions MUST be generated via: npm run generate:rpc <feature>. ` +
|
|
146
|
+
`Fix the feature config instead, then regenerate.`,
|
|
147
|
+
fix: [
|
|
148
|
+
'1. Find the feature config: backend/src/features/<feature>/config/<feature>.config.ts',
|
|
149
|
+
'2. Fix the config (accessLevel, customWhereClause, etc.)',
|
|
150
|
+
'3. Regenerate: cd backend && npm run generate:rpc <feature>',
|
|
151
|
+
'4. NEVER edit SQL files directly — the generator will overwrite your changes'
|
|
152
|
+
].join('\n')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function run(config, projectRoot) {
|
|
158
|
+
const checkAll = config.rpcGeneratorOrigin?.checkAll === true
|
|
159
|
+
const results = {
|
|
160
|
+
passed: true,
|
|
161
|
+
findings: [],
|
|
162
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
163
|
+
details: {
|
|
164
|
+
mode: checkAll ? 'full' : 'staged',
|
|
165
|
+
filesChecked: 0,
|
|
166
|
+
filesWithHeader: 0,
|
|
167
|
+
filesWithoutHeader: 0
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let filesToCheck
|
|
172
|
+
|
|
173
|
+
if (checkAll) {
|
|
174
|
+
// Full audit mode: check all files
|
|
175
|
+
filesToCheck = getAllFiles(config, projectRoot)
|
|
176
|
+
} else {
|
|
177
|
+
// Pre-commit mode: only staged files
|
|
178
|
+
const stagedFiles = getStagedFiles(projectRoot)
|
|
179
|
+
|
|
180
|
+
if (stagedFiles === null) {
|
|
181
|
+
// Not in a git repo — fall back to all files
|
|
182
|
+
filesToCheck = getAllFiles(config, projectRoot)
|
|
183
|
+
results.details.mode = 'full (no git)'
|
|
184
|
+
} else if (stagedFiles.length === 0) {
|
|
185
|
+
// No staged RPC files — nothing to check
|
|
186
|
+
results.details.mode = 'staged (no matching files)'
|
|
187
|
+
return results
|
|
188
|
+
} else {
|
|
189
|
+
filesToCheck = stagedFiles
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (filesToCheck.length === 0) {
|
|
194
|
+
results.skipped = true
|
|
195
|
+
results.skipReason = 'No get_*_results/counts SQL files found'
|
|
196
|
+
return results
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const filePath of filesToCheck) {
|
|
200
|
+
const result = checkFile(filePath, projectRoot)
|
|
201
|
+
if (!result) continue
|
|
202
|
+
|
|
203
|
+
results.details.filesChecked++
|
|
204
|
+
|
|
205
|
+
if (result.ok) {
|
|
206
|
+
results.details.filesWithHeader++
|
|
207
|
+
} else {
|
|
208
|
+
results.details.filesWithoutHeader++
|
|
209
|
+
results.passed = false
|
|
210
|
+
results.findings.push(result.finding)
|
|
211
|
+
results.summary.critical++
|
|
212
|
+
results.summary.total++
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return results
|
|
217
|
+
}
|
package/lib/runner.js
CHANGED
|
@@ -18,6 +18,7 @@ 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
20
|
import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
|
|
21
|
+
import * as rpcGeneratorOrigin from './checks/supabase/rpc-generator-origin.js'
|
|
21
22
|
import * as fileOrganization from './checks/hygiene/file-organization.js'
|
|
22
23
|
|
|
23
24
|
// Register all checks
|
|
@@ -39,7 +40,8 @@ const ALL_CHECKS = {
|
|
|
39
40
|
],
|
|
40
41
|
supabase: [
|
|
41
42
|
rlsPolicyAudit,
|
|
42
|
-
rpcParamMismatch
|
|
43
|
+
rpcParamMismatch,
|
|
44
|
+
rpcGeneratorOrigin
|
|
43
45
|
],
|
|
44
46
|
hygiene: [
|
|
45
47
|
fileOrganization
|