configorama 0.11.0 → 1.0.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.
Files changed (78) hide show
  1. package/README.md +429 -123
  2. package/cli.js +282 -49
  3. package/index.d.ts +43 -1
  4. package/package.json +5 -1
  5. package/src/capabilities.js +59 -0
  6. package/src/capabilities.test.js +44 -0
  7. package/src/display.js +70 -7
  8. package/src/display.test.js +82 -0
  9. package/src/errors.js +73 -0
  10. package/src/index.js +91 -1
  11. package/src/main.js +159 -19
  12. package/src/parsers/esm.js +1 -16
  13. package/src/parsers/typescript.js +1 -48
  14. package/src/resolvers/valueFromCron.js +4 -25
  15. package/src/resolvers/valueFromEval.js +11 -1
  16. package/src/resolvers/valueFromFile.js +8 -1
  17. package/src/resolvers/valueFromGit.js +43 -17
  18. package/src/resolvers/valueFromOptions.js +5 -4
  19. package/src/utils/filters/filterArgs.js +57 -0
  20. package/src/utils/filters/oneOf.js +77 -0
  21. package/src/utils/introspection/audit.js +78 -0
  22. package/src/utils/introspection/graph.js +43 -0
  23. package/src/utils/introspection/model.js +150 -0
  24. package/src/utils/introspection/model.test.js +93 -0
  25. package/src/utils/parsing/commentAnnotations.js +107 -0
  26. package/src/utils/parsing/commentAnnotations.test.js +123 -0
  27. package/src/utils/parsing/enrichMetadata.js +64 -1
  28. package/src/utils/parsing/enrichMetadata.test.js +84 -0
  29. package/src/utils/parsing/extractComment.js +145 -0
  30. package/src/utils/parsing/extractComment.test.js +182 -0
  31. package/src/utils/parsing/preProcess.js +2 -1
  32. package/src/utils/paths/findLineForKey.js +2 -2
  33. package/src/utils/paths/ignorePaths.js +22 -9
  34. package/src/utils/redaction/redact.js +78 -0
  35. package/src/utils/redaction/redact.test.js +38 -0
  36. package/src/utils/redaction/setupRedaction.js +47 -0
  37. package/src/utils/redaction/setupRedaction.test.js +68 -0
  38. package/src/utils/requirements/configRequirements.js +351 -0
  39. package/src/utils/requirements/configRequirements.test.js +380 -0
  40. package/src/utils/requirements/serializeRequirements.js +120 -0
  41. package/src/utils/requirements/serializeRequirements.test.js +211 -0
  42. package/src/utils/security/evalSafety.js +86 -0
  43. package/src/utils/security/evalSafety.test.js +61 -0
  44. package/src/utils/security/safetyPolicy.js +110 -0
  45. package/src/utils/security/safetyPolicy.test.js +29 -0
  46. package/src/utils/strings/didYouMean.js +70 -0
  47. package/src/utils/strings/didYouMean.test.js +52 -0
  48. package/src/utils/strings/formatFunctionArgs.js +6 -1
  49. package/src/utils/strings/splitByComma.js +5 -0
  50. package/src/utils/ui/configWizard.js +208 -34
  51. package/src/utils/ui/createEditorLink.js +17 -1
  52. package/src/utils/ui/promptDescriptors.js +196 -0
  53. package/src/utils/ui/promptDescriptors.test.js +162 -0
  54. package/src/utils/variables/cleanVariable.js +22 -0
  55. package/src/utils/variables/getVariableType.js +1 -0
  56. package/types/src/index.d.ts +0 -24
  57. package/types/src/index.d.ts.map +1 -1
  58. package/types/src/main.d.ts +16 -8
  59. package/types/src/main.d.ts.map +1 -1
  60. package/types/src/resolvers/valueFromFile.d.ts +0 -2
  61. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  62. package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
  63. package/types/src/resolvers/valueFromSelf.d.ts +1 -0
  64. package/types/src/resolvers/valueFromSelf.d.ts.map +1 -0
  65. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  66. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  67. package/types/src/utils/paths/findLineForKey.d.ts +0 -9
  68. package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
  69. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  70. package/types/src/utils/variables/variableUtils.d.ts +1 -1
  71. package/types/src/display.d.ts +0 -62
  72. package/types/src/display.d.ts.map +0 -1
  73. package/types/src/metadata.d.ts +0 -28
  74. package/types/src/metadata.d.ts.map +0 -1
  75. package/types/src/utils/BoundedMap.d.ts +0 -10
  76. package/types/src/utils/BoundedMap.d.ts.map +0 -1
  77. package/types/src/utils/paths/ignorePaths.d.ts +0 -5
  78. package/types/src/utils/paths/ignorePaths.d.ts.map +0 -1
@@ -114,6 +114,32 @@ const GIT_KEYS = {
114
114
  }
115
115
 
116
116
  function createResolver(cwd) {
117
+ let gitRepo
118
+ const gitResultCache = new Map()
119
+
120
+ function isCurrentGitRepo() {
121
+ if (typeof gitRepo === 'undefined') {
122
+ gitRepo = isGitRepo(cwd)
123
+ }
124
+ return gitRepo
125
+ }
126
+
127
+ function cachedSafeGit(key, cmdFn) {
128
+ if (!gitResultCache.has(key)) {
129
+ gitResultCache.set(key, _safeGit(cmdFn))
130
+ }
131
+ return gitResultCache.get(key)
132
+ }
133
+
134
+ function gitExec(args) {
135
+ const key = `git:${JSON.stringify(args)}`
136
+ return cachedSafeGit(key, () => _execFile('git', args))
137
+ }
138
+
139
+ function gitRemote(name = 'origin') {
140
+ return cachedSafeGit(`remote:${name}`, () => getGitRemote(name))
141
+ }
142
+
117
143
  async function _getValueFromGit(variableString) {
118
144
  const variable = variableString.split(`${GIT_PREFIX}:`)[1]
119
145
  let value = null
@@ -123,14 +149,14 @@ function createResolver(cwd) {
123
149
  // undefined. This lets fallbacks like `${git:branch, "main"}` work, and
124
150
  // when there's no fallback the outer resolver throws a clear "Unable to
125
151
  // resolve config variable" error pointing at the config path.
126
- if (!isGitRepo(cwd)) {
152
+ if (!isCurrentGitRepo()) {
127
153
  return undefined
128
154
  }
129
155
 
130
156
  if (variable.match(/^remote/i)) {
131
157
  const hasParams = functionRegex.exec(variableString)
132
158
  const remoteName = (hasParams && hasParams[2]) ? formatFunctionArgs(hasParams[2]) : 'origin'
133
- return _safeGit(() => getGitRemote(remoteName))
159
+ return gitRemote(remoteName)
134
160
  }
135
161
 
136
162
  const normalizedVar = (variable || '').toLowerCase()
@@ -152,7 +178,7 @@ function createResolver(cwd) {
152
178
  case 'repository':
153
179
  case 'reposlug':
154
180
  case 'repo-slug': {
155
- const urla = await _safeGit(() => getGitRemote())
181
+ const urla = await gitRemote()
156
182
  if (!urla) return undefined
157
183
  const parseda = GitUrlParse(urla)
158
184
  value = parseda.full_name
@@ -162,7 +188,7 @@ function createResolver(cwd) {
162
188
  case GIT_KEYS.name:
163
189
  case 'reponame': // repoName
164
190
  case 'repo-name': {
165
- const toplevel = await _safeGit(() => _execFile('git', ['rev-parse', '--show-toplevel']))
191
+ const toplevel = await gitExec(['rev-parse', '--show-toplevel'])
166
192
  if (!toplevel) return undefined
167
193
  value = path.basename(toplevel)
168
194
  break
@@ -173,7 +199,7 @@ function createResolver(cwd) {
173
199
  case 'organization':
174
200
  case 'repoowner': // repoOwner
175
201
  case 'repo-owner': {
176
- const url = await _safeGit(() => getGitRemote())
202
+ const url = await gitRemote()
177
203
  if (!url) return undefined
178
204
  const parsed = GitUrlParse(url)
179
205
  value = parsed.organization || parsed.owner
@@ -185,12 +211,12 @@ function createResolver(cwd) {
185
211
  case 'dirpath': // dirPath
186
212
  case 'dir-path':
187
213
  case 'dir_path': {
188
- const gitBasePath = await _safeGit(() => _execFile('git', ['rev-parse', '--show-toplevel']))
214
+ const gitBasePath = await gitExec(['rev-parse', '--show-toplevel'])
189
215
  if (!gitBasePath) return undefined
190
216
  if (cwd) {
191
217
  const subPath = cwd.replace(gitBasePath, '')
192
- const branch = await _safeGit(() => _execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD']))
193
- const url = await _safeGit(() => getGitRemote())
218
+ const branch = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])
219
+ const url = await gitRemote()
194
220
  if (!url) return undefined
195
221
  value = (subPath && branch) ? `${url}/tree/${branch}${subPath}` : url
196
222
  }
@@ -200,12 +226,12 @@ function createResolver(cwd) {
200
226
  case GIT_KEYS.url:
201
227
  case 'repourl': // repoUrl
202
228
  case 'repo-url':
203
- value = await _safeGit(() => getGitRemote())
229
+ value = await gitRemote()
204
230
  break
205
231
  // Current commit sha
206
232
  case 'sha':
207
233
  case 'sha1':
208
- value = await _safeGit(() => _execFile('git', ['rev-parse', '--short', 'HEAD']))
234
+ value = await gitExec(['rev-parse', '--short', 'HEAD'])
209
235
  break
210
236
  // Current commit full sha
211
237
  case GIT_KEYS.commit:
@@ -213,7 +239,7 @@ function createResolver(cwd) {
213
239
  case 'commit-sha':
214
240
  case 'commithash':
215
241
  case 'commit-hash':
216
- value = await _safeGit(() => _execFile('git', ['rev-parse', 'HEAD']))
242
+ value = await gitExec(['rev-parse', 'HEAD'])
217
243
  break
218
244
  // Branches
219
245
  case GIT_KEYS.branch:
@@ -221,7 +247,7 @@ function createResolver(cwd) {
221
247
  case 'branch-name':
222
248
  case 'currentbranch': // currentBranch
223
249
  case 'current-branch':
224
- value = await _safeGit(() => _execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD']))
250
+ value = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])
225
251
  break
226
252
  // Commit msg
227
253
  case GIT_KEYS.message:
@@ -230,26 +256,26 @@ function createResolver(cwd) {
230
256
  case 'commit-message':
231
257
  case 'commitmsg': // commitMsg
232
258
  case 'commit-msg':
233
- value = await _safeGit(() => _execFile('git', ['log', '-1', '--pretty=%B']))
259
+ value = await gitExec(['log', '-1', '--pretty=%B'])
234
260
  break
235
261
  // Git tags
236
262
  case GIT_KEYS.tag:
237
263
  case 'describe':
238
- value = await _safeGit(() => _execFile('git', ['describe', '--always']))
264
+ value = await gitExec(['describe', '--always'])
239
265
  break
240
266
  // Git tags
241
267
  case 'describeLight':
242
268
  case 'describelight':
243
269
  case 'describe-light':
244
- value = await _safeGit(() => _execFile('git', ['describe', '--always', '--tags']))
270
+ value = await gitExec(['describe', '--always', '--tags'])
245
271
  break
246
272
  // Is branch dirty
247
273
  case 'isDirty':
248
274
  case 'isdirty':
249
275
  case 'is-dirty': {
250
- const writeTree = await _safeGit(() => _execFile('git', ['write-tree']))
276
+ const writeTree = await gitExec(['write-tree'])
251
277
  if (!writeTree) return undefined
252
- const changes = await _safeGit(() => _execFile('git', ['diff-index', writeTree.trim(), '--']))
278
+ const changes = await gitExec(['diff-index', writeTree.trim(), '--'])
253
279
  if (changes === undefined) return undefined
254
280
  value = `${changes.length > 0}`
255
281
  break
@@ -1,6 +1,6 @@
1
1
  // Resolves values from CLI option flags
2
- // Matches ${opt:FLAG_NAME} syntax with optional fallback values
3
- const optRefSyntax = RegExp(/^opt:/g)
2
+ // Matches ${opt:FLAG_NAME} and ${option:FLAG_NAME} syntax with optional fallback values
3
+ const optRefSyntax = RegExp(/^(?:opt|option):/g)
4
4
 
5
5
  function getValueFromOptions(variableString, options) {
6
6
  const requestedOption = variableString.split(':')[1]
@@ -12,8 +12,9 @@ module.exports = {
12
12
  type: 'options',
13
13
  source: 'user',
14
14
  prefix: 'opt',
15
- syntax: '${opt:flagName}',
16
- description: 'Resolves CLI option flags. Examples: ${opt:stage}, ${opt:other, "fallbackValue"}',
15
+ prefixes: ['opt', 'option'],
16
+ syntax: '${option:flagName}',
17
+ description: 'Resolves CLI option flags. Examples: ${option:stage}, ${opt:stage}, ${option:other, "fallbackValue"}',
17
18
  match: optRefSyntax,
18
19
  resolver: getValueFromOptions
19
20
  }
@@ -0,0 +1,57 @@
1
+ const MARKER = '__CONFIGORAMA_FILTER_ARG__'
2
+
3
+ class ResolvedFilterArg {
4
+ constructor(value) {
5
+ this.value = value
6
+ this.__resolvedFilterArg = true
7
+ }
8
+
9
+ toString() {
10
+ return String(this.value)
11
+ }
12
+
13
+ valueOf() {
14
+ return this.value
15
+ }
16
+ }
17
+
18
+ function encodeBase64Url(value) {
19
+ return Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
20
+ }
21
+
22
+ function decodeBase64Url(value) {
23
+ let base64 = value.replace(/-/g, '+').replace(/_/g, '/')
24
+ while (base64.length % 4) base64 += '='
25
+ return Buffer.from(base64, 'base64').toString('utf8')
26
+ }
27
+
28
+ function encodeFilterArg(value) {
29
+ return `${MARKER}:${encodeBase64Url(JSON.stringify(value))}`
30
+ }
31
+
32
+ function isEncodedFilterArg(value) {
33
+ return typeof value === 'string' && value.startsWith(`${MARKER}:`)
34
+ }
35
+
36
+ function decodeFilterArg(value) {
37
+ if (!isEncodedFilterArg(value)) return value
38
+ const encoded = value.slice(MARKER.length + 1)
39
+ return new ResolvedFilterArg(JSON.parse(decodeBase64Url(encoded)))
40
+ }
41
+
42
+ function isResolvedFilterArg(value) {
43
+ return Boolean(value && value.__resolvedFilterArg)
44
+ }
45
+
46
+ function unwrapFilterArg(value) {
47
+ return isResolvedFilterArg(value) ? value.value : value
48
+ }
49
+
50
+ module.exports = {
51
+ ResolvedFilterArg,
52
+ decodeFilterArg,
53
+ encodeFilterArg,
54
+ isEncodedFilterArg,
55
+ isResolvedFilterArg,
56
+ unwrapFilterArg,
57
+ }
@@ -0,0 +1,77 @@
1
+ const { splitCsv } = require('../strings/splitCsv')
2
+ const { trimSurroundingQuotes } = require('../strings/quoteUtils')
3
+ const { isResolvedFilterArg, unwrapFilterArg } = require('./filterArgs')
4
+
5
+ function isRuntimeContextArg(value) {
6
+ return typeof value === 'string' && value.startsWith('from ')
7
+ }
8
+
9
+ function stripRuntimeContext(args) {
10
+ if (!args.length) return args
11
+ const last = args[args.length - 1]
12
+ return isRuntimeContextArg(last) ? args.slice(0, -1) : args
13
+ }
14
+
15
+ function hasDynamicArgument(value) {
16
+ return typeof value === 'string' && value.includes('${')
17
+ }
18
+
19
+ function parseOneOfLiteral(rawValue) {
20
+ const trimmed = String(rawValue).trim()
21
+ const unquoted = trimSurroundingQuotes(trimmed, false)
22
+ if (unquoted !== trimmed) return unquoted
23
+ if (/^-?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) return Number(trimmed)
24
+ return trimmed
25
+ }
26
+
27
+ function parseOneOfFilter(filter) {
28
+ if (typeof filter !== 'string') return null
29
+ const match = filter.match(/^oneOf\(([\s\S]*)\)$/)
30
+ if (!match) return null
31
+
32
+ const args = splitCsv(match[1], ',', { protectVariables: true })
33
+ .filter(arg => arg !== '')
34
+
35
+ if (args.some(hasDynamicArgument)) {
36
+ return {
37
+ dynamic: true,
38
+ allowedValues: null,
39
+ }
40
+ }
41
+
42
+ return {
43
+ dynamic: false,
44
+ allowedValues: args.map(arg => String(parseOneOfLiteral(arg))),
45
+ }
46
+ }
47
+
48
+ function validateOneOf(value, ...rawArgs) {
49
+ const args = stripRuntimeContext(rawArgs)
50
+ if (!args.length) {
51
+ throw new Error('Configorama Error: oneOf() requires at least one allowed value')
52
+ }
53
+ if (args.some(hasDynamicArgument)) {
54
+ throw new Error('Configorama Error: oneOf(${...}) dynamic arguments are not supported yet')
55
+ }
56
+
57
+ const hasResolvedFilterArg = args.some(isResolvedFilterArg)
58
+ const unwrappedArgs = args.map(unwrapFilterArg)
59
+ if (hasResolvedFilterArg) {
60
+ if (unwrappedArgs.length !== 1 || !Array.isArray(unwrappedArgs[0])) {
61
+ throw new Error('Configorama Error: oneOf(${...}) must resolve to an array')
62
+ }
63
+ }
64
+
65
+ const allowed = hasResolvedFilterArg ? unwrappedArgs[0] : unwrappedArgs
66
+ const allowedValues = allowed.map(String)
67
+ if (!allowedValues.some(allowed => allowed === String(value))) {
68
+ throw new Error(`Configorama Error: Value "${value}" is not oneOf(${allowedValues.join(', ')})`)
69
+ }
70
+ return value
71
+ }
72
+
73
+ module.exports = {
74
+ parseOneOfFilter,
75
+ parseOneOfLiteral,
76
+ validateOneOf,
77
+ }
@@ -0,0 +1,78 @@
1
+ const { severityForRisk } = require('./model')
2
+
3
+ function sortFindings(a, b) {
4
+ const severityOrder = { high: 0, medium: 1, low: 2, info: 3 }
5
+ const severityDiff = (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99)
6
+ if (severityDiff !== 0) return severityDiff
7
+ return String(a.id).localeCompare(String(b.id))
8
+ }
9
+
10
+ function buildAuditReport(introspection, options = {}) {
11
+ const findings = []
12
+
13
+ for (const node of introspection.nodes || []) {
14
+ if (!node.risk || node.risk === 'none') continue
15
+ findings.push({
16
+ id: node.id,
17
+ severity: node.severity || severityForRisk(node.risk),
18
+ risk: node.risk,
19
+ kind: node.kind,
20
+ variable: node.variable,
21
+ path: node.path,
22
+ relativePath: node.relativePath,
23
+ configPaths: node.paths || [],
24
+ message: messageForNode(node),
25
+ })
26
+ }
27
+
28
+ if (options.dotenv === true) {
29
+ findings.push({
30
+ id: 'dotenv:useDotenv',
31
+ severity: 'high',
32
+ risk: 'environment_mutation',
33
+ kind: 'source',
34
+ message: 'Configuration requests dotenv loading, which mutates process.env.',
35
+ })
36
+ }
37
+
38
+ if (options.customResolvers && options.customResolvers.length) {
39
+ for (const resolver of options.customResolvers.slice().sort()) {
40
+ findings.push({
41
+ id: `customResolver:${resolver}`,
42
+ severity: 'high',
43
+ risk: 'custom_extension',
44
+ kind: 'source',
45
+ variableType: resolver,
46
+ message: `Custom resolver "${resolver}" can execute user-provided code.`,
47
+ })
48
+ }
49
+ }
50
+
51
+ findings.sort(sortFindings)
52
+
53
+ return {
54
+ schemaVersion: 1,
55
+ safeMode: options.safeMode === true,
56
+ findings,
57
+ diagnostics: introspection.diagnostics || [],
58
+ summary: {
59
+ high: findings.filter(finding => finding.severity === 'high').length,
60
+ medium: findings.filter(finding => finding.severity === 'medium').length,
61
+ low: findings.filter(finding => finding.severity === 'low').length,
62
+ info: findings.filter(finding => finding.severity === 'info').length,
63
+ total: findings.length,
64
+ }
65
+ }
66
+ }
67
+
68
+ function messageForNode(node) {
69
+ if (node.risk === 'executable_code') return 'Reference may execute JavaScript or TypeScript.'
70
+ if (node.risk === 'process_spawn') return 'Reference may spawn a git process.'
71
+ if (node.risk === 'local_file_read') return 'Reference reads a local file.'
72
+ if (node.risk === 'data_flow_expression') return 'Expression can read resolved config values but is not JavaScript execution.'
73
+ return `Risk surface: ${node.risk}`
74
+ }
75
+
76
+ module.exports = {
77
+ buildAuditReport,
78
+ }
@@ -0,0 +1,43 @@
1
+ function quote(value) {
2
+ return JSON.stringify(String(value))
3
+ }
4
+
5
+ function toMermaid(graph) {
6
+ const lines = ['graph TD']
7
+ for (const node of graph.nodes) {
8
+ lines.push(` ${sanitizeId(node.id)}[${quote(node.label || node.variable || node.relativePath || node.id).slice(1, -1)}]`)
9
+ }
10
+ for (const edge of graph.edges) {
11
+ lines.push(` ${sanitizeId(edge.from)} -->|${edge.kind}| ${sanitizeId(edge.to)}`)
12
+ }
13
+ return lines.join('\n') + '\n'
14
+ }
15
+
16
+ function sanitizeId(value) {
17
+ return String(value).replace(/[^A-Za-z0-9_]/g, '_')
18
+ }
19
+
20
+ function toDot(graph) {
21
+ const lines = ['digraph configorama {']
22
+ for (const node of graph.nodes) {
23
+ lines.push(` ${quote(node.id)} [label=${quote(node.label || node.variable || node.relativePath || node.id)}];`)
24
+ }
25
+ for (const edge of graph.edges) {
26
+ lines.push(` ${quote(edge.from)} -> ${quote(edge.to)} [label=${quote(edge.kind)}];`)
27
+ }
28
+ lines.push('}')
29
+ return lines.join('\n') + '\n'
30
+ }
31
+
32
+ function formatGraph(graph, format = 'json') {
33
+ const normalized = String(format || 'json').toLowerCase()
34
+ if (normalized === 'mermaid' || normalized === 'mmd') return toMermaid(graph)
35
+ if (normalized === 'dot' || normalized === 'graphviz') return toDot(graph)
36
+ return JSON.stringify(graph, null, 2)
37
+ }
38
+
39
+ module.exports = {
40
+ formatGraph,
41
+ toDot,
42
+ toMermaid,
43
+ }
@@ -0,0 +1,150 @@
1
+ const path = require('path')
2
+ const { EXECUTABLE_EXTENSIONS } = require('../security/safetyPolicy')
3
+ const { redactRequirementValue } = require('../redaction/redact')
4
+
5
+ const SCHEMA_VERSION = 1
6
+
7
+ function sortBy(keys) {
8
+ return (a, b) => {
9
+ for (const key of keys) {
10
+ const av = a[key] === undefined || a[key] === null ? '' : String(a[key])
11
+ const bv = b[key] === undefined || b[key] === null ? '' : String(b[key])
12
+ if (av < bv) return -1
13
+ if (av > bv) return 1
14
+ }
15
+ return 0
16
+ }
17
+ }
18
+
19
+ function normalizeVariableType(type) {
20
+ if (type === 'options' || type === 'opt') return 'option'
21
+ if (type === 'dot.prop') return 'dotProp'
22
+ if (type === 'if') return 'eval'
23
+ return type || 'unknown'
24
+ }
25
+
26
+ function riskForVariable(variableType, variable) {
27
+ const type = normalizeVariableType(variableType)
28
+ if (type === 'eval') return 'data_flow_expression'
29
+ if (type === 'git') return 'process_spawn'
30
+ if (type === 'file' || type === 'text') {
31
+ const match = String(variable || '').match(/^(?:file|text)\((.+?)\)/)
32
+ const ext = match ? path.extname(match[1]).toLowerCase() : ''
33
+ return EXECUTABLE_EXTENSIONS.has(ext) ? 'executable_code' : 'local_file_read'
34
+ }
35
+ return 'none'
36
+ }
37
+
38
+ function severityForRisk(risk) {
39
+ if (risk === 'executable_code' || risk === 'custom_extension' || risk === 'environment_mutation') return 'high'
40
+ if (risk === 'process_spawn') return 'medium'
41
+ if (risk === 'local_file_read' || risk === 'data_flow_expression') return 'low'
42
+ return 'info'
43
+ }
44
+
45
+ function buildIntrospection(enrichedMetadata = {}, options = {}) {
46
+ const uniqueVariables = enrichedMetadata.uniqueVariables || {}
47
+ const requirements = options.requirements || []
48
+ const requirementsByVariable = new Map(requirements.map(req => [req.variable, req]))
49
+ const nodes = []
50
+ const edges = []
51
+ const diagnostics = []
52
+
53
+ for (const [key, entry] of Object.entries(uniqueVariables).sort(([a], [b]) => a.localeCompare(b))) {
54
+ const variable = entry.variable || key
55
+ const variableType = normalizeVariableType(entry.variableType)
56
+ const requirement = requirementsByVariable.get(variable)
57
+ const risk = riskForVariable(variableType, variable)
58
+ const node = {
59
+ id: `variable:${variable}`,
60
+ kind: variableType === 'file' || variableType === 'text'
61
+ ? 'file'
62
+ : (risk === 'executable_code' ? 'executable' : 'variable'),
63
+ variable,
64
+ variableType,
65
+ sourceClass: entry.variableSourceType || entry.sourceClass || requirement?.sourceClass || null,
66
+ risk,
67
+ severity: severityForRisk(risk),
68
+ paths: [...new Set((entry.occurrences || []).map(occ => occ.path).filter(Boolean))].sort(),
69
+ sensitive: requirement ? requirement.sensitive === true : false,
70
+ }
71
+ if (requirement) {
72
+ node.required = requirement.required
73
+ node.default = redactRequirementValue(requirement, requirement.default)
74
+ node.description = requirement.description
75
+ }
76
+ nodes.push(node)
77
+
78
+ for (const configPath of node.paths) {
79
+ edges.push({
80
+ from: `configPath:${configPath}`,
81
+ to: node.id,
82
+ kind: 'uses',
83
+ })
84
+ }
85
+
86
+ for (const inner of entry.innerVariables || []) {
87
+ edges.push({
88
+ from: node.id,
89
+ to: `variable:${inner.variable}`,
90
+ kind: 'depends_on',
91
+ })
92
+ }
93
+
94
+ if ((variableType === 'file' || variableType === 'text') && String(variable).includes('${')) {
95
+ diagnostics.push({
96
+ code: 'dynamic_file_target',
97
+ severity: 'info',
98
+ variable,
99
+ message: 'File target contains variables; static introspection records a partial edge.',
100
+ })
101
+ }
102
+ }
103
+
104
+ const fileDeps = enrichedMetadata.fileDependencies || {}
105
+ for (const dep of fileDeps.byConfigPath || []) {
106
+ const id = `file:${dep.relativePath || dep.filePath}`
107
+ if (!nodes.some(node => node.id === id)) {
108
+ nodes.push({
109
+ id,
110
+ kind: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'executable' : 'file',
111
+ path: dep.filePath,
112
+ relativePath: dep.relativePath,
113
+ exists: dep.exists,
114
+ risk: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'executable_code' : 'local_file_read',
115
+ severity: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'high' : 'low',
116
+ })
117
+ }
118
+ if (dep.location) {
119
+ edges.push({
120
+ from: `configPath:${dep.location}`,
121
+ to: id,
122
+ kind: 'reads',
123
+ })
124
+ }
125
+ }
126
+
127
+ nodes.sort(sortBy(['kind', 'id']))
128
+ edges.sort(sortBy(['from', 'kind', 'to']))
129
+ diagnostics.sort(sortBy(['code', 'variable']))
130
+
131
+ return {
132
+ schemaVersion: SCHEMA_VERSION,
133
+ nodes,
134
+ edges,
135
+ diagnostics,
136
+ summary: {
137
+ nodes: nodes.length,
138
+ edges: edges.length,
139
+ diagnostics: diagnostics.length,
140
+ }
141
+ }
142
+ }
143
+
144
+ module.exports = {
145
+ SCHEMA_VERSION,
146
+ buildIntrospection,
147
+ normalizeVariableType,
148
+ riskForVariable,
149
+ severityForRisk,
150
+ }
@@ -0,0 +1,93 @@
1
+ const { test } = require('uvu')
2
+ const assert = require('uvu/assert')
3
+ const { buildIntrospection, riskForVariable } = require('./model')
4
+ const { buildAuditReport } = require('./audit')
5
+ const { formatGraph } = require('./graph')
6
+
7
+ test('riskForVariable classifies eval as data-flow and js file refs as executable', () => {
8
+ assert.is(riskForVariable('eval', 'eval(1 + 1)'), 'data_flow_expression')
9
+ assert.is(riskForVariable('if', 'if(true)'), 'data_flow_expression')
10
+ assert.is(riskForVariable('file', 'file(./config.js)'), 'executable_code')
11
+ assert.is(riskForVariable('file', 'file(./config.yml)'), 'local_file_read')
12
+ })
13
+
14
+ test('buildIntrospection creates deterministic nodes edges and dynamic diagnostics', () => {
15
+ const graph = buildIntrospection({
16
+ uniqueVariables: {
17
+ 'file(./${opt:stage}.yml)': {
18
+ variable: 'file(./${opt:stage}.yml)',
19
+ variableType: 'file',
20
+ variableSourceType: 'config',
21
+ occurrences: [{ path: 'database' }],
22
+ innerVariables: [{ variable: 'opt:stage', variableType: 'options' }]
23
+ },
24
+ 'opt:stage': {
25
+ variable: 'opt:stage',
26
+ variableType: 'options',
27
+ variableSourceType: 'user',
28
+ occurrences: [{ path: 'stage' }]
29
+ }
30
+ },
31
+ fileDependencies: {
32
+ byConfigPath: []
33
+ }
34
+ }, {
35
+ requirements: [{ variable: 'opt:stage', sensitive: false, required: true, default: null }]
36
+ })
37
+
38
+ assert.is(graph.schemaVersion, 1)
39
+ assert.ok(graph.nodes.some(node => node.id === 'variable:file(./${opt:stage}.yml)'))
40
+ assert.ok(graph.edges.some(edge => edge.kind === 'depends_on'))
41
+ assert.is(graph.diagnostics[0].code, 'dynamic_file_target')
42
+ })
43
+
44
+ test('buildIntrospection redacts sensitive requirement defaults', () => {
45
+ const graph = buildIntrospection({
46
+ uniqueVariables: {
47
+ 'env:API_KEY': {
48
+ variable: 'env:API_KEY',
49
+ variableType: 'env',
50
+ variableSourceType: 'user',
51
+ occurrences: [{ path: 'apiKey' }]
52
+ }
53
+ },
54
+ fileDependencies: { byConfigPath: [] }
55
+ }, {
56
+ requirements: [{
57
+ variable: 'env:API_KEY',
58
+ sensitive: true,
59
+ required: false,
60
+ default: 'secret-value',
61
+ description: 'API key'
62
+ }]
63
+ })
64
+
65
+ const node = graph.nodes.find(item => item.variable === 'env:API_KEY')
66
+ assert.is(node.sensitive, true)
67
+ assert.is(node.default, '********')
68
+ })
69
+
70
+ test('audit report sorts executable findings before lower severity surfaces', () => {
71
+ const report = buildAuditReport({
72
+ nodes: [
73
+ { id: 'variable:eval(1)', risk: 'data_flow_expression', severity: 'low', kind: 'variable' },
74
+ { id: 'variable:file(./x.js)', risk: 'executable_code', severity: 'high', kind: 'executable' }
75
+ ],
76
+ diagnostics: []
77
+ })
78
+
79
+ assert.is(report.summary.total, 2)
80
+ assert.is(report.findings[0].severity, 'high')
81
+ })
82
+
83
+ test('formatGraph emits mermaid and dot formats', () => {
84
+ const graph = {
85
+ nodes: [{ id: 'configPath:stage', label: 'stage' }, { id: 'variable:opt:stage', variable: 'opt:stage' }],
86
+ edges: [{ from: 'configPath:stage', to: 'variable:opt:stage', kind: 'uses' }]
87
+ }
88
+
89
+ assert.match(formatGraph(graph, 'mermaid'), /graph TD/)
90
+ assert.match(formatGraph(graph, 'dot'), /digraph configorama/)
91
+ })
92
+
93
+ test.run()