@soulbatical/tetra-dev-toolkit 1.20.25 → 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.
- package/bin/tetra-init-smoke.js +237 -0
- package/bin/tetra-style-audit.js +0 -0
- package/lib/checks/codeQuality/env-vars-defined.js +491 -0
- package/lib/checks/codeQuality/next-route-coverage.js +337 -0
- package/lib/checks/codeQuality/tetra-ui-ssr-safe-imports.js +335 -0
- package/lib/checks/index.js +3 -0
- package/package.json +3 -2
- package/templates/playwright-smoke.spec.ts.template +188 -0
|
@@ -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
|
+
}
|