@soulbatical/tetra-dev-toolkit 1.5.1 → 1.6.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.
@@ -0,0 +1,623 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Tetra Dev Toolkit - Project Init CLI
5
+ *
6
+ * Initializes a Tetra project with all required config files:
7
+ * - .ralph/ directory (ports.json, INFRASTRUCTURE.yml, MARKETING.yml, config.sh, status.json)
8
+ * - .tetra-quality.json
9
+ * - Verifies doppler.yaml and CLAUDE.md existence
10
+ *
11
+ * Usage:
12
+ * tetra-init # Interactive full init
13
+ * tetra-init ralph # Only .ralph/ config files
14
+ * tetra-init check # Verify project completeness (no changes)
15
+ * tetra-init --name myproject # Non-interactive with project name
16
+ */
17
+
18
+ import { program } from 'commander'
19
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
20
+ import { join, basename } from 'path'
21
+ import { createInterface } from 'readline'
22
+
23
+ const projectRoot = process.cwd()
24
+
25
+ // ─── Helpers ────────────────────────────────────────────────────
26
+
27
+ function ask(question, defaultValue) {
28
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
29
+ const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `
30
+ return new Promise((resolve) => {
31
+ rl.question(prompt, (answer) => {
32
+ rl.close()
33
+ resolve(answer.trim() || defaultValue || '')
34
+ })
35
+ })
36
+ }
37
+
38
+ function writeIfMissing(filePath, content, options = {}) {
39
+ const relativePath = filePath.replace(projectRoot + '/', '')
40
+ if (existsSync(filePath) && !options.force) {
41
+ console.log(` ⏭️ ${relativePath} already exists`)
42
+ return false
43
+ }
44
+ const dir = filePath.substring(0, filePath.lastIndexOf('/'))
45
+ if (!existsSync(dir)) {
46
+ mkdirSync(dir, { recursive: true })
47
+ }
48
+ writeFileSync(filePath, content)
49
+ console.log(` ✅ Created ${relativePath}`)
50
+ return true
51
+ }
52
+
53
+ function detectProjectName() {
54
+ const packagePath = join(projectRoot, 'package.json')
55
+ if (existsSync(packagePath)) {
56
+ try {
57
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
58
+ return pkg.name?.replace(/^@[^/]+\//, '') || basename(projectRoot)
59
+ } catch { /* ignore */ }
60
+ }
61
+ return basename(projectRoot)
62
+ }
63
+
64
+ function detectPorts() {
65
+ // Try to find ports from doppler.yaml or package.json scripts
66
+ const packagePath = join(projectRoot, 'package.json')
67
+ if (existsSync(packagePath)) {
68
+ try {
69
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
70
+ const scripts = pkg.scripts || {}
71
+ const allScripts = Object.values(scripts).join(' ')
72
+
73
+ // Try to extract backend port from scripts
74
+ const backendPortMatch = allScripts.match(/PORT[=:](\d+)/) ||
75
+ allScripts.match(/-p\s+(\d+).*backend/) ||
76
+ allScripts.match(/backend.*-p\s+(\d+)/)
77
+
78
+ // Try to extract frontend port from scripts
79
+ const frontendPortMatch = allScripts.match(/next\s+dev\s+-p\s+(\d+)/) ||
80
+ allScripts.match(/-p\s+(\d+)/)
81
+
82
+ return {
83
+ backend: backendPortMatch ? parseInt(backendPortMatch[1]) : null,
84
+ frontend: frontendPortMatch ? parseInt(frontendPortMatch[1]) : null
85
+ }
86
+ } catch { /* ignore */ }
87
+ }
88
+ return { backend: null, frontend: null }
89
+ }
90
+
91
+ function detectSupabaseProjectId() {
92
+ // Check doppler.yaml or existing env references
93
+ const dopplerPath = join(projectRoot, 'doppler.yaml')
94
+ if (existsSync(dopplerPath)) {
95
+ return 'check-doppler' // User needs to fill in from Doppler
96
+ }
97
+ return null
98
+ }
99
+
100
+ function hasWorkspace(name) {
101
+ return existsSync(join(projectRoot, name))
102
+ }
103
+
104
+ // ─── Templates ──────────────────────────────────────────────────
105
+
106
+ function generateInfrastructureYml(config) {
107
+ return `# ${config.name} Infrastructure Configuration
108
+ # Single source of truth for all external services and infrastructure
109
+ # Machine-readable by ralph-manager health checks
110
+
111
+ project:
112
+ name: ${config.name}
113
+ phase: development
114
+ description: "${config.description}"
115
+ repo: null
116
+
117
+ hosting:
118
+ frontend:
119
+ type: null
120
+ site_name: null
121
+ url: null
122
+ branch: main
123
+ backend:
124
+ type: null
125
+ service_name: null
126
+ url: null
127
+ branch: main
128
+
129
+ domains:
130
+ registrar: null
131
+ primary: null
132
+ aliases: []
133
+ dns_provider: null
134
+ ssl: auto
135
+
136
+ database:
137
+ provider: supabase
138
+ region: eu-central-1
139
+ project_id: null
140
+
141
+ secrets:
142
+ manager: doppler
143
+ configs: []
144
+
145
+ email:
146
+ provider: null
147
+ type: null
148
+ addresses: []
149
+
150
+ monitoring:
151
+ uptime: null
152
+ error_tracking: null
153
+ analytics: null
154
+
155
+ services:
156
+ payment: null
157
+ email_delivery: null
158
+ cdn: null
159
+
160
+ security:
161
+ rls_enabled: true
162
+ auth_provider: supabase
163
+ rate_limiting: true
164
+ repo_visibility: private
165
+ open_issues: []
166
+
167
+ meta:
168
+ created_at: "${new Date().toISOString().split('T')[0]}"
169
+ updated_by: tetra-init
170
+ version: 1
171
+ `
172
+ }
173
+
174
+ function generateMarketingYml(config) {
175
+ return `# ${config.name} Marketing Stack Configuration
176
+ # Single source of truth for marketing infrastructure status
177
+ # Machine-readable by ralph-manager
178
+
179
+ project: ${config.name}
180
+ updated_at: "${new Date().toISOString().split('T')[0]}"
181
+ updated_by: tetra-init
182
+
183
+ # ─── Meta (Facebook/Instagram) ───────────────────────────────
184
+
185
+ meta:
186
+ business_portfolio: null
187
+ business_portfolio_id: null
188
+ ad_account:
189
+ name: null
190
+ id: null
191
+ status: not_configured
192
+ currency: EUR
193
+ pixel:
194
+ name: null
195
+ id: null
196
+ status: not_configured
197
+ consent_mode: true
198
+ events: []
199
+ pages: []
200
+
201
+ # ─── Google ──────────────────────────────────────────────────
202
+
203
+ google:
204
+ analytics:
205
+ measurement_id: null
206
+ stream_id: null
207
+ status: not_configured
208
+ consent_mode: true
209
+ ads:
210
+ customer_id: null
211
+ status: not_configured
212
+ search_console:
213
+ verified: false
214
+ status: not_configured
215
+ tag_manager:
216
+ container_id: null
217
+ status: not_configured
218
+
219
+ # ─── Consent & Privacy ──────────────────────────────────────
220
+
221
+ consent:
222
+ platform: null
223
+ cookie_banner: false
224
+ privacy_policy: false
225
+ terms_of_service: false
226
+ gdpr_compliant: false
227
+
228
+ # ─── SEO ────────────────────────────────────────────────────
229
+
230
+ seo:
231
+ sitemap: false
232
+ robots_txt: false
233
+ structured_data: false
234
+ canonical_urls: false
235
+ meta_tags: false
236
+
237
+ # ─── Email Marketing ────────────────────────────────────────
238
+
239
+ email_marketing:
240
+ provider: null
241
+ list_id: null
242
+ status: not_configured
243
+
244
+ meta_info:
245
+ created_at: "${new Date().toISOString().split('T')[0]}"
246
+ updated_by: tetra-init
247
+ version: 1
248
+ `
249
+ }
250
+
251
+ function generatePortsJson(config) {
252
+ const obj = {
253
+ backend_port: config.backendPort,
254
+ frontend_port: config.frontendPort,
255
+ api_url: `http://localhost:${config.backendPort}`,
256
+ synced_at: new Date().toISOString(),
257
+ source: 'tetra-init'
258
+ }
259
+ return JSON.stringify(obj, null, 2) + '\n'
260
+ }
261
+
262
+ function generateConfigSh(config) {
263
+ return `# ${config.name} project config overrides for Ralph
264
+ # Allowed tools for autonomous Ralph sessions
265
+ CLAUDE_ALLOWED_TOOLS="Write,Edit,Read,Bash,Glob,Grep,WebFetch,WebSearch"
266
+ `
267
+ }
268
+
269
+ function generateStatusJson() {
270
+ return JSON.stringify({
271
+ last_session: null,
272
+ last_task: null,
273
+ updated_at: new Date().toISOString()
274
+ }, null, 2) + '\n'
275
+ }
276
+
277
+ function generateFixPlan(config) {
278
+ return `# ${config.name} — Fix Plan
279
+
280
+ ## Active Tasks
281
+
282
+ - [ ] Project initialization — verify all config files are correct
283
+ - [ ] First feature implementation
284
+
285
+ ## Completed
286
+
287
+ (none yet)
288
+ `
289
+ }
290
+
291
+ function generatePromptMd(config) {
292
+ return `# Ralph Development Instructions - ${config.name}
293
+
294
+ ## Context
295
+ You are Ralph, an AUTONOMOUS AI development agent working on **${config.name}**.
296
+
297
+ ## KRITIEKE REGEL: NOOIT VRAGEN, ALTIJD DOEN
298
+
299
+ \`\`\`
300
+ VERBODEN:
301
+ - "Wat wil je als eerste oppakken?"
302
+ - "Waar wil je mee beginnen?"
303
+ - "Wil je dat ik X doe?"
304
+ - Elke vraag aan de gebruiker over wat je moet doen
305
+
306
+ VERPLICHT:
307
+ - Kies ZELF de eerste onafgeronde taak uit @fix_plan.md
308
+ - Voer die taak UIT in dezelfde loop
309
+ - Vraag alleen om hulp als iets technisch ONMOGELIJK is
310
+ \`\`\`
311
+
312
+ ## Sessie Opstart (loop 1)
313
+
314
+ 1. \`Read .ralph/@fix_plan.md\` — vind eerste \`- [ ]\` taak
315
+ 2. **BEGIN DIRECT met die taak** — niet samenvatten, niet vragen, DOEN
316
+ 3. Na afronding: vink af met [x], ga naar volgende taak
317
+
318
+ ## Na elke taak
319
+
320
+ 1. Vink de taak af in @fix_plan.md
321
+ 2. Ga naar de volgende \`- [ ]\` taak
322
+ 3. Als alles af is: schrijf samenvatting en stop
323
+ `
324
+ }
325
+
326
+ function generateProjectJson(config) {
327
+ return JSON.stringify({
328
+ ralph_id: null,
329
+ name: config.name,
330
+ created_at: new Date().toISOString()
331
+ }, null, 2) + '\n'
332
+ }
333
+
334
+ function generateTestConfigYml(config) {
335
+ return `# ${config.name} Test Configuration
336
+ # Used by Ralph for automated testing
337
+
338
+ test_suites:
339
+ unit:
340
+ command: "npm test"
341
+ timeout: 120
342
+ typecheck:
343
+ command: "npx tsc --noEmit"
344
+ timeout: 60
345
+ e2e:
346
+ command: "npx playwright test"
347
+ timeout: 300
348
+ enabled: false
349
+
350
+ health_checks:
351
+ backend:
352
+ url: "http://localhost:${config.backendPort}/api/health"
353
+ timeout: 5
354
+ frontend:
355
+ url: "http://localhost:${config.frontendPort}"
356
+ timeout: 10
357
+ `
358
+ }
359
+
360
+ function generateTetraQualityJson() {
361
+ return JSON.stringify({
362
+ "$schema": "https://tetra-tools.dev/schemas/quality-toolkit.json",
363
+ "suites": {
364
+ "security": true,
365
+ "stability": true,
366
+ "codeQuality": true,
367
+ "supabase": "auto",
368
+ "hygiene": true
369
+ },
370
+ "security": {
371
+ "checkHardcodedSecrets": true,
372
+ "checkServiceKeyExposure": true
373
+ },
374
+ "stability": {
375
+ "requireHusky": true,
376
+ "requireCiConfig": true,
377
+ "allowedVulnerabilities": {
378
+ "critical": 0,
379
+ "high": 0,
380
+ "moderate": 10
381
+ }
382
+ },
383
+ "ignore": [
384
+ "node_modules/**",
385
+ "dist/**",
386
+ "build/**",
387
+ ".next/**"
388
+ ]
389
+ }, null, 2) + '\n'
390
+ }
391
+
392
+ // ─── Commands ───────────────────────────────────────────────────
393
+
394
+ async function initRalph(config, options) {
395
+ console.log('')
396
+ console.log('📁 Initializing .ralph/ directory...')
397
+
398
+ const ralphDir = join(projectRoot, '.ralph')
399
+ if (!existsSync(ralphDir)) {
400
+ mkdirSync(ralphDir, { recursive: true })
401
+ console.log(' ✅ Created .ralph/')
402
+ }
403
+
404
+ // Specs directory
405
+ const specsDir = join(ralphDir, 'specs')
406
+ if (!existsSync(specsDir)) {
407
+ mkdirSync(specsDir, { recursive: true })
408
+ console.log(' ✅ Created .ralph/specs/')
409
+ }
410
+
411
+ writeIfMissing(join(ralphDir, 'ports.json'), generatePortsJson(config), options)
412
+ writeIfMissing(join(ralphDir, 'INFRASTRUCTURE.yml'), generateInfrastructureYml(config), options)
413
+ writeIfMissing(join(ralphDir, 'MARKETING.yml'), generateMarketingYml(config), options)
414
+ writeIfMissing(join(ralphDir, 'config.sh'), generateConfigSh(config), options)
415
+ writeIfMissing(join(ralphDir, 'status.json'), generateStatusJson(), options)
416
+ writeIfMissing(join(ralphDir, '@fix_plan.md'), generateFixPlan(config), options)
417
+ writeIfMissing(join(ralphDir, 'PROMPT.md'), generatePromptMd(config), options)
418
+ writeIfMissing(join(ralphDir, 'project.json'), generateProjectJson(config), options)
419
+ writeIfMissing(join(ralphDir, 'test-config.yml'), generateTestConfigYml(config), options)
420
+ }
421
+
422
+ async function initQuality(config, options) {
423
+ console.log('')
424
+ console.log('🔍 Initializing quality config...')
425
+
426
+ writeIfMissing(
427
+ join(projectRoot, '.tetra-quality.json'),
428
+ generateTetraQualityJson(),
429
+ options
430
+ )
431
+ }
432
+
433
+ function checkCompleteness() {
434
+ console.log('')
435
+ console.log('🔎 Checking project completeness...')
436
+ console.log('═'.repeat(50))
437
+
438
+ const checks = [
439
+ // Root files
440
+ { path: 'package.json', category: 'root', required: true },
441
+ { path: 'doppler.yaml', category: 'root', required: true },
442
+ { path: 'CLAUDE.md', category: 'root', required: true },
443
+ { path: '.tetra-quality.json', category: 'root', required: false },
444
+ { path: '.gitignore', category: 'root', required: true },
445
+
446
+ // .ralph/ files
447
+ { path: '.ralph/ports.json', category: 'ralph', required: true },
448
+ { path: '.ralph/INFRASTRUCTURE.yml', category: 'ralph', required: true },
449
+ { path: '.ralph/MARKETING.yml', category: 'ralph', required: false },
450
+ { path: '.ralph/config.sh', category: 'ralph', required: false },
451
+ { path: '.ralph/status.json', category: 'ralph', required: false },
452
+ { path: '.ralph/@fix_plan.md', category: 'ralph', required: true },
453
+ { path: '.ralph/PROMPT.md', category: 'ralph', required: true },
454
+ { path: '.ralph/project.json', category: 'ralph', required: true },
455
+ { path: '.ralph/test-config.yml', category: 'ralph', required: false },
456
+ { path: '.ralph/specs', category: 'ralph', required: false },
457
+
458
+ // Backend
459
+ { path: 'backend/package.json', category: 'backend', required: true },
460
+ { path: 'backend/tsconfig.json', category: 'backend', required: true },
461
+ { path: 'backend/src/index.ts', category: 'backend', required: true },
462
+ { path: 'backend/src/auth-config.ts', category: 'backend', required: true },
463
+ { path: 'backend/src/core/Application.ts', category: 'backend', required: true },
464
+ { path: 'backend/src/core/RouteManager.ts', category: 'backend', required: true },
465
+
466
+ // Frontend
467
+ { path: 'frontend/package.json', category: 'frontend', required: true },
468
+ { path: 'frontend/next.config.ts', category: 'frontend', required: true },
469
+ { path: 'frontend/src/app/layout.tsx', category: 'frontend', required: true },
470
+
471
+ // Database
472
+ { path: 'supabase/migrations', category: 'database', required: false },
473
+ ]
474
+
475
+ let currentCategory = ''
476
+ let passed = 0
477
+ let failed = 0
478
+ let warnings = 0
479
+
480
+ for (const check of checks) {
481
+ if (check.category !== currentCategory) {
482
+ currentCategory = check.category
483
+ console.log('')
484
+ console.log(` ${currentCategory.toUpperCase()}`)
485
+ }
486
+
487
+ const fullPath = join(projectRoot, check.path)
488
+ const exists = existsSync(fullPath)
489
+ const icon = exists ? '✅' : (check.required ? '❌' : '⚠️')
490
+
491
+ if (exists) {
492
+ passed++
493
+ } else if (check.required) {
494
+ failed++
495
+ } else {
496
+ warnings++
497
+ }
498
+
499
+ console.log(` ${icon} ${check.path}`)
500
+ }
501
+
502
+ console.log('')
503
+ console.log('═'.repeat(50))
504
+ console.log(` ✅ ${passed} present ❌ ${failed} missing (required) ⚠️ ${warnings} missing (optional)`)
505
+
506
+ if (failed > 0) {
507
+ console.log('')
508
+ console.log(' Run `tetra-init` to generate missing files.')
509
+ } else if (warnings > 0) {
510
+ console.log('')
511
+ console.log(' All required files present! Run `tetra-init` to generate optional files.')
512
+ } else {
513
+ console.log('')
514
+ console.log(' 🎉 Project is fully initialized!')
515
+ }
516
+
517
+ console.log('')
518
+ return failed === 0
519
+ }
520
+
521
+ // ─── Main ───────────────────────────────────────────────────────
522
+
523
+ program
524
+ .name('tetra-init')
525
+ .description('Initialize a Tetra project with all required config files')
526
+ .version('1.0.0')
527
+ .argument('[component]', 'Component to init: ralph, quality, check, or all (default)')
528
+ .option('-n, --name <name>', 'Project name (skips interactive prompt)')
529
+ .option('-d, --description <desc>', 'Project description')
530
+ .option('--backend-port <port>', 'Backend port number', parseInt)
531
+ .option('--frontend-port <port>', 'Frontend port number', parseInt)
532
+ .option('-f, --force', 'Overwrite existing files')
533
+ .option('-y, --yes', 'Accept all defaults (non-interactive)')
534
+ .action(async (component, options) => {
535
+ console.log('')
536
+ console.log('🚀 Tetra Project Init')
537
+ console.log('═'.repeat(50))
538
+
539
+ // Check-only mode
540
+ if (component === 'check') {
541
+ const ok = checkCompleteness()
542
+ process.exit(ok ? 0 : 1)
543
+ }
544
+
545
+ // Gather config
546
+ const detectedName = detectProjectName()
547
+ const detectedPorts = detectPorts()
548
+ const isInteractive = !options.yes && process.stdin.isTTY
549
+
550
+ let config = {
551
+ name: options.name || detectedName,
552
+ description: options.description || '',
553
+ backendPort: options.backendPort || detectedPorts.backend,
554
+ frontendPort: options.frontendPort || detectedPorts.frontend,
555
+ }
556
+
557
+ if (isInteractive) {
558
+ console.log('')
559
+ config.name = await ask('Project name', config.name)
560
+ config.description = await ask('Description', config.description || `${config.name} platform`)
561
+ config.backendPort = parseInt(await ask('Backend port', String(config.backendPort || 3000))) || 3000
562
+ config.frontendPort = parseInt(await ask('Frontend port', String(config.frontendPort || 3001))) || 3001
563
+ } else {
564
+ // Non-interactive defaults
565
+ config.description = config.description || `${config.name} platform`
566
+ config.backendPort = config.backendPort || 3000
567
+ config.frontendPort = config.frontendPort || 3001
568
+ }
569
+
570
+ console.log('')
571
+ console.log(` Project: ${config.name}`)
572
+ console.log(` Backend: localhost:${config.backendPort}`)
573
+ console.log(` Frontend: localhost:${config.frontendPort}`)
574
+
575
+ const components = component === 'all' || !component
576
+ ? ['ralph', 'quality']
577
+ : [component]
578
+
579
+ for (const comp of components) {
580
+ switch (comp) {
581
+ case 'ralph':
582
+ await initRalph(config, options)
583
+ break
584
+ case 'quality':
585
+ await initQuality(config, options)
586
+ break
587
+ default:
588
+ console.log(`Unknown component: ${comp}`)
589
+ console.log('Available: ralph, quality, check, or all (default)')
590
+ }
591
+ }
592
+
593
+ // Verify essential root files
594
+ console.log('')
595
+ console.log('📋 Checking essential root files...')
596
+
597
+ const essentials = [
598
+ { path: 'doppler.yaml', hint: 'Create doppler.yaml with your Doppler project config' },
599
+ { path: 'CLAUDE.md', hint: 'Create CLAUDE.md with project instructions for Claude Code' },
600
+ { path: '.gitignore', hint: 'Create .gitignore (node_modules, .next, dist, etc.)' },
601
+ ]
602
+
603
+ for (const item of essentials) {
604
+ const fullPath = join(projectRoot, item.path)
605
+ if (existsSync(fullPath)) {
606
+ console.log(` ✅ ${item.path}`)
607
+ } else {
608
+ console.log(` ⚠️ ${item.path} — ${item.hint}`)
609
+ }
610
+ }
611
+
612
+ console.log('')
613
+ console.log('✅ Init complete!')
614
+ console.log('')
615
+ console.log('Next steps:')
616
+ console.log(' 1. Review generated files in .ralph/')
617
+ console.log(' 2. Fill in INFRASTRUCTURE.yml with hosting/domain info')
618
+ console.log(' 3. Run `tetra-init check` to verify completeness')
619
+ console.log(' 4. Run `tetra-setup` to add quality hooks')
620
+ console.log('')
621
+ })
622
+
623
+ program.parse()
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
@@ -26,6 +26,7 @@
26
26
  "main": "lib/index.js",
27
27
  "bin": {
28
28
  "tetra-audit": "./bin/tetra-audit.js",
29
+ "tetra-init": "./bin/tetra-init.js",
29
30
  "tetra-setup": "./bin/tetra-setup.js",
30
31
  "tetra-dev-token": "./bin/tetra-dev-token.js"
31
32
  },