configorama 0.11.2 → 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 (52) 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 +117 -15
  12. package/src/resolvers/valueFromEval.js +11 -1
  13. package/src/resolvers/valueFromFile.js +5 -0
  14. package/src/resolvers/valueFromGit.js +43 -17
  15. package/src/resolvers/valueFromOptions.js +5 -4
  16. package/src/utils/filters/filterArgs.js +57 -0
  17. package/src/utils/filters/oneOf.js +77 -0
  18. package/src/utils/introspection/audit.js +78 -0
  19. package/src/utils/introspection/graph.js +43 -0
  20. package/src/utils/introspection/model.js +150 -0
  21. package/src/utils/introspection/model.test.js +93 -0
  22. package/src/utils/parsing/commentAnnotations.js +107 -0
  23. package/src/utils/parsing/commentAnnotations.test.js +123 -0
  24. package/src/utils/parsing/enrichMetadata.js +64 -1
  25. package/src/utils/parsing/enrichMetadata.test.js +84 -0
  26. package/src/utils/parsing/extractComment.js +145 -0
  27. package/src/utils/parsing/extractComment.test.js +182 -0
  28. package/src/utils/parsing/preProcess.js +2 -1
  29. package/src/utils/paths/findLineForKey.js +2 -2
  30. package/src/utils/paths/ignorePaths.js +21 -9
  31. package/src/utils/redaction/redact.js +78 -0
  32. package/src/utils/redaction/redact.test.js +38 -0
  33. package/src/utils/redaction/setupRedaction.js +47 -0
  34. package/src/utils/redaction/setupRedaction.test.js +68 -0
  35. package/src/utils/requirements/configRequirements.js +351 -0
  36. package/src/utils/requirements/configRequirements.test.js +380 -0
  37. package/src/utils/requirements/serializeRequirements.js +120 -0
  38. package/src/utils/requirements/serializeRequirements.test.js +211 -0
  39. package/src/utils/security/evalSafety.js +86 -0
  40. package/src/utils/security/evalSafety.test.js +61 -0
  41. package/src/utils/security/safetyPolicy.js +110 -0
  42. package/src/utils/security/safetyPolicy.test.js +29 -0
  43. package/src/utils/strings/didYouMean.js +70 -0
  44. package/src/utils/strings/didYouMean.test.js +52 -0
  45. package/src/utils/strings/formatFunctionArgs.js +6 -1
  46. package/src/utils/strings/splitByComma.js +5 -0
  47. package/src/utils/ui/configWizard.js +208 -34
  48. package/src/utils/ui/createEditorLink.js +17 -1
  49. package/src/utils/ui/promptDescriptors.js +196 -0
  50. package/src/utils/ui/promptDescriptors.test.js +162 -0
  51. package/src/utils/variables/cleanVariable.js +22 -0
  52. package/src/utils/variables/getVariableType.js +1 -0
@@ -3,6 +3,8 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('../paths/filePathUtils')
5
5
  const { preResolveString, preResolveSingle } = require('../resolution/preResolveVariable')
6
+ const { parseOneOfFilter } = require('../filters/oneOf')
7
+ const { extractComment } = require('./extractComment')
6
8
 
7
9
  // Type filters that indicate expected value types
8
10
  const TYPE_FILTERS = ['Boolean', 'String', 'Number', 'Array', 'Object', 'Json']
@@ -61,6 +63,8 @@ function createOccurrence(instance, varMatch, options = {}) {
61
63
 
62
64
  // Extract type from filters
63
65
  const type = extractTypeFromFilters(filters)
66
+ const oneOfFilter = filters && filters.find(filter => typeof filter === 'string' && filter.match(/^oneOf\(/))
67
+ const oneOf = parseOneOfFilter(oneOfFilter)
64
68
 
65
69
  const occurrence = {
66
70
  originalString: instance.originalStringValue,
@@ -79,6 +83,11 @@ function createOccurrence(instance, varMatch, options = {}) {
79
83
 
80
84
  if (description) {
81
85
  occurrence.description = description
86
+ occurrence.descriptionSource = 'help'
87
+ }
88
+
89
+ if (oneOf && oneOf.allowedValues) {
90
+ occurrence.allowedValues = oneOf.allowedValues
82
91
  }
83
92
 
84
93
  if (instance.defaultValueSrc) {
@@ -133,6 +142,56 @@ async function enrichMetadata(
133
142
  // Create resolve context early for resolving descriptions
134
143
  const configDir = configPath ? path.dirname(configPath) : undefined
135
144
  const resolveContext = { config: originalConfig, variableSyntax, configDir, options }
145
+ const commentFileType = configPath ? path.extname(configPath) : ''
146
+ let commentLines = []
147
+ if (configPath) {
148
+ try {
149
+ commentLines = fs.readFileSync(configPath, 'utf8').split('\n')
150
+ } catch (error) {
151
+ commentLines = []
152
+ }
153
+ }
154
+
155
+ function applyCommentFallback(occurrence) {
156
+ if (!occurrence) return occurrence
157
+ const comment = extractComment(occurrence.path, commentLines, commentFileType)
158
+ if (comment) {
159
+ if (comment.description && (!occurrence.description || comment.descriptionSource === 'commentTag')) {
160
+ occurrence.description = comment.description
161
+ occurrence.descriptionSource = comment.descriptionSource
162
+ }
163
+ if (comment.obtainHint) occurrence.obtainHint = comment.obtainHint
164
+ if (comment.examples) occurrence.examples = comment.examples
165
+ if (comment.defaultHint) occurrence.defaultHint = comment.defaultHint
166
+ if (comment.sensitive !== undefined) {
167
+ occurrence.sensitive = comment.sensitive
168
+ occurrence.sensitiveSource = 'commentTag'
169
+ }
170
+ if (comment.group) occurrence.group = comment.group
171
+ if (comment.deprecationMessage) occurrence.deprecationMessage = comment.deprecationMessage
172
+ }
173
+ return occurrence
174
+ }
175
+
176
+ async function applyDynamicOneOfAllowedValues(occurrence) {
177
+ if (!occurrence || occurrence.allowedValues || !occurrence.filters) return occurrence
178
+ const oneOfFilter = occurrence.filters.find(filter => typeof filter === 'string' && filter.match(/^oneOf\(/))
179
+ if (!oneOfFilter) return occurrence
180
+
181
+ const match = oneOfFilter.match(/^oneOf\(\s*\$\{([^}]+)\}\s*\)$/)
182
+ if (!match) return occurrence
183
+
184
+ try {
185
+ const resolved = await preResolveSingle(match[1], resolveContext)
186
+ if (Array.isArray(resolved)) {
187
+ occurrence.allowedValues = resolved.map(String)
188
+ }
189
+ } catch (error) {
190
+ // Leave unresolved dynamic oneOf args without allowedValues; runtime will report errors.
191
+ }
192
+
193
+ return occurrence
194
+ }
136
195
 
137
196
  const varKeys = Object.keys(metadata.variables)
138
197
 
@@ -439,6 +498,8 @@ async function enrichMetadata(
439
498
  for (const instance of varInstances) {
440
499
  // Add this occurrence with its full context
441
500
  const occurrence = createOccurrence(instance, key)
501
+ applyCommentFallback(occurrence)
502
+ await applyDynamicOneOfAllowedValues(occurrence)
442
503
  entry.occurrences.push(occurrence)
443
504
 
444
505
  // Find inner variables in resolveDetails (excluding the outermost variable itself)
@@ -511,6 +572,8 @@ async function enrichMetadata(
511
572
  hasFallback: !!detail.hasFallback,
512
573
  defaultValue: siblingDefaultValue,
513
574
  })
575
+ applyCommentFallback(siblingOccurrence)
576
+ await applyDynamicOneOfAllowedValues(siblingOccurrence)
514
577
  if (siblingDefaultValueSrc) {
515
578
  siblingOccurrence.defaultValueSrc = siblingDefaultValueSrc
516
579
  }
@@ -763,4 +826,4 @@ async function enrichMetadata(
763
826
  return metadata
764
827
  }
765
828
 
766
- module.exports = enrichMetadata
829
+ module.exports = enrichMetadata
@@ -0,0 +1,84 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const configorama = require('../../index')
8
+
9
+ function writeTempConfig(lines) {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'configorama-annotations-'))
11
+ const configPath = path.join(dir, 'config.yml')
12
+ fs.writeFileSync(configPath, lines.join('\n'))
13
+ return { dir, configPath }
14
+ }
15
+
16
+ test('enriched metadata maps comment annotations onto occurrences', async () => {
17
+ const { dir, configPath } = writeTempConfig([
18
+ '# Stripe live secret key',
19
+ '# @from Stripe dashboard > Developers > API keys',
20
+ '# @example sk_live_...',
21
+ '# @default Set in CI',
22
+ '# @sensitive false',
23
+ '# @group Payments',
24
+ '# @deprecated Use STRIPE_RESTRICTED_KEY instead',
25
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY}',
26
+ ])
27
+
28
+ try {
29
+ const analysis = await configorama.analyze(configPath, { options: {} })
30
+ const occurrence = analysis.uniqueVariables['env:STRIPE_SECRET_KEY'].occurrences[0]
31
+
32
+ assert.is(occurrence.description, 'Stripe live secret key')
33
+ assert.is(occurrence.descriptionSource, 'leadingComment')
34
+ assert.is(occurrence.obtainHint, 'Stripe dashboard > Developers > API keys')
35
+ assert.equal(occurrence.examples, ['sk_live_...'])
36
+ assert.is(occurrence.defaultHint, 'Set in CI')
37
+ assert.is(occurrence.defaultValue, undefined)
38
+ assert.is(occurrence.sensitive, false)
39
+ assert.is(occurrence.sensitiveSource, 'commentTag')
40
+ assert.is(occurrence.group, 'Payments')
41
+ assert.is(occurrence.deprecationMessage, 'Use STRIPE_RESTRICTED_KEY instead')
42
+ } finally {
43
+ fs.rmSync(dir, { recursive: true, force: true })
44
+ }
45
+ })
46
+
47
+ test('enriched metadata lets @description override help on the same occurrence', async () => {
48
+ const { dir, configPath } = writeTempConfig([
49
+ '# Plain comment loses',
50
+ '# @description Comment tag wins',
51
+ 'apiKey: ${env:CONFIGORAMA_ANNOTATED_API_KEY | help("Help loses")}',
52
+ ])
53
+
54
+ try {
55
+ const analysis = await configorama.analyze(configPath, { options: {} })
56
+ const occurrence = analysis.uniqueVariables['env:CONFIGORAMA_ANNOTATED_API_KEY'].occurrences[0]
57
+
58
+ assert.is(occurrence.description, 'Comment tag wins')
59
+ assert.is(occurrence.descriptionSource, 'commentTag')
60
+ } finally {
61
+ fs.rmSync(dir, { recursive: true, force: true })
62
+ }
63
+ })
64
+
65
+ test('enriched metadata keeps normal comment below help while preserving independent tags', async () => {
66
+ const { dir, configPath } = writeTempConfig([
67
+ '# Plain comment loses to help',
68
+ '# @from Dashboard > Tokens',
69
+ 'apiToken: ${env:CONFIGORAMA_ANNOTATED_TOKEN | help("Help wins")}',
70
+ ])
71
+
72
+ try {
73
+ const analysis = await configorama.analyze(configPath, { options: {} })
74
+ const occurrence = analysis.uniqueVariables['env:CONFIGORAMA_ANNOTATED_TOKEN'].occurrences[0]
75
+
76
+ assert.is(occurrence.description, 'Help wins')
77
+ assert.is(occurrence.descriptionSource, 'help')
78
+ assert.is(occurrence.obtainHint, 'Dashboard > Tokens')
79
+ } finally {
80
+ fs.rmSync(dir, { recursive: true, force: true })
81
+ }
82
+ })
83
+
84
+ test.run()
@@ -0,0 +1,145 @@
1
+ const { findLineByPath, findLineForKey } = require('../paths/findLineForKey')
2
+ const { parseCommentAnnotations } = require('./commentAnnotations')
3
+
4
+ function getCommentMarkers(fileType) {
5
+ if (fileType === '.json') return []
6
+ if (fileType === '.json5' || fileType === '.jsonc') return ['//']
7
+ if (fileType === '.hcl') return ['//', '#']
8
+ if (['.yml', '.yaml', '.toml', '.ini'].includes(fileType)) return ['#']
9
+ return ['#', '//']
10
+ }
11
+
12
+ function isDecorationComment(text) {
13
+ return /^[\s\-=_*]{3,}$/.test(text)
14
+ }
15
+
16
+ function stripCommentMarker(line, marker) {
17
+ return line.trim().slice(marker.length).replace(/^\s?/, '')
18
+ }
19
+
20
+ function findCommentStart(line, markers) {
21
+ let quote = null
22
+ let variableDepth = 0
23
+
24
+ for (let i = 0; i < line.length; i++) {
25
+ const char = line[i]
26
+ const prev = i > 0 ? line[i - 1] : ''
27
+
28
+ if (!quote && char === '$' && line[i + 1] === '{') {
29
+ variableDepth++
30
+ i++
31
+ continue
32
+ }
33
+ if (!quote && variableDepth > 0 && char === '}') {
34
+ variableDepth--
35
+ continue
36
+ }
37
+ if ((char === "'" || char === '"') && prev !== '\\') {
38
+ if (!quote) quote = char
39
+ else if (quote === char) quote = null
40
+ continue
41
+ }
42
+ if (quote || variableDepth > 0) continue
43
+
44
+ for (const marker of markers) {
45
+ if (line.slice(i, i + marker.length) === marker) {
46
+ return { index: i, marker }
47
+ }
48
+ }
49
+ }
50
+
51
+ return null
52
+ }
53
+
54
+ function getLineNumber(configPath, lines, fileType) {
55
+ if (['.yml', '.yaml', '.json5', '.jsonc'].includes(fileType)) {
56
+ const byPath = findLineByPath(configPath, lines, fileType)
57
+ if (byPath) return byPath
58
+ }
59
+
60
+ if (fileType === '.json') return 0
61
+ const key = String(configPath).split('.').pop()
62
+ return findLineForKey(key, lines, fileType)
63
+ }
64
+
65
+ function getLeadingComment(lines, lineIndex, markers) {
66
+ const comments = getLeadingCommentLines(lines, lineIndex, markers)
67
+ return comments.length ? comments.join(' ') : null
68
+ }
69
+
70
+ function getLeadingCommentLines(lines, lineIndex, markers) {
71
+ const comments = []
72
+ for (let i = lineIndex - 1; i >= 0; i--) {
73
+ const line = lines[i]
74
+ if (!line || line.trim() === '') break
75
+
76
+ const trimmed = line.trim()
77
+ const marker = markers.find(item => trimmed.startsWith(item))
78
+ if (!marker) break
79
+
80
+ const text = stripCommentMarker(trimmed, marker).trim()
81
+ if (text && !isDecorationComment(text)) comments.unshift(text)
82
+ }
83
+
84
+ return comments
85
+ }
86
+
87
+ function hasAnnotations(annotations) {
88
+ return annotations && Object.keys(annotations).length > 0
89
+ }
90
+
91
+ function buildCommentResult(commentLines, fallbackDescriptionSource) {
92
+ const parsed = parseCommentAnnotations(commentLines)
93
+ if (!parsed.description && !hasAnnotations(parsed.annotations)) return null
94
+
95
+ const result = {}
96
+ if (parsed.description) {
97
+ result.description = parsed.description
98
+ result.descriptionSource = parsed.descriptionSource || fallbackDescriptionSource
99
+ }
100
+
101
+ if (hasAnnotations(parsed.annotations)) {
102
+ result.annotations = parsed.annotations
103
+ for (const [key, value] of Object.entries(parsed.annotations)) {
104
+ if (key === 'description') continue
105
+ result[key] = value
106
+ }
107
+ }
108
+
109
+ return result
110
+ }
111
+
112
+ function extractComment(configPath, lines, fileType) {
113
+ try {
114
+ const markers = getCommentMarkers(fileType)
115
+ if (!markers.length || !configPath || !Array.isArray(lines) || !lines.length) return null
116
+
117
+ const lineNumber = getLineNumber(configPath, lines, fileType)
118
+ if (!lineNumber) return null
119
+
120
+ const lineIndex = lineNumber - 1
121
+ const line = lines[lineIndex] || ''
122
+ const inlineStart = findCommentStart(line, markers)
123
+ if (inlineStart) {
124
+ const text = stripCommentMarker(line.slice(inlineStart.index), inlineStart.marker).trim()
125
+ if (text && !isDecorationComment(text)) {
126
+ return buildCommentResult([text], 'comment')
127
+ }
128
+ }
129
+
130
+ const leading = getLeadingCommentLines(lines, lineIndex, markers)
131
+ const result = buildCommentResult(leading, 'leadingComment')
132
+ if (result) return result
133
+ } catch (error) {
134
+ return null
135
+ }
136
+
137
+ return null
138
+ }
139
+
140
+ module.exports = {
141
+ extractComment,
142
+ findCommentStart,
143
+ getCommentMarkers,
144
+ getLeadingComment,
145
+ }
@@ -0,0 +1,182 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const configorama = require('../../index')
8
+ const { extractComment, findCommentStart, getCommentMarkers } = require('./extractComment')
9
+
10
+ test('extractComment reads trailing inline YAML comments', () => {
11
+ const lines = ['apiKey: ${env:API_KEY} # API key from dashboard']
12
+ assert.equal(extractComment('apiKey', lines, '.yml'), {
13
+ description: 'API key from dashboard',
14
+ descriptionSource: 'comment',
15
+ })
16
+ })
17
+
18
+ test('extractComment reads leading block comments and drops decoration', () => {
19
+ const lines = [
20
+ '# --------',
21
+ '# Database host',
22
+ '# Used by app startup',
23
+ 'host: ${env:DB_HOST}',
24
+ ]
25
+ assert.equal(extractComment('host', lines, '.yaml'), {
26
+ description: 'Database host Used by app startup',
27
+ descriptionSource: 'leadingComment',
28
+ })
29
+ })
30
+
31
+ test('extractComment ignores comment markers inside variables and quotes', () => {
32
+ const lines = ['apiKey: ${env:API_KEY, "abc#123"} # Real comment']
33
+ const start = findCommentStart(lines[0], getCommentMarkers('.yml'))
34
+ assert.is(lines[0].slice(start.index), '# Real comment')
35
+ })
36
+
37
+ test('extractComment uses path-aware lookup for nested duplicate YAML keys', () => {
38
+ const lines = [
39
+ 'first:',
40
+ ' apiKey: ${env:FIRST_KEY} # First key',
41
+ 'second:',
42
+ ' apiKey: ${env:SECOND_KEY} # Second key',
43
+ ]
44
+ assert.equal(extractComment('second.apiKey', lines, '.yml'), {
45
+ description: 'Second key',
46
+ descriptionSource: 'comment',
47
+ })
48
+ })
49
+
50
+ test('extractComment skips JSON comments and handles failures without throwing', () => {
51
+ assert.is(extractComment('apiKey', ['"apiKey": "${env:API_KEY}" // ignored'], '.json'), null)
52
+ assert.is(extractComment(null, null, '.yml'), null)
53
+ })
54
+
55
+ test('extractComment handles JSON5 line comments', () => {
56
+ const lines = [
57
+ '{',
58
+ ' "apiKey": "${env:API_KEY}" // API key',
59
+ '}',
60
+ ]
61
+ assert.equal(extractComment('apiKey', lines, '.json5'), {
62
+ description: 'API key',
63
+ descriptionSource: 'comment',
64
+ })
65
+ })
66
+
67
+ test('extractComment parses leading YAML annotation tags', () => {
68
+ const lines = [
69
+ '# Stripe live secret key',
70
+ '# @from Stripe dashboard > Developers > API keys',
71
+ '# @sensitive true',
72
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY}',
73
+ ]
74
+ assert.equal(extractComment('stripeSecret', lines, '.yml'), {
75
+ description: 'Stripe live secret key',
76
+ descriptionSource: 'leadingComment',
77
+ annotations: {
78
+ obtainHint: 'Stripe dashboard > Developers > API keys',
79
+ sensitive: true,
80
+ },
81
+ obtainHint: 'Stripe dashboard > Developers > API keys',
82
+ sensitive: true,
83
+ })
84
+ })
85
+
86
+ test('extractComment lets explicit @description override plain comment text', () => {
87
+ const lines = [
88
+ '# Plain comment loses to explicit description',
89
+ '# @description Stripe live secret key',
90
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY}',
91
+ ]
92
+ assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
93
+ description: 'Stripe live secret key',
94
+ descriptionSource: 'commentTag',
95
+ annotations: {
96
+ description: 'Stripe live secret key',
97
+ },
98
+ })
99
+ })
100
+
101
+ test('extractComment parses inline tags without requiring a description', () => {
102
+ const lines = [
103
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY} # @from Stripe dashboard > Developers > API keys',
104
+ ]
105
+ assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
106
+ annotations: {
107
+ obtainHint: 'Stripe dashboard > Developers > API keys',
108
+ },
109
+ obtainHint: 'Stripe dashboard > Developers > API keys',
110
+ })
111
+ })
112
+
113
+ test('extractComment parses JSONC and HCL annotation comments', () => {
114
+ const jsoncLines = [
115
+ '{',
116
+ ' // @group Payments',
117
+ ' "stripeSecret": "${env:STRIPE_SECRET_KEY}"',
118
+ '}',
119
+ ]
120
+ assert.equal(extractComment('stripeSecret', jsoncLines, '.jsonc'), {
121
+ annotations: {
122
+ group: 'Payments',
123
+ },
124
+ group: 'Payments',
125
+ })
126
+
127
+ const hclLines = [
128
+ '// Stripe live secret key',
129
+ '// @from Stripe dashboard > Developers > API keys',
130
+ 'stripe_secret = "${env:STRIPE_SECRET_KEY}"',
131
+ ]
132
+ assert.equal(extractComment('stripe_secret', hclLines, '.hcl'), {
133
+ description: 'Stripe live secret key',
134
+ descriptionSource: 'leadingComment',
135
+ annotations: {
136
+ obtainHint: 'Stripe dashboard > Developers > API keys',
137
+ },
138
+ obtainHint: 'Stripe dashboard > Developers > API keys',
139
+ })
140
+ })
141
+
142
+ test('extractComment keeps unknown tag-shaped comments as plain description', () => {
143
+ const lines = [
144
+ '# @david rotate this key after launch',
145
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY}',
146
+ ]
147
+ assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
148
+ description: '@david rotate this key after launch',
149
+ descriptionSource: 'leadingComment',
150
+ })
151
+ })
152
+
153
+ test('requirements model uses comments when help is absent and keeps help precedence', async () => {
154
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'configorama-comments-'))
155
+ const configPath = path.join(dir, 'config.yml')
156
+ fs.writeFileSync(configPath, [
157
+ '# API key from dashboard',
158
+ 'apiKey: ${env:CONFIGORAMA_COMMENT_API_KEY}',
159
+ 'withHelp: ${env:CONFIGORAMA_COMMENT_HELP | help("Help wins")} # Comment loses',
160
+ 'nested:',
161
+ ' apiKey: ${env:CONFIGORAMA_COMMENT_NESTED} # Nested key',
162
+ ].join('\n'))
163
+
164
+ try {
165
+ const result = await configorama.analyze(configPath, {
166
+ instructions: true,
167
+ options: {}
168
+ })
169
+ const byVariable = Object.fromEntries(result.requirements.map(req => [req.variable, req]))
170
+
171
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_API_KEY'].description, 'API key from dashboard')
172
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_API_KEY'].descriptionSource, 'leadingComment')
173
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_HELP'].description, 'Help wins')
174
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_HELP'].descriptionSource, 'help')
175
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_NESTED'].description, 'Nested key')
176
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_NESTED'].descriptionSource, 'comment')
177
+ } finally {
178
+ fs.rmSync(dir, { recursive: true, force: true })
179
+ }
180
+ })
181
+
182
+ test.run()
@@ -26,7 +26,8 @@ function preProcess(configObject, variableSyntax, variableTypes, options = {}) {
26
26
  // Extract reference prefixes from variable types, or use defaults
27
27
  const refPrefixes = variableTypes && variableTypes.length > 0
28
28
  ? variableTypes
29
- .map(v => (v.prefix || v.type) + ':')
29
+ .flatMap(v => v.prefixes || [v.prefix || v.type])
30
+ .map(prefix => prefix + ':')
30
31
  .filter(p => p !== 'dot.prop:' && p !== 'string:' && p !== 'number:')
31
32
  : ['self:', 'opt:', 'env:', 'file:', 'text:', 'deep:']
32
33
 
@@ -19,8 +19,8 @@ function findLineForKey(keyToFind, lines, fileType) {
19
19
  if (fileType === '.yml' || fileType === '.yaml') {
20
20
  return new RegExp(`^\\s*${escapedKey}\\s*:`).test(line)
21
21
  }
22
- // TOML: key = or key=
23
- if (fileType === '.toml') {
22
+ // TOML/HCL: key = or key=
23
+ if (fileType === '.toml' || fileType === '.hcl') {
24
24
  return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
25
25
  }
26
26
  // JSON: "key": or "key" :
@@ -30,17 +30,29 @@ function patternToSegments(pattern) {
30
30
  }
31
31
 
32
32
  function matchSegments(patternSegments, pathSegments) {
33
- if (!patternSegments.length) return pathSegments.length === 0
33
+ return matchFrom(patternSegments, 0, pathSegments, 0)
34
+ }
34
35
 
35
- const [head, ...tail] = patternSegments
36
- if (head === '**') {
37
- if (matchSegments(tail, pathSegments)) return true
38
- return pathSegments.length > 0 && matchSegments(patternSegments, pathSegments.slice(1))
36
+ // Index-based glob match: '**' spans zero-or-more segments (with backtracking),
37
+ // '*' matches one segment, anything else matches literally. Avoids per-call array
38
+ // allocation (no destructuring/slice) that dominated resolution hot paths.
39
+ function matchFrom(pattern, pi, path, si) {
40
+ while (pi < pattern.length) {
41
+ const head = pattern[pi]
42
+ if (head === '**') {
43
+ // '**' consumes zero segments here...
44
+ if (matchFrom(pattern, pi + 1, path, si)) return true
45
+ // ...or one more path segment, still anchored on '**'
46
+ if (si >= path.length) return false
47
+ si++
48
+ continue
49
+ }
50
+ if (si >= path.length) return false
51
+ if (head !== '*' && head !== path[si]) return false
52
+ pi++
53
+ si++
39
54
  }
40
-
41
- if (!pathSegments.length) return false
42
- if (head !== '*' && head !== pathSegments[0]) return false
43
- return matchSegments(tail, pathSegments.slice(1))
55
+ return si === path.length
44
56
  }
45
57
 
46
58
  function normalizeIgnorePaths(options = {}) {
@@ -0,0 +1,78 @@
1
+ const dotProp = require('dot-prop')
2
+
3
+ const REDACTED_VALUE = '********'
4
+
5
+ const DEFAULT_SENSITIVE_PATTERNS = [
6
+ /secret/i,
7
+ /password/i,
8
+ /token/i,
9
+ /key/i,
10
+ /credential/i,
11
+ /auth/i,
12
+ /private[-_]?key/i,
13
+ /client[-_]?secret/i,
14
+ ]
15
+
16
+ function cloneJson(value) {
17
+ if (value === undefined) return undefined
18
+ return JSON.parse(JSON.stringify(value))
19
+ }
20
+
21
+ function toRegex(pattern) {
22
+ if (pattern instanceof RegExp) return pattern
23
+ return new RegExp(String(pattern), 'i')
24
+ }
25
+
26
+ function getSensitivePatterns(options = {}) {
27
+ const customPatterns = (options.patterns || options.sensitivePatterns || []).map(toRegex)
28
+ return DEFAULT_SENSITIVE_PATTERNS.concat(customPatterns)
29
+ }
30
+
31
+ function isSensitiveName(name, options = {}) {
32
+ if (!name) return false
33
+ const value = String(name)
34
+ return getSensitivePatterns(options).some(pattern => pattern.test(value))
35
+ }
36
+
37
+ function getSensitiveOverride(entries = []) {
38
+ const entry = entries.find(item => item && typeof item.value === 'boolean')
39
+ if (!entry) return null
40
+ return entry.value
41
+ }
42
+
43
+ function isSensitiveVariable(name, options = {}) {
44
+ const override = getSensitiveOverride(options.sensitiveEntries || [])
45
+ if (override !== null) return override
46
+ return isSensitiveName(name, options) || isSensitiveName(options.path, options)
47
+ }
48
+
49
+ function redactValue(value) {
50
+ if (value === undefined) return undefined
51
+ return REDACTED_VALUE
52
+ }
53
+
54
+ function redactObjectByPaths(value, paths = []) {
55
+ const redacted = cloneJson(value)
56
+ for (const configPath of paths || []) {
57
+ if (configPath && dotProp.has(redacted, configPath)) {
58
+ dotProp.set(redacted, configPath, REDACTED_VALUE)
59
+ }
60
+ }
61
+ return redacted
62
+ }
63
+
64
+ function redactRequirementValue(requirement, value) {
65
+ return requirement && requirement.sensitive === true ? REDACTED_VALUE : value
66
+ }
67
+
68
+ module.exports = {
69
+ DEFAULT_SENSITIVE_PATTERNS,
70
+ REDACTED_VALUE,
71
+ cloneJson,
72
+ getSensitivePatterns,
73
+ isSensitiveName,
74
+ isSensitiveVariable,
75
+ redactObjectByPaths,
76
+ redactRequirementValue,
77
+ redactValue,
78
+ }