@soulbatical/tetra-dev-toolkit 1.20.24 → 1.20.26

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,491 @@
1
+ /**
2
+ * Code Quality Check: Env Vars Defined in Doppler
3
+ *
4
+ * Scans source files for process.env.X and import.meta.env.X references
5
+ * and verifies they are defined in the appropriate Doppler config(s).
6
+ *
7
+ * WHAT IT CATCHES:
8
+ * - FAIL: process.env.X || "" (empty-string fallback) and X is missing in Doppler
9
+ * → silently produces wrong API URLs, missing tokens, empty base URLs
10
+ * - FAIL: process.env.X ?? "" (nullish-coalescing empty fallback) and X missing
11
+ * - WARN: process.env.X || 'non-empty-default' and X missing in Doppler
12
+ * → sometimes intentional, sometimes a forgotten secret
13
+ * - WARN: process.env.X (no fallback) and X missing
14
+ *
15
+ * SKIP:
16
+ * - NODE_ENV, PORT — always set by runtime
17
+ * - Vars already validated via tetra-core createApp({ requiredEnvVars: [...] })
18
+ * - If Doppler CLI is not installed or not authenticated
19
+ *
20
+ * HOW IT WORKS:
21
+ * 1. Reads doppler.yaml from projectRoot to discover the Doppler project name.
22
+ * 2. Scans TS/TSX/JS/JSX source files for env var references.
23
+ * 3. Maps each file path to a Doppler config (dev_backend, dev_frontend, etc.)
24
+ * 4. Calls `doppler secrets --json` once per config and caches results.
25
+ * 5. Reports missing vars with suggested fix commands.
26
+ *
27
+ * Severity: high — empty-fallback missing vars cause silent production failures
28
+ */
29
+
30
+ import { readFileSync, existsSync } from 'fs'
31
+ import { join, relative } from 'path'
32
+ import { glob } from 'glob'
33
+ import { execFileSync } from 'child_process'
34
+
35
+ // ============================================================================
36
+ // META
37
+ // ============================================================================
38
+
39
+ export const meta = {
40
+ id: 'env-vars-defined',
41
+ name: 'Env vars defined in Doppler',
42
+ category: 'codeQuality',
43
+ severity: 'high',
44
+ description: 'Verifies that env vars referenced in source files are defined in the matching Doppler config'
45
+ }
46
+
47
+ // ============================================================================
48
+ // CONSTANTS
49
+ // ============================================================================
50
+
51
+ const IGNORE_PATTERNS = [
52
+ '**/node_modules/**',
53
+ '**/.next/**',
54
+ '**/dist/**',
55
+ '**/build/**',
56
+ '**/.netlify/**',
57
+ '**/coverage/**',
58
+ ]
59
+
60
+ /**
61
+ * Vars that are always present — set by runtime, Next.js, Express, or Node itself.
62
+ * Never flag these as missing.
63
+ */
64
+ const BUILTIN_VARS = new Set([
65
+ 'NODE_ENV',
66
+ 'PORT',
67
+ 'HOST',
68
+ 'HOSTNAME',
69
+ 'PWD',
70
+ 'HOME',
71
+ 'PATH',
72
+ 'USER',
73
+ 'SHELL',
74
+ 'TERM',
75
+ 'CI',
76
+ 'TZ',
77
+ 'LANG',
78
+ 'npm_lifecycle_event',
79
+ 'npm_package_version',
80
+ 'VERCEL',
81
+ 'VERCEL_ENV',
82
+ 'VERCEL_URL',
83
+ 'NEXT_RUNTIME',
84
+ 'NEXT_PHASE',
85
+ ])
86
+
87
+ // ============================================================================
88
+ // DOPPLER CONFIG MAPPING
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Map a source file path (relative to projectRoot) to Doppler config name(s).
93
+ * Returns an array because some files may be relevant to multiple configs
94
+ * (e.g. a shared util used by both backend and frontend).
95
+ *
96
+ * Convention:
97
+ * frontend/** → dev_frontend (+ prd_frontend if it exists)
98
+ * src/app/** → dev_frontend (Next.js app router)
99
+ * backend/** → dev_backend (+ prd_backend if it exists)
100
+ * backend-mcp/** → dev_backend
101
+ * bot/** → dev_bot
102
+ * (other) → dev_backend (safe default)
103
+ */
104
+ function getConfigsForFile(relPath) {
105
+ const p = relPath.replace(/\\/g, '/')
106
+
107
+ if (p.startsWith('frontend/') || p.startsWith('src/app/') || p.startsWith('src/pages/')) {
108
+ return ['dev_frontend', 'prd_frontend']
109
+ }
110
+ if (p.startsWith('bot/')) {
111
+ return ['dev_bot']
112
+ }
113
+ if (p.startsWith('backend-mcp/') || p.startsWith('backend/')) {
114
+ return ['dev_backend', 'prd_backend']
115
+ }
116
+
117
+ // Top-level src/ could be Next.js root — treat as frontend
118
+ if (p.startsWith('src/')) {
119
+ return ['dev_frontend', 'prd_frontend']
120
+ }
121
+
122
+ // Default fallback
123
+ return ['dev_backend']
124
+ }
125
+
126
+ // ============================================================================
127
+ // DOPPLER HELPERS
128
+ // ============================================================================
129
+
130
+ /**
131
+ * Read doppler.yaml from the project root.
132
+ * Returns the first `project:` value found, or null.
133
+ *
134
+ * doppler.yaml format:
135
+ * setup:
136
+ * - project: my-project
137
+ * config: dev_backend
138
+ * path: backend/
139
+ */
140
+ function readDopplerProject(projectRoot) {
141
+ const dopplerYamlPath = join(projectRoot, 'doppler.yaml')
142
+ if (!existsSync(dopplerYamlPath)) return null
143
+
144
+ try {
145
+ const content = readFileSync(dopplerYamlPath, 'utf-8')
146
+ const m = content.match(/project:\s*(\S+)/)
147
+ return m ? m[1] : null
148
+ } catch {
149
+ return null
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Test whether the `doppler` CLI is installed and authenticated.
155
+ * Uses execFileSync with argument array — no shell injection risk.
156
+ * Returns { ok: boolean, reason?: string }
157
+ */
158
+ function checkDopplerAuth() {
159
+ try {
160
+ execFileSync('doppler', ['--version'], { stdio: 'ignore', timeout: 5000 })
161
+ } catch {
162
+ return { ok: false, reason: 'doppler CLI not found — install it from https://docs.doppler.com/docs/install-cli' }
163
+ }
164
+
165
+ try {
166
+ execFileSync('doppler', ['me'], { stdio: 'ignore', timeout: 5000 })
167
+ } catch {
168
+ return { ok: false, reason: 'doppler CLI not authenticated — run `doppler login`' }
169
+ }
170
+
171
+ return { ok: true }
172
+ }
173
+
174
+ /**
175
+ * Fetch the set of secret names defined in a Doppler config.
176
+ * Returns null if the config does not exist or the call fails.
177
+ *
178
+ * Cache is per (project, config) within a single audit run.
179
+ * Uses execFileSync with argument array — no shell injection risk.
180
+ */
181
+ const _dopplerCache = new Map()
182
+
183
+ function fetchDopplerSecretNames(dopplerProject, configName) {
184
+ const key = `${dopplerProject}::${configName}`
185
+ if (_dopplerCache.has(key)) return _dopplerCache.get(key)
186
+
187
+ try {
188
+ const raw = execFileSync(
189
+ 'doppler',
190
+ ['secrets', '--project', dopplerProject, '--config', configName, '--json'],
191
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }
192
+ )
193
+ const parsed = JSON.parse(raw.toString())
194
+ const names = new Set(Object.keys(parsed))
195
+ _dopplerCache.set(key, names)
196
+ return names
197
+ } catch {
198
+ // Config may not exist (e.g. prd_frontend in a project that only has dev_frontend)
199
+ _dopplerCache.set(key, null)
200
+ return null
201
+ }
202
+ }
203
+
204
+ // ============================================================================
205
+ // SOURCE SCANNING
206
+ // ============================================================================
207
+
208
+ /**
209
+ * Patterns we look for in source files.
210
+ * Each pattern captures the variable name and optionally the fallback value.
211
+ *
212
+ * Matches:
213
+ * process.env.MY_VAR
214
+ * process.env.MY_VAR || ''
215
+ * process.env.MY_VAR || ""
216
+ * process.env.MY_VAR ?? ''
217
+ * process.env.MY_VAR ?? ""
218
+ * process.env.MY_VAR || 'some-default'
219
+ * import.meta.env.MY_VAR
220
+ * import.meta.env.MY_VAR || ''
221
+ */
222
+ const ENV_RE_SOURCE = /(?:process\.env|import\.meta\.env)\.([A-Z][A-Z0-9_]*)(?:\s*(?:\|\||[?][?])\s*(['"`]([^'"`]*)['"`]))?/g
223
+
224
+ function scanFile(content) {
225
+ const findings = []
226
+ const lines = content.split('\n')
227
+
228
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
229
+ const line = lines[lineIdx]
230
+
231
+ // Skip comment lines
232
+ if (/^\s*(\/\/|\/\*|\*)/.test(line)) continue
233
+
234
+ const lineRe = new RegExp(ENV_RE_SOURCE.source, 'g')
235
+ let match
236
+
237
+ while ((match = lineRe.exec(line)) !== null) {
238
+ const varName = match[1]
239
+ const hasFallback = match[2] !== undefined
240
+ const fallbackValue = match[3] ?? null
241
+ // Empty fallback: || '' or || "" or ?? '' or ?? ""
242
+ const isEmptyFallback = hasFallback && (fallbackValue === '' || fallbackValue === null)
243
+
244
+ findings.push({
245
+ varName,
246
+ line: lineIdx + 1,
247
+ hasFallback,
248
+ isEmptyFallback,
249
+ fallbackValue,
250
+ })
251
+ }
252
+ }
253
+
254
+ return findings
255
+ }
256
+
257
+ // ============================================================================
258
+ // REQUIRED ENV VARS FROM TETRA-CORE createApp()
259
+ // ============================================================================
260
+
261
+ /**
262
+ * Scan the project for `requiredEnvVars: [...]` in createApp() calls.
263
+ * These are already validated at startup so we skip them in this check.
264
+ */
265
+ function collectRequiredEnvVars(projectRoot) {
266
+ const result = new Set()
267
+
268
+ const files = glob.sync('**/*.ts', {
269
+ cwd: projectRoot,
270
+ ignore: IGNORE_PATTERNS,
271
+ absolute: true,
272
+ })
273
+
274
+ for (const absPath of files) {
275
+ let content
276
+ try { content = readFileSync(absPath, 'utf-8') } catch { continue }
277
+
278
+ // Match: requiredEnvVars: ['A', 'B', "C"]
279
+ const blockRe = /requiredEnvVars\s*:\s*\[([^\]]+)\]/g
280
+ let m
281
+ while ((m = blockRe.exec(content)) !== null) {
282
+ const inner = m[1]
283
+ const strRe = /['"]([A-Z][A-Z0-9_]*)['"]/g
284
+ let s
285
+ while ((s = strRe.exec(inner)) !== null) {
286
+ result.add(s[1])
287
+ }
288
+ }
289
+ }
290
+
291
+ return result
292
+ }
293
+
294
+ // ============================================================================
295
+ // MAIN CHECK
296
+ // ============================================================================
297
+
298
+ export async function run(config, projectRoot) {
299
+ const result = {
300
+ passed: true,
301
+ findings: [],
302
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
303
+ details: {
304
+ filesScanned: 0,
305
+ varsChecked: 0,
306
+ missingInDoppler: 0,
307
+ emptyFallbackMissing: 0,
308
+ dopplerSkipped: false,
309
+ dopplerProject: null,
310
+ }
311
+ }
312
+
313
+ // ── Step 1: check Doppler auth ────────────────────────────────────────────
314
+
315
+ const authCheck = checkDopplerAuth()
316
+ if (!authCheck.ok) {
317
+ result.details.dopplerSkipped = true
318
+ result.findings.push({
319
+ file: 'project',
320
+ line: 0,
321
+ severity: 'low',
322
+ message: `env-vars-defined check skipped: ${authCheck.reason}`,
323
+ })
324
+ result.summary.low++
325
+ result.summary.total++
326
+ return result
327
+ }
328
+
329
+ // ── Step 2: read Doppler project name ──────────────────────────────────────
330
+
331
+ const dopplerProject = readDopplerProject(projectRoot)
332
+ if (!dopplerProject) {
333
+ result.details.dopplerSkipped = true
334
+ result.findings.push({
335
+ file: 'doppler.yaml',
336
+ line: 0,
337
+ severity: 'low',
338
+ message: 'env-vars-defined check skipped: no doppler.yaml found (or no project: field)',
339
+ })
340
+ result.summary.low++
341
+ result.summary.total++
342
+ return result
343
+ }
344
+
345
+ result.details.dopplerProject = dopplerProject
346
+
347
+ // ── Step 3: collect vars already validated by tetra-core ──────────────────
348
+
349
+ const alreadyValidated = collectRequiredEnvVars(projectRoot)
350
+
351
+ // ── Step 4: scan source files ─────────────────────────────────────────────
352
+
353
+ const sourceFiles = glob.sync('**/*.{ts,tsx,js,jsx}', {
354
+ cwd: projectRoot,
355
+ ignore: IGNORE_PATTERNS,
356
+ absolute: true,
357
+ })
358
+
359
+ result.details.filesScanned = sourceFiles.length
360
+
361
+ // Track reported (file, varName) pairs to avoid duplicate findings for same
362
+ // var referenced multiple times in same file
363
+ const reported = new Set()
364
+
365
+ for (const absPath of sourceFiles) {
366
+ let content
367
+ try { content = readFileSync(absPath, 'utf-8') } catch { continue }
368
+
369
+ const relPath = relative(projectRoot, absPath)
370
+ const usages = scanFile(content)
371
+
372
+ for (const usage of usages) {
373
+ const { varName, line, hasFallback, isEmptyFallback, fallbackValue } = usage
374
+
375
+ // Skip builtins
376
+ if (BUILTIN_VARS.has(varName)) continue
377
+
378
+ // Skip vars already validated at startup by tetra-core
379
+ if (alreadyValidated.has(varName)) continue
380
+
381
+ result.details.varsChecked++
382
+
383
+ const configs = getConfigsForFile(relPath)
384
+
385
+ // Check each applicable config — if the var is defined in ANY of them,
386
+ // it's fine (e.g. prd_frontend might not exist yet)
387
+ let foundInAny = false
388
+ const checkedConfigs = []
389
+
390
+ for (const configName of configs) {
391
+ const secrets = fetchDopplerSecretNames(dopplerProject, configName)
392
+ if (secrets === null) {
393
+ // Config doesn't exist (e.g. prd_frontend) — skip silently
394
+ continue
395
+ }
396
+ checkedConfigs.push(configName)
397
+ if (secrets.has(varName)) {
398
+ foundInAny = true
399
+ break
400
+ }
401
+ }
402
+
403
+ // If we couldn't check any config (all returned null), skip silently
404
+ if (checkedConfigs.length === 0) continue
405
+
406
+ if (foundInAny) continue
407
+
408
+ // Var is missing in all checked configs
409
+ result.details.missingInDoppler++
410
+
411
+ const dedupeKey = `${relPath}::${varName}`
412
+ const firstOccurrence = !reported.has(dedupeKey)
413
+ reported.add(dedupeKey)
414
+
415
+ const fixConfig = checkedConfigs[0] ?? configs[0]
416
+ const fixCmd = `doppler secrets set ${varName}=<value> --project ${dopplerProject} --config ${fixConfig}`
417
+
418
+ if (isEmptyFallback) {
419
+ // FAIL — empty fallback means code will silently use "" as the value
420
+ result.details.emptyFallbackMissing++
421
+ result.passed = false
422
+ result.summary.high++
423
+ result.summary.total++
424
+
425
+ result.findings.push({
426
+ file: relPath,
427
+ line,
428
+ severity: 'high',
429
+ message: [
430
+ `Missing env var with empty fallback — silent failure risk:`,
431
+ ` File: ${relPath}:${line}`,
432
+ ` Var: ${varName}`,
433
+ ` Pattern: process.env.${varName} || "" ← empty string silently used as value`,
434
+ ` Configs: ${checkedConfigs.join(', ')} (project: ${dopplerProject})`,
435
+ ``,
436
+ ` This is the silent-failure class: code runs without error but produces`,
437
+ ` wrong API URLs, empty tokens, or missing base URLs.`,
438
+ ``,
439
+ ` Fix option 1: add to Doppler:`,
440
+ ` ${fixCmd}`,
441
+ ` Fix option 2: replace with getRequiredEnv("${varName}") from @soulbatical/tetra-ui`,
442
+ ` → crashes loud at startup instead of silently using ""`,
443
+ ].join('\n'),
444
+ fix: fixCmd,
445
+ })
446
+ } else if (hasFallback && firstOccurrence) {
447
+ // WARN — non-empty fallback, possibly intentional
448
+ result.summary.medium++
449
+ result.summary.total++
450
+
451
+ result.findings.push({
452
+ file: relPath,
453
+ line,
454
+ severity: 'medium',
455
+ message: [
456
+ `Env var with non-empty fallback not found in Doppler (may be intentional):`,
457
+ ` File: ${relPath}:${line}`,
458
+ ` Var: ${varName}`,
459
+ ` Fallback: "${fallbackValue}"`,
460
+ ` Configs: ${checkedConfigs.join(', ')} (project: ${dopplerProject})`,
461
+ ``,
462
+ ` If this var must be configurable per environment, add it to Doppler:`,
463
+ ` ${fixCmd}`,
464
+ ].join('\n'),
465
+ fix: fixCmd,
466
+ })
467
+ } else if (!hasFallback && firstOccurrence) {
468
+ // WARN — no fallback, var missing
469
+ result.summary.medium++
470
+ result.summary.total++
471
+
472
+ result.findings.push({
473
+ file: relPath,
474
+ line,
475
+ severity: 'medium',
476
+ message: [
477
+ `Env var not found in Doppler:`,
478
+ ` File: ${relPath}:${line}`,
479
+ ` Var: ${varName}`,
480
+ ` Configs: ${checkedConfigs.join(', ')} (project: ${dopplerProject})`,
481
+ ``,
482
+ ` Fix: ${fixCmd}`,
483
+ ].join('\n'),
484
+ fix: fixCmd,
485
+ })
486
+ }
487
+ }
488
+ }
489
+
490
+ return result
491
+ }