configorama 0.8.0 → 0.9.3

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 (34) hide show
  1. package/README.md +38 -3
  2. package/package.json +1 -1
  3. package/src/main.js +53 -21
  4. package/src/parsers/yaml.js +4 -4
  5. package/src/parsers/yaml.test.js +52 -0
  6. package/src/resolvers/valueFromFile.js +15 -2
  7. package/src/utils/PromiseTracker.js +54 -0
  8. package/src/utils/encoders/unknown-values.js +1 -1
  9. package/src/utils/encoders/unknown-values.test.js +146 -0
  10. package/src/utils/lodash.js +5 -4
  11. package/src/utils/lodash.test.js +172 -0
  12. package/src/utils/parsing/cloudformationSchema.js +24 -2
  13. package/src/utils/parsing/cloudformationSchema.test.js +236 -0
  14. package/src/utils/parsing/mergeByKeys.js +9 -8
  15. package/src/utils/parsing/mergeByKeys.test.js +189 -0
  16. package/src/utils/parsing/parse.js +5 -2
  17. package/src/utils/paths/getFullFilePath.js +2 -2
  18. package/src/utils/paths/getFullFilePath.test.js +152 -0
  19. package/src/utils/regex/index.js +65 -1
  20. package/src/utils/regex/index.test.js +195 -0
  21. package/src/utils/strings/formatFunctionArgs.js +4 -0
  22. package/src/utils/strings/splitCsv.js +46 -19
  23. package/types/src/main.d.ts.map +1 -1
  24. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  25. package/types/src/utils/PromiseTracker.d.ts +4 -0
  26. package/types/src/utils/PromiseTracker.d.ts.map +1 -1
  27. package/types/src/utils/lodash.d.ts.map +1 -1
  28. package/types/src/utils/parsing/mergeByKeys.d.ts.map +1 -1
  29. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  30. package/types/src/utils/regex/index.d.ts +15 -1
  31. package/types/src/utils/regex/index.d.ts.map +1 -1
  32. package/types/src/utils/strings/formatFunctionArgs.d.ts.map +1 -1
  33. package/types/src/utils/strings/splitCsv.d.ts +1 -1
  34. package/types/src/utils/strings/splitCsv.d.ts.map +1 -1
package/README.md CHANGED
@@ -870,14 +870,49 @@ const config = await configorama(configFile, {
870
870
  allowUnresolvedVariables: ['param', 'file'], // only these pass through
871
871
  options: { stage: 'prod' }
872
872
  })
873
- // Unresolved ${param:x} and ${file(missing.yml)} pass through
874
- // Unresolved ${env:MISSING} throws an error
873
+ // Input: { paramKey: '${param:x}', fileKey: '${file(missing.yml)}' }
874
+ // Output: { paramKey: '${param:x}', fileKey: '${file(missing.yml)}' }
875
+
876
+ // Allow only SPECIFIC types to be unresolved
877
+ const config = await configorama(configFile, {
878
+ allowUnresolvedVariables: ['param', 'file'], // only these pass through
879
+ options: { stage: 'prod' }
880
+ })
881
+ // Input: { key: '${env:MISSING_VAR}', paramKey: '${param:x}', fileKey: '${file(missing.yml)}' }
882
+ // Unresolved ${param:x} and ${file(missing.yml)} pass through but
883
+ // Output error thrown because ${env:MISSING_VAR} throws an error
875
884
  ```
876
885
 
877
- This is useful for multi-stage resolution (e.g., Serverless Dashboard resolves params after local resolution).
886
+ This is useful for multi-stage resolution (e.g., Downstream Serverless Dashboard resolves params after local resolution).
887
+
888
+ > **Note:** This option does NOT apply to `self:` or dotProp variables (e.g., `${foo.bar.baz}`). These are local references that configorama fully owns—if they can't be resolved, it's a config error, not something to defer to another system.
878
889
 
879
890
  ## FAQ
880
891
 
892
+ **Q: What happens with circular variable dependencies?**
893
+
894
+ Configorama detects circular dependencies and throws a helpful error instead of hanging forever.
895
+
896
+ ```yml
897
+ # Direct cycle - throws error
898
+ a: ${self:b}
899
+ b: ${self:a}
900
+ # Error: Circular variable dependency detected: b → a → b
901
+
902
+ # Indirect cycle - also detected
903
+ a: ${self:b}
904
+ b: ${self:c}
905
+ c: ${self:a}
906
+ # Error: Circular variable dependency detected: c → a → b → c
907
+
908
+ # Works with shorthand syntax too
909
+ foo:
910
+ bar: ${baz.qux}
911
+ baz:
912
+ qux: ${foo.bar}
913
+ # Error: Circular variable dependency detected: baz.qux → foo.bar → baz.qux
914
+ ```
915
+
881
916
  **Q: Why should I use this?**
882
917
 
883
918
  Never rendering a stale configuration file again!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configorama",
3
- "version": "0.8.0",
3
+ "version": "0.9.3",
4
4
  "description": "Variable support for configuration files",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
package/src/main.js CHANGED
@@ -121,6 +121,7 @@ class Configorama {
121
121
  allowUndefinedValues: false,
122
122
  // Allow known variable types that can't be resolved to pass through
123
123
  // Can be: false | true | ['param', 'file', 'env', ...]
124
+ // Note: Does not apply to self: or dotprop refs - those always error
124
125
  allowUnresolvedVariables: false,
125
126
  // Return metadata
126
127
  returnMetadata: false,
@@ -334,21 +335,25 @@ class Configorama {
334
335
  /** */
335
336
  /* its file ref so we need to shift lookup for self in nested files */
336
337
  if (valueObject.isFileRef) {
337
- const exists = dotProp.get(fullObject, varString)
338
- // console.log('fallThroughSelfMatcher exists', exists)
339
- if (!exists) {
340
- // @ Todo make recursive
341
- const deepProperties = [valueObject.path[0]].concat(varString)
342
- const dotPropPath = deepProperties.join('.')
343
- const deeperExists = dotProp.get(fullObject, dotPropPath)
344
- // console.log('fallThroughSelfMatcher deeper', deeperExists)
345
- return deeperExists
338
+ // First check if property exists in the nested file's context (preferred)
339
+ const nestedPath = [valueObject.path[0]].concat(varString)
340
+ const nestedDotPath = nestedPath.join('.')
341
+ if (dotProp.has(fullObject, nestedDotPath)) {
342
+ // Property exists in nested context - return true to indicate match
343
+ // (actual value resolution happens in resolver, not here)
344
+ return true
346
345
  }
346
+ // Fall back to top-level lookup
347
+ if (dotProp.has(fullObject, varString)) {
348
+ return true
349
+ }
350
+ return false
347
351
  }
348
352
  // console.log('fallthrough fullObject', fullObject)
349
353
  /* is simple ${whatever} reference in same file */
350
354
  const startOf = varString.split('.')
351
- return fullObject[startOf[0]]
355
+ // Use has() to properly check existence for falsy values
356
+ return dotProp.has(fullObject, startOf[0])
352
357
  },
353
358
  resolver: (varString, options, config, pathValue) => {
354
359
  /*
@@ -2767,6 +2772,25 @@ Missing Value ${missingValue} - ${matchedString}
2767
2772
 
2768
2773
  // console.log('getValueFromSrc propertyString', propertyString)
2769
2774
  // console.log(`tracker contains ${variableString}`, this.tracker.contains(variableString))
2775
+
2776
+ // Cycle detection: track dependencies and check for cycles
2777
+ const fromPath = valueObject.path ? valueObject.path.join('.') : null
2778
+ // Extract target path from variableString (e.g., 'self:b' → 'b', 'b.c' → 'b.c')
2779
+ let toPath = variableString
2780
+ if (variableString.startsWith('self:')) {
2781
+ toPath = variableString.slice(5)
2782
+ }
2783
+ // For cycle detection, only track self-references
2784
+ if (fromPath && (variableString.startsWith('self:') || !variableString.includes(':'))) {
2785
+ if (this.tracker.wouldCreateCycle(fromPath, toPath)) {
2786
+ const cyclePath = this.tracker.getCyclePath(fromPath, toPath)
2787
+ return Promise.reject(new Error(
2788
+ `Circular variable dependency detected: ${cyclePath.join(' → ')}`
2789
+ ))
2790
+ }
2791
+ this.tracker.addDependency(fromPath, toPath)
2792
+ }
2793
+
2770
2794
  if (this.tracker.contains(variableString)) {
2771
2795
  // console.log('try to get', variableString)
2772
2796
  return this.tracker.get(variableString, propertyString)
@@ -2873,7 +2897,7 @@ Missing Value ${missingValue} - ${matchedString}
2873
2897
  // Find the most recent call for this variableString
2874
2898
  for (let i = this.resolutionTracking[pathKey].calls.length - 1; i >= 0; i--) {
2875
2899
  if (this.resolutionTracking[pathKey].calls[i].variableString === variableString) {
2876
- const v = (typeof val === 'object' && val.__internal_only_flag) ? val.value : val
2900
+ const v = (val && typeof val === 'object' && val.__internal_only_flag) ? val.value : val
2877
2901
  this.resolutionTracking[pathKey].calls[i].resolvedValue = v
2878
2902
  this.resolutionTracking[pathKey].calls[i].resolverType = resolverType
2879
2903
  break
@@ -3244,15 +3268,16 @@ Missing Value ${missingValue} - ${matchedString}
3244
3268
 
3245
3269
  /* its file ref so we need to shift lookup for self in nested files */
3246
3270
  if (data.isFileRef) {
3247
- const dotPropPath = deepProperties.length > 1 ? deepProperties.join('.') : deepProperties[0]
3248
- const exists = dotProp.get(valueToPopulate, dotPropPath)
3249
- // console.log('self exists', exists)
3250
- if (!exists) {
3251
- // @ Todo make recursive
3252
- deepProperties = [data.path[0]].concat(deepProperties)
3253
- // console.log('self fixed deepProperties', deepProperties)
3254
- }
3271
+ // First check if property exists in the nested file's context (preferred for file refs)
3272
+ const nestedPath = [data.path[0]].concat(deepProperties)
3273
+ const nestedDotPath = nestedPath.join('.')
3274
+ if (dotProp.has(valueToPopulate, nestedDotPath)) {
3275
+ // Property exists in nested context, prefer it over top-level
3276
+ deepProperties = nestedPath
3277
+ }
3278
+ // Otherwise, keep deepProperties as-is to try top-level lookup
3255
3279
  }
3280
+
3256
3281
  return this.getDeeperValue(deepProperties, valueToPopulate).then((res) => {
3257
3282
  /*
3258
3283
  console.log('self getDeeperValue variableString', variableString)
@@ -3426,7 +3451,7 @@ Missing Value ${missingValue} - ${matchedString}
3426
3451
  })
3427
3452
  }
3428
3453
  runFunction(variableString) {
3429
- console.log('runFunction', variableString)
3454
+ // console.log('runFunction', variableString)
3430
3455
  /* If json object value return it */
3431
3456
  if (variableString.match(/^\s*{/) && variableString.match(/}\s*$/)) {
3432
3457
  return variableString
@@ -3452,7 +3477,14 @@ Missing Value ${missingValue} - ${matchedString}
3452
3477
  // TODO fix how commas + spaces are ned
3453
3478
  const splitter = splitCsv(rawArgs, ', ')
3454
3479
  // console.log('splitter', splitter)
3455
- argsToPass = formatFunctionArgs(splitter)
3480
+ // Recursively evaluate any nested function calls in arguments
3481
+ const evaluatedArgs = splitter.map((arg) => {
3482
+ if (typeof arg === 'string' && funcRegex.test(arg)) {
3483
+ return this.runFunction(arg)
3484
+ }
3485
+ return arg
3486
+ })
3487
+ argsToPass = formatFunctionArgs(evaluatedArgs)
3456
3488
  }
3457
3489
  // console.log('argsToPass runFunction', argsToPass)
3458
3490
  // TODO check for camelCase version. | toUpperCase messes with function name
@@ -132,8 +132,8 @@ function preProcess(ymlStr = '') {
132
132
  if (txt.indexOf(`'${nested}'`) > -1) {
133
133
  return
134
134
  }
135
- /* Replace variable wrapped in quotes */
136
- fixedText = fixedText.replace(nested, `"${nested}"`)
135
+ /* Replace ALL occurrences of variable wrapped in quotes */
136
+ fixedText = fixedText.replaceAll(nested, `"${nested}"`)
137
137
  })
138
138
  ymlStr = ymlStr.replace(txt, fixedText)
139
139
  }
@@ -171,8 +171,8 @@ function preProcess(ymlStr = '') {
171
171
  if (txt.indexOf(`'${nested}'`) > -1) {
172
172
  return
173
173
  }
174
- /* Replace variable wrapped in quotes */
175
- fixedText = fixedText.replace(nested, `"${nested}"`)
174
+ /* Replace ALL occurrences of variable wrapped in quotes */
175
+ fixedText = fixedText.replaceAll(nested, `"${nested}"`)
176
176
  })
177
177
  ymlStr = ymlStr.replace(txt, fixedText)
178
178
  }
@@ -166,4 +166,56 @@ test('preProcess - should handle empty input', () => {
166
166
  assert.equal(result, '')
167
167
  })
168
168
 
169
+ // ==========================================
170
+ // Duplicate variable tests - Bug: String.replace() only replaces first occurrence
171
+ // ==========================================
172
+
173
+ test('preProcess - should wrap ALL duplicate variables in quotes within same array', () => {
174
+ const input = `
175
+ items: [\${var:foo}, \${var:foo}, \${var:foo}]
176
+ `
177
+ const result = preProcess(input)
178
+
179
+ // All three occurrences should be wrapped
180
+ const wrappedCount = (result.match(/"\$\{var:foo\}"/g) || []).length
181
+
182
+ assert.is(wrappedCount, 3, `Expected 3 wrapped occurrences, got ${wrappedCount}. Output: ${result}`)
183
+ })
184
+
185
+ test('preProcess - should wrap ALL duplicate variables in quotes within same object', () => {
186
+ const input = `
187
+ config: {stage: \${env:STAGE}, region: \${env:STAGE}}
188
+ `
189
+ const result = preProcess(input)
190
+
191
+ // Both occurrences should be wrapped
192
+ const wrappedCount = (result.match(/"\$\{env:STAGE\}"/g) || []).length
193
+
194
+ assert.is(wrappedCount, 2, `Expected 2 wrapped occurrences, got ${wrappedCount}. Output: ${result}`)
195
+ })
196
+
197
+ test('preProcess - should wrap duplicate variables mixed with unique variables in array', () => {
198
+ const input = `
199
+ mixed: [\${env:FOO}, \${env:BAR}, \${env:FOO}]
200
+ `
201
+ const result = preProcess(input)
202
+
203
+ const fooCount = (result.match(/"\$\{env:FOO\}"/g) || []).length
204
+ const barCount = (result.match(/"\$\{env:BAR\}"/g) || []).length
205
+
206
+ assert.is(fooCount, 2, `Expected 2 wrapped FOO occurrences, got ${fooCount}. Output: ${result}`)
207
+ assert.is(barCount, 1, `Expected 1 wrapped BAR occurrence, got ${barCount}. Output: ${result}`)
208
+ })
209
+
210
+ test('preProcess - should wrap duplicate variables in nested array structure', () => {
211
+ const input = `
212
+ nested: [[\${opt:stage}, \${opt:stage}], [\${opt:stage}]]
213
+ `
214
+ const result = preProcess(input)
215
+
216
+ const wrappedCount = (result.match(/"\$\{opt:stage\}"/g) || []).length
217
+
218
+ assert.is(wrappedCount, 3, `Expected 3 wrapped occurrences, got ${wrappedCount}. Output: ${result}`)
219
+ })
220
+
169
221
  test.run()
@@ -253,7 +253,19 @@ ${JSON.stringify(options.context, null, 2)}`,
253
253
  if (fileExtension === 'js' || fileExtension === 'cjs') {
254
254
  const jsFile = require(fullFilePath)
255
255
  const { moduleName } = parseModuleReference(variableString, matchedFileString)
256
- const returnValueFunction = moduleName ? jsFile[moduleName] : jsFile
256
+ // For default export functions with :property syntax, keep the function and use deep properties
257
+ // For named exports (non-function module), look up the named export
258
+ let returnValueFunction = jsFile
259
+ let includeFirstProperty = false
260
+
261
+ if (moduleName && typeof jsFile === 'function') {
262
+ // Default export function with property access - include first property in path
263
+ returnValueFunction = jsFile
264
+ includeFirstProperty = true
265
+ } else if (moduleName) {
266
+ // Named export - look it up directly
267
+ returnValueFunction = jsFile[moduleName]
268
+ }
257
269
 
258
270
  return processExecutableFile({
259
271
  fileModule: jsFile,
@@ -264,7 +276,8 @@ ${JSON.stringify(options.context, null, 2)}`,
264
276
  matchedFileString,
265
277
  relativePath,
266
278
  fileType: 'javascript',
267
- getDeeperValue: ctx.getDeeperValue
279
+ getDeeperValue: ctx.getDeeperValue,
280
+ includeFirstProperty
268
281
  })
269
282
  }
270
283
 
@@ -8,6 +8,8 @@ class PromiseTracker {
8
8
  reset() {
9
9
  this.promiseList = []
10
10
  this.promiseMap = {}
11
+ // Track which variables depend on which (for cycle detection)
12
+ this.dependencyGraph = {}
11
13
  this.startTime = Date.now()
12
14
  this.cursor = 0
13
15
  }
@@ -81,6 +83,58 @@ class PromiseTracker {
81
83
  promise.waitList += ` ${specifier}`
82
84
  return promise
83
85
  }
86
+ // Add a dependency edge: "from" depends on "to"
87
+ addDependency(from, to) {
88
+ if (!this.dependencyGraph[from]) {
89
+ this.dependencyGraph[from] = new Set()
90
+ }
91
+ this.dependencyGraph[from].add(to)
92
+ }
93
+ // Check if adding dependency from → to would create a cycle
94
+ wouldCreateCycle(from, to) {
95
+ // Check if "to" can reach "from" (meaning from → to would close a cycle)
96
+ const visited = new Set()
97
+ const stack = [to]
98
+ while (stack.length > 0) {
99
+ const current = stack.pop()
100
+ if (current === from) {
101
+ return true
102
+ }
103
+ if (visited.has(current)) {
104
+ continue
105
+ }
106
+ visited.add(current)
107
+ const deps = this.dependencyGraph[current]
108
+ if (deps) {
109
+ for (const dep of deps) {
110
+ stack.push(dep)
111
+ }
112
+ }
113
+ }
114
+ return false
115
+ }
116
+ // Get the cycle path for error reporting
117
+ getCyclePath(from, to) {
118
+ const path = [from, to]
119
+ const visited = new Set([from, to])
120
+ let current = to
121
+ while (current !== from) {
122
+ const deps = this.dependencyGraph[current]
123
+ if (!deps) break
124
+ let found = false
125
+ for (const dep of deps) {
126
+ if (dep === from || !visited.has(dep)) {
127
+ path.push(dep)
128
+ visited.add(dep)
129
+ current = dep
130
+ found = true
131
+ break
132
+ }
133
+ }
134
+ if (!found) break
135
+ }
136
+ return path
137
+ }
84
138
  getPending() {
85
139
  return this.promiseList.filter(p => (p.state === 'pending'))
86
140
  }
@@ -20,7 +20,7 @@ function decodeUnknown(rawValue) {
20
20
  let val = rawValue.replace(PASSTHROUGH_PATTERN, '')
21
21
  if (x.length) {
22
22
  x.forEach(({ match, value }) => {
23
- const decodedValue = Buffer.from(value, 'base64').toString('ascii')
23
+ const decodedValue = Buffer.from(value, 'base64').toString('utf8')
24
24
  val = val.replace(match, decodedValue)
25
25
  })
26
26
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Tests for unknown-values.js - encoding/decoding passthrough variables
3
+ *
4
+ * These functions handle variables that cannot be resolved by Configorama
5
+ * but need to be passed through to external systems (like Serverless Dashboard).
6
+ */
7
+ const { test } = require('uvu')
8
+ const assert = require('uvu/assert')
9
+ const {
10
+ encodeUnknown,
11
+ decodeUnknown,
12
+ findUnknownValues,
13
+ hasEncodedUnknown,
14
+ PASSTHROUGH_PATTERN
15
+ } = require('./unknown-values')
16
+
17
+ // ==========================================
18
+ // encodeUnknown tests
19
+ // ==========================================
20
+
21
+ test('encodeUnknown - encodes string with passthrough wrapper', () => {
22
+ const result = encodeUnknown('${param:test}')
23
+ assert.ok(result.startsWith('>passthrough[_['))
24
+ assert.ok(result.endsWith(']_]'))
25
+ })
26
+
27
+ test('encodeUnknown - produces valid base64', () => {
28
+ const result = encodeUnknown('${param:test}')
29
+ const base64Part = result.replace('>passthrough[_[', '').replace(']_]', '')
30
+ // Should not throw
31
+ const decoded = Buffer.from(base64Part, 'base64').toString('utf8')
32
+ assert.is(decoded, '${param:test}')
33
+ })
34
+
35
+ // ==========================================
36
+ // decodeUnknown tests - basic functionality
37
+ // ==========================================
38
+
39
+ test('decodeUnknown - decodes encoded value back to original', () => {
40
+ const input = '${param:simpleValue}'
41
+ const encoded = encodeUnknown(input)
42
+ const decoded = decodeUnknown(encoded)
43
+ assert.is(decoded, input)
44
+ })
45
+
46
+ test('decodeUnknown - handles multiple encoded values', () => {
47
+ const input1 = '${param:first}'
48
+ const input2 = '${param:second}'
49
+ const combined = `prefix ${encodeUnknown(input1)} middle ${encodeUnknown(input2)} suffix`
50
+ const decoded = decodeUnknown(combined)
51
+ assert.is(decoded, `prefix ${input1} middle ${input2} suffix`)
52
+ })
53
+
54
+ test('decodeUnknown - returns original string if no encoded values', () => {
55
+ const input = 'no encoded values here'
56
+ const result = decodeUnknown(input)
57
+ assert.is(result, input)
58
+ })
59
+
60
+ // ==========================================
61
+ // decodeUnknown tests - UTF-8 support (bug fix)
62
+ // ==========================================
63
+
64
+ test('encodeUnknown/decodeUnknown - handles UTF-8 accented characters', () => {
65
+ const input = '${param:café}'
66
+ const encoded = encodeUnknown(input)
67
+ const decoded = decodeUnknown(encoded)
68
+ assert.is(decoded, input, 'UTF-8 accented characters should decode correctly')
69
+ })
70
+
71
+ test('encodeUnknown/decodeUnknown - handles UTF-8 emoji', () => {
72
+ const input = '${param:celebration-🎉}'
73
+ const encoded = encodeUnknown(input)
74
+ const decoded = decodeUnknown(encoded)
75
+ assert.is(decoded, input, 'UTF-8 emoji should decode correctly')
76
+ })
77
+
78
+ test('encodeUnknown/decodeUnknown - handles multi-byte UTF-8 characters (Japanese)', () => {
79
+ const input = '${param:日本語}'
80
+ const encoded = encodeUnknown(input)
81
+ const decoded = decodeUnknown(encoded)
82
+ assert.is(decoded, input, 'Multi-byte UTF-8 characters should decode correctly')
83
+ })
84
+
85
+ test('encodeUnknown/decodeUnknown - handles Chinese characters', () => {
86
+ const input = '${param:中文测试}'
87
+ const encoded = encodeUnknown(input)
88
+ const decoded = decodeUnknown(encoded)
89
+ assert.is(decoded, input, 'Chinese characters should decode correctly')
90
+ })
91
+
92
+ test('encodeUnknown/decodeUnknown - handles mixed ASCII and UTF-8', () => {
93
+ const input = '${param:hello-世界-🌍}'
94
+ const encoded = encodeUnknown(input)
95
+ const decoded = decodeUnknown(encoded)
96
+ assert.is(decoded, input, 'Mixed ASCII and UTF-8 should decode correctly')
97
+ })
98
+
99
+ test('encodeUnknown/decodeUnknown - handles special UTF-8 punctuation', () => {
100
+ const input = '${param:price-€100-£50}'
101
+ const encoded = encodeUnknown(input)
102
+ const decoded = decodeUnknown(encoded)
103
+ assert.is(decoded, input, 'UTF-8 currency symbols should decode correctly')
104
+ })
105
+
106
+ // ==========================================
107
+ // findUnknownValues tests
108
+ // ==========================================
109
+
110
+ test('findUnknownValues - finds encoded values in text', () => {
111
+ const encoded = encodeUnknown('${param:test}')
112
+ const results = findUnknownValues(encoded)
113
+ assert.is(results.length, 1)
114
+ assert.ok(results[0].match.startsWith('[_['))
115
+ assert.ok(results[0].value.length > 0)
116
+ })
117
+
118
+ test('findUnknownValues - returns empty array for text without encoded values', () => {
119
+ const results = findUnknownValues('no encoded values')
120
+ assert.is(results.length, 0)
121
+ })
122
+
123
+ test('findUnknownValues - finds multiple encoded values', () => {
124
+ const text = `${encodeUnknown('${a}')} and ${encodeUnknown('${b}')}`
125
+ const results = findUnknownValues(text)
126
+ assert.is(results.length, 2)
127
+ })
128
+
129
+ // ==========================================
130
+ // hasEncodedUnknown tests
131
+ // ==========================================
132
+
133
+ test('hasEncodedUnknown - returns true for encoded values', () => {
134
+ const encoded = encodeUnknown('${param:test}')
135
+ assert.is(hasEncodedUnknown(encoded), true)
136
+ })
137
+
138
+ test('hasEncodedUnknown - returns false for plain text', () => {
139
+ assert.is(hasEncodedUnknown('plain text'), false)
140
+ })
141
+
142
+ test('hasEncodedUnknown - returns false for regular variables', () => {
143
+ assert.is(hasEncodedUnknown('${param:test}'), false)
144
+ })
145
+
146
+ test.run()
@@ -32,14 +32,15 @@ function set(object, path, value) {
32
32
  const lastIndex = keys.length - 1;
33
33
 
34
34
  for (let i = 0; i < lastIndex; i++) {
35
- const key = keys[i];
35
+ const key = keys[i]
36
36
 
37
- if (current[key] === undefined) {
37
+ // Check if value is undefined, null, or not an object (primitives can't have properties)
38
+ if (current[key] == null || typeof current[key] !== 'object') {
38
39
  // Create appropriate container based on next key type
39
- current[key] = Number.isInteger(keys[i + 1]) && keys[i + 1] >= 0 ? [] : {};
40
+ current[key] = Number.isInteger(keys[i + 1]) && keys[i + 1] >= 0 ? [] : {}
40
41
  }
41
42
 
42
- current = current[key];
43
+ current = current[key]
43
44
  }
44
45
 
45
46
  current[keys[lastIndex]] = value;