configorama 0.9.8 → 0.9.12

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 (51) hide show
  1. package/README.md +83 -0
  2. package/index.d.ts +38 -29
  3. package/package.json +1 -1
  4. package/src/main.js +254 -101
  5. package/src/parsers/esm.js +0 -14
  6. package/src/parsers/typescript.js +0 -10
  7. package/src/resolvers/valueFromEval.js +69 -11
  8. package/src/resolvers/valueFromFile.js +1 -1
  9. package/src/resolvers/valueFromIf.js +75 -0
  10. package/src/resolvers/valueFromIf.test.js +66 -0
  11. package/src/resolvers/valueFromNumber.js +3 -0
  12. package/src/utils/handleSignalEvents.js +3 -4
  13. package/src/utils/lodash.js +18 -7
  14. package/src/utils/parsing/cloudformationSchema.js +1 -2
  15. package/src/utils/parsing/cloudformationSchema.test.js +14 -0
  16. package/src/utils/parsing/preProcess.js +220 -5
  17. package/src/utils/paths/getFullFilePath.js +6 -2
  18. package/src/utils/paths/getFullFilePath.test.js +18 -0
  19. package/src/utils/regex/index.js +18 -3
  20. package/src/utils/regex/index.test.js +24 -0
  21. package/src/utils/strings/quoteAware.js +141 -0
  22. package/src/utils/strings/replaceAll.js +13 -1
  23. package/src/utils/strings/splitByComma.js +25 -15
  24. package/src/utils/strings/splitByComma.test.js +19 -0
  25. package/src/utils/strings/splitOnPipe.js +30 -0
  26. package/src/utils/strings/splitOnPipe.test.js +68 -0
  27. package/src/utils/validation/isValidValue.test.js +1 -1
  28. package/src/utils/variables/findNestedVariables.js +8 -2
  29. package/types/src/main.d.ts +3 -1
  30. package/types/src/main.d.ts.map +1 -1
  31. package/types/src/parsers/esm.d.ts.map +1 -1
  32. package/types/src/parsers/typescript.d.ts.map +1 -1
  33. package/types/src/resolvers/valueFromEval.d.ts +1 -0
  34. package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
  35. package/types/src/resolvers/valueFromIf.d.ts +7 -0
  36. package/types/src/resolvers/valueFromIf.d.ts.map +1 -0
  37. package/types/src/resolvers/valueFromNumber.d.ts.map +1 -1
  38. package/types/src/utils/handleSignalEvents.d.ts.map +1 -1
  39. package/types/src/utils/lodash.d.ts.map +1 -1
  40. package/types/src/utils/parsing/preProcess.d.ts +5 -1
  41. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  42. package/types/src/utils/paths/getFullFilePath.d.ts.map +1 -1
  43. package/types/src/utils/regex/index.d.ts.map +1 -1
  44. package/types/src/utils/strings/quoteAware.d.ts +30 -0
  45. package/types/src/utils/strings/quoteAware.d.ts.map +1 -0
  46. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  47. package/types/src/utils/strings/splitByComma.d.ts +1 -1
  48. package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
  49. package/types/src/utils/strings/splitOnPipe.d.ts +8 -0
  50. package/types/src/utils/strings/splitOnPipe.d.ts.map +1 -0
  51. package/types/src/utils/variables/findNestedVariables.d.ts.map +1 -1
@@ -1,29 +1,86 @@
1
1
  // const evalRefSyntax = RegExp(/^eval\((~?[\{\}\:\${}a-zA=>+!-Z0-9._\-\/,'"\*\` ]+?)?\)/g)
2
2
  const evalRefSyntax = RegExp(/^eval\((.*)?\)/g)
3
+ const { replaceOutsideQuotes } = require('../utils/strings/quoteAware')
4
+
5
+ // Pattern for encoded objects/arrays: __OBJ:base64__ or __ARR:base64__
6
+ const ENCODED_PATTERN = /__(?:OBJ|ARR):([A-Za-z0-9+/=]+)__/g
7
+
8
+ // Encode object/array for embedding in eval expressions
9
+ function encodeValue(value) {
10
+ const prefix = Array.isArray(value) ? 'ARR' : 'OBJ'
11
+ const encoded = Buffer.from(JSON.stringify(value)).toString('base64')
12
+ return `__${prefix}:${encoded}__`
13
+ }
14
+
15
+ // Decode encoded values and build context for subscript
16
+ function decodeValues(expression) {
17
+ const context = {}
18
+ let idx = 0
19
+
20
+ const processed = expression.replace(ENCODED_PATTERN, (match, base64) => {
21
+ const decoded = JSON.parse(Buffer.from(base64, 'base64').toString('utf8'))
22
+ const placeholder = `__VAL${idx}__`
23
+ context[`__VAL${idx}__`] = decoded
24
+ idx++
25
+ return placeholder
26
+ })
27
+
28
+ return { processed, context }
29
+ }
30
+
31
+ // Wrap individual comparisons in parentheses for correct precedence with && / ||
32
+ // Subscript has operator precedence issues without explicit parens
33
+ function wrapComparisons(expr) {
34
+ if (!/&&|\|\|/.test(expr)) return expr
35
+
36
+ // Match comparisons: value op value (where op is ===, !==, ==, !=, >=, <=, >, <)
37
+ // Values can be: quoted strings, numbers, identifiers, or __VAL0__ placeholders
38
+ const compPattern = /((?:"[^"]*"|'[^']*'|__VAL\d+__|__NULL__|[a-zA-Z_][a-zA-Z0-9_]*|[\d.]+))\s*(===|!==|==|!=|>=|<=|>|<)\s*((?:"[^"]*"|'[^']*'|__VAL\d+__|__NULL__|[a-zA-Z_][a-zA-Z0-9_]*|[\d.]+))/g
39
+
40
+ return expr.replace(compPattern, '($1 $2 $3)')
41
+ }
3
42
 
4
43
  async function getValueFromEval(variableString) {
5
- // console.log('getValueFromEval variableString', variableString)
6
- // console.log('getValueFromEval variableString', variableString)
7
44
  // Extract the expression inside eval()
8
45
  const match = variableString.match(/^eval\((.+)\)$/)
9
- // console.log('match', match)
10
46
  if (!match) {
11
47
  throw new Error(`Invalid eval syntax: ${variableString}. Expected format: eval(expression)`)
12
48
  }
13
-
49
+
14
50
  const expression = match[1].trim()
15
- // console.log('expression', expression)
16
-
51
+ if (process.env.DEBUG_EVAL) console.log('eval expression:', expression)
52
+
17
53
  // Use "justin" variant to support strict comparison (===, !==) and other JS-like operators
18
54
  try {
19
55
  const { default: subscript } = await import('subscript/justin')
20
-
56
+
21
57
  // Handle string comparisons by ensuring both sides are quoted
22
- const processedExpression = expression.replace(/([a-zA-Z0-9_]+)\s*([=!<>]=?)\s*['"]([^'"]+)['"]/g, '"$1"$2"$3"')
23
-
24
- // console.log('processedExpression', processedExpression)
58
+ let processedExpression = expression.replace(/([a-zA-Z0-9_]+)\s*([=!<>]=?)\s*['"]([^'"]+)['"]/g, '"$1"$2"$3"')
59
+
60
+ // Decode any encoded objects/arrays
61
+ const { processed: withDecodedValues, context: valueContext } = decodeValues(processedExpression)
62
+ processedExpression = withDecodedValues
63
+
64
+ // Workaround: subscript doesn't handle null keyword correctly
65
+ // Replace null with placeholder and inject via context (but not inside quoted strings)
66
+ const hasNull = /\bnull\b/.test(processedExpression)
67
+ if (hasNull) {
68
+ processedExpression = replaceOutsideQuotes(processedExpression, 'null', '__NULL__')
69
+ }
70
+
71
+ // Build context with null and any decoded values
72
+ /** @type {Record<string, unknown>} */
73
+ const context = { ...valueContext }
74
+ if (hasNull) {
75
+ context.__NULL__ = null
76
+ }
77
+
78
+ // Wrap comparisons in parens for correct precedence with && / ||
79
+ processedExpression = wrapComparisons(processedExpression)
80
+
81
+ if (process.env.DEBUG_EVAL) console.log('eval processed:', processedExpression)
25
82
  const fn = subscript(processedExpression)
26
- const result = fn()
83
+ const result = fn(Object.keys(context).length > 0 ? context : undefined)
27
84
  return result
28
85
  } catch (error) {
29
86
  throw new Error(`Error evaluating expression "${expression}": ${error.message}`)
@@ -33,6 +90,7 @@ async function getValueFromEval(variableString) {
33
90
  module.exports = {
34
91
  type: 'eval',
35
92
  source: 'readonly',
93
+ encodeValue,
36
94
  description: '${eval(expression)} - Evaluates mathematical expressions',
37
95
  match: evalRefSyntax,
38
96
  resolver: getValueFromEval
@@ -258,7 +258,7 @@ ${JSON.stringify(options.context, null, 2)}`,
258
258
 
259
259
  /* handle case for referencing raw JS files to inline them */
260
260
  if (argsToPass.length
261
- && (argsToPass && argsToPass[0] && argsToPass[0].toLowerCase() === 'raw')
261
+ && (argsToPass && argsToPass[0] && typeof argsToPass[0] === 'string' && argsToPass[0].toLowerCase() === 'raw')
262
262
  || opts.asRawText
263
263
  ) {
264
264
  // Encode foo() to foo__PH_PAREN_OPEN__) to avoid function collisions
@@ -0,0 +1,75 @@
1
+ /* ${if(...)} syntax - alias for eval() with more intuitive name for conditionals */
2
+ const { resolver: evalResolver } = require('./valueFromEval')
3
+ const { findOutsideQuotes } = require('../utils/strings/quoteAware')
4
+
5
+ // Match both:
6
+ // if(condition ? trueVal : falseVal) - ternary inside
7
+ // if(condition) ? trueVal : falseVal - ternary outside
8
+ const ifRefSyntax = RegExp(/^if\s*\(.*\)(\s*\?.*)?/g)
9
+
10
+ async function getValueFromIf(variableString) {
11
+ if (process.env.DEBUG_IF) console.log('if resolver input:', variableString)
12
+
13
+ // Validate: check for empty condition
14
+ const emptyConditionMatch = variableString.match(/^if\s*\(\s*\)/)
15
+ if (emptyConditionMatch) {
16
+ throw new Error('Empty condition in ${if()}. Expected: ${if(condition) ? trueVal : falseVal}')
17
+ }
18
+
19
+ // Check for external ternary: if(condition) ? trueVal : falseVal
20
+ // Must properly balance parentheses to find where if() ends
21
+ const match = variableString.match(/^if\s*\(/)
22
+ if (match) {
23
+ const afterIf = variableString.substring(match[0].length)
24
+ let depth = 1
25
+ let i = 0
26
+
27
+ // Find the matching closing paren
28
+ while (i < afterIf.length && depth > 0) {
29
+ if (afterIf[i] === '(') depth++
30
+ else if (afterIf[i] === ')') depth--
31
+ if (depth > 0) i++
32
+ }
33
+
34
+ if (depth === 0) {
35
+ // Check what comes after the if() block
36
+ const afterCondition = afterIf.substring(i + 1).trim()
37
+
38
+ if (afterCondition.startsWith('?')) {
39
+ // External ternary: if(condition) ? trueVal : falseVal
40
+ const condition = afterIf.substring(0, i)
41
+ const ternaryPart = afterCondition.substring(1).trim() // after ?
42
+
43
+ // Find the colon separating trueVal and falseVal (outside quotes and encoded patterns)
44
+ const colonIdx = findOutsideQuotes(ternaryPart, (str, idx) => {
45
+ if (str[idx] !== ':') return 0
46
+ // Skip colons inside encoded patterns __OBJ:...__ or __ARR:...__
47
+ const before = str.substring(0, idx)
48
+ if (/__(?:OBJ|ARR|VAL\d+)$/.test(before)) return 0
49
+ return 1
50
+ })
51
+
52
+ if (colonIdx !== -1) {
53
+ const trueVal = ternaryPart.substring(0, colonIdx).trim()
54
+ const falseVal = ternaryPart.substring(colonIdx + 1).trim()
55
+ const expression = `(${condition}) ? ${trueVal} : ${falseVal}`
56
+ if (process.env.DEBUG_IF) console.log('if resolver external ternary:', expression)
57
+ return evalResolver(`eval(${expression})`)
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ // Standard syntax: if(condition ? trueVal : falseVal) or if(boolExpr)
64
+ const converted = variableString.replace(/^if\s*\(/, 'eval(')
65
+ if (process.env.DEBUG_IF) console.log('if resolver standard syntax:', converted)
66
+ return evalResolver(converted)
67
+ }
68
+
69
+ module.exports = {
70
+ type: 'if',
71
+ source: 'readonly',
72
+ description: '${if(condition) ? "yes" : "no"} - Conditional expressions',
73
+ match: ifRefSyntax,
74
+ resolver: getValueFromIf
75
+ }
@@ -0,0 +1,66 @@
1
+ /* Tests for ${if(...)} syntax - alias for eval */
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const configorama = require('../../src')
5
+
6
+ test('if() basic ternary', async () => {
7
+ const result = await configorama({
8
+ yes: '${if(5 > 3 ? "yes" : "no")}',
9
+ no: '${if(3 > 5 ? "yes" : "no")}'
10
+ })
11
+ assert.is(result.yes, 'yes')
12
+ assert.is(result.no, 'no')
13
+ })
14
+
15
+ test('if() with parentheses around condition', async () => {
16
+ const result = await configorama({
17
+ result: '${if((10 < 20) ? "smaller" : "bigger")}'
18
+ })
19
+ assert.is(result.result, 'smaller')
20
+ })
21
+
22
+ test('if() boolean result', async () => {
23
+ const result = await configorama({
24
+ isTrue: '${if(10 == 10)}',
25
+ isFalse: '${if(10 == 5)}'
26
+ })
27
+ assert.is(result.isTrue, true)
28
+ assert.is(result.isFalse, false)
29
+ })
30
+
31
+ test('if() with variables', async () => {
32
+ const result = await configorama({
33
+ threshold: 50,
34
+ value: 75,
35
+ status: '${if(${self:value} > ${self:threshold} ? "above" : "below")}'
36
+ })
37
+ assert.is(result.status, 'above')
38
+ })
39
+
40
+ test('if() nested ternary', async () => {
41
+ const result = await configorama({
42
+ score: 85,
43
+ grade: '${if(${self:score} >= 90 ? "A" : ${self:score} >= 80 ? "B" : "C")}'
44
+ })
45
+ assert.is(result.grade, 'B')
46
+ })
47
+
48
+ test('if() with logical operators', async () => {
49
+ const result = await configorama({
50
+ both: '${if(true && true)}',
51
+ either: '${if(false || true)}',
52
+ neither: '${if(false && false)}'
53
+ })
54
+ assert.is(result.both, true)
55
+ assert.is(result.either, true)
56
+ assert.is(result.neither, false)
57
+ })
58
+
59
+ test('if() arithmetic in condition', async () => {
60
+ const result = await configorama({
61
+ result: '${if((5 + 5) > 8 ? "big" : "small")}'
62
+ })
63
+ assert.is(result.result, 'big')
64
+ })
65
+
66
+ test.run()
@@ -1,6 +1,9 @@
1
1
  const isNumber = require('lodash.isnumber')
2
2
 
3
3
  function isNumberVariable(variableString) {
4
+ if (!variableString || variableString.trim().length === 0) {
5
+ return false
6
+ }
4
7
  const num = Number(variableString)
5
8
  return !isNaN(num) && isNumber(num)
6
9
  }
@@ -24,10 +24,9 @@ Exit received. Waiting for current operation to finish...
24
24
  // Clean up readline interface when done
25
25
  process.once('exit', () => rl.close())
26
26
 
27
- // Use once() instead of on()
28
- rl.once('SIGINT', () => process.emit('SIGINT'))
29
- rl.once('SIGTERM', () => process.emit('SIGTERM'))
30
- rl.once('SIGBREAK', () => process.emit('SIGBREAK'))
27
+ rl.on('SIGINT', () => process.emit('SIGINT'))
28
+ rl.on('SIGTERM', () => process.emit('SIGTERM'))
29
+ rl.on('SIGBREAK', () => process.emit('SIGBREAK'))
31
30
  }
32
31
 
33
32
  // Remove any existing listeners before adding new ones
@@ -47,26 +47,37 @@ function set(object, path, value) {
47
47
  return object;
48
48
  }
49
49
 
50
+ // Cache for trim regex patterns (perf: avoid recompilation)
51
+ const trimRegexCache = new Map()
52
+
50
53
  // Custom implementation of lodash.trim
51
54
  function trim(string, chars) {
52
55
  if (string === null || string === undefined) {
53
56
  return '';
54
57
  }
55
-
58
+
56
59
  string = String(string);
57
-
60
+
58
61
  if (!chars && String.prototype.trim) {
59
62
  return string.trim();
60
63
  }
61
-
64
+
62
65
  if (!chars) {
63
66
  // Default characters to trim (whitespace)
64
67
  chars = ' \t\n\r\f\v\u00a0\u1680\u2000\u200a\u2028\u2029\u202f\u205f\u3000\ufeff';
65
68
  }
66
-
67
- // Create a regex pattern with the characters to trim
68
- const pattern = new RegExp(`^[${chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')}]+|[${chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')}]+$`, 'g');
69
-
69
+
70
+ // Check cache first
71
+ let pattern = trimRegexCache.get(chars)
72
+ if (!pattern) {
73
+ // Create and cache regex pattern with the characters to trim
74
+ const escaped = chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
75
+ pattern = new RegExp(`^[${escaped}]+|[${escaped}]+$`, 'g')
76
+ trimRegexCache.set(chars, pattern)
77
+ }
78
+
79
+ // Reset lastIndex for global regex reuse
80
+ pattern.lastIndex = 0
70
81
  return string.replace(pattern, '');
71
82
  }
72
83
 
@@ -1,7 +1,6 @@
1
1
  const YAML = require('js-yaml');
2
2
  const includes = require('lodash.includes');
3
3
  const isString = require('lodash.isstring');
4
- const split = require('lodash.split');
5
4
  const flatten = require('lodash.flatten');
6
5
  const map = require('lodash.map');
7
6
 
@@ -67,7 +66,7 @@ const createSchema = () => {
67
66
  map(['mapping', 'scalar', 'sequence'], kind => yamlType(functionName, kind))
68
67
  )
69
68
  );
70
- return YAML.Schema.create(types);
69
+ return YAML.Schema.create(YAML.DEFAULT_SAFE_SCHEMA, types);
71
70
  };
72
71
 
73
72
  module.exports = {
@@ -233,4 +233,18 @@ test('!EachMemberIn - member inclusion check', () => {
233
233
  assert.equal(parsed.Value['Fn::EachMemberIn'], [['a', 'b'], ['a', 'b', 'c']])
234
234
  })
235
235
 
236
+ // ==========================================
237
+ // Security - Unsafe YAML tags should be blocked
238
+ // ==========================================
239
+
240
+ test('security - !!js/function should be rejected', () => {
241
+ const maliciousYaml = `Handler: !!js/function 'function() { return "pwned"; }'`
242
+ assert.throws(() => parseCfYaml(maliciousYaml), /unknown tag/)
243
+ })
244
+
245
+ test('security - !!js/regexp should be rejected', () => {
246
+ const maliciousYaml = `Pattern: !!js/regexp /test/`
247
+ assert.throws(() => parseCfYaml(maliciousYaml), /unknown tag/)
248
+ })
249
+
236
250
  test.run()
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Preprocesses config to fix malformed fallback references
3
- * and escape variables inside help() filter arguments
2
+ * Preprocesses config to fix malformed fallback references,
3
+ * escape variables inside help() filter arguments,
4
+ * and convert bare references in if() expressions
4
5
  */
5
6
  const { splitByComma } = require('../strings/splitByComma')
7
+ const { getQuoteRanges } = require('../strings/quoteAware')
6
8
  const { extractVariableWrapper } = require('../variables/variableUtils')
7
9
 
8
10
  /**
@@ -10,9 +12,12 @@ const { extractVariableWrapper } = require('../variables/variableUtils')
10
12
  * @param {Object} configObject - The parsed configuration object
11
13
  * @param {RegExp} variableSyntax - The variable syntax regex to use
12
14
  * @param {Array} [variableTypes] - Array of variable type definitions with type/prefix fields
15
+ * @param {Object} [options] - Options for preprocessing
16
+ * @param {boolean} [options.skipFallbackFix] - Skip fixing malformed fallbacks (for object configs)
13
17
  * @returns {Object} The preprocessed configuration object
14
18
  */
15
- function preProcess(configObject, variableSyntax, variableTypes) {
19
+ function preProcess(configObject, variableSyntax, variableTypes, options = {}) {
20
+ const { skipFallbackFix = false } = options
16
21
  // Extract prefix/suffix from variable syntax for reconstructing variables
17
22
  const { prefix: varPrefix, suffix: varSuffix } = variableSyntax
18
23
  ? extractVariableWrapper(variableSyntax.source)
@@ -51,6 +56,214 @@ function preProcess(configObject, variableSyntax, variableTypes) {
51
56
  })
52
57
  }
53
58
 
59
+ /**
60
+ * Convert bare config references inside if() expressions to ${...} syntax
61
+ * Also wraps unquoted ${...} refs in quotes for proper string comparison
62
+ * e.g., ${if(provider.stage === "prod")} => ${if("${provider.stage}" === "prod")}
63
+ * e.g., ${if(${provider.stage} === "prod")} => ${if("${provider.stage}" === "prod")}
64
+ * @param {string} str - String potentially containing if() expressions
65
+ * @returns {string} String with bare refs converted
66
+ */
67
+ function convertBareRefsInIf(str) {
68
+ if (typeof str !== 'string') return str
69
+
70
+ const reserved = ['true', 'false', 'null', 'undefined', 'NaN', 'Infinity']
71
+ const prefixLen = varPrefix.length
72
+ const suffixLen = varSuffix.length
73
+
74
+ // Find if( blocks and process them
75
+ let result = str
76
+ let i = 0
77
+
78
+ while (i < result.length) {
79
+ // Look for ${if( or similar with custom prefix
80
+ const ifStart = result.indexOf(varPrefix + 'if(', i)
81
+ if (ifStart === -1) break
82
+
83
+ // Find the matching closing suffix by counting nested prefixes/suffixes
84
+ const contentStart = ifStart + prefixLen + 3 // after "${if("
85
+ let depth = 1
86
+ let j = contentStart
87
+
88
+ while (j < result.length && depth > 0) {
89
+ if (result.substring(j, j + prefixLen) === varPrefix) {
90
+ depth++
91
+ j += prefixLen
92
+ } else if (result.substring(j, j + suffixLen) === varSuffix) {
93
+ depth--
94
+ if (depth > 0) j += suffixLen
95
+ } else {
96
+ j++
97
+ }
98
+ }
99
+
100
+ if (depth === 0) {
101
+ // Extract the if content (everything between "if(" and the final ")")
102
+ const fullContent = result.substring(contentStart, j)
103
+
104
+ // Process the content: wrap bare refs and unquoted var refs in quotes
105
+ let processed = fullContent
106
+
107
+ // 1. First convert bare refs (word.word or word:word) to quoted var refs
108
+ // Must do this BEFORE handling ${...} to avoid double-wrapping
109
+ // Pattern excludes refs inside ${...} by using negative lookbehind for varPrefix
110
+ const escapedPrefix = varPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
111
+ const bareRefPattern = new RegExp(
112
+ `(?<!${escapedPrefix}[^${varSuffix}]*)(?<!")(?<!')(?<=^|[^.\\w])([a-zA-Z_][a-zA-Z0-9_]*(?:[.:][a-zA-Z_][a-zA-Z0-9_]*)+)(?![.\\w])`,
113
+ 'g'
114
+ )
115
+
116
+ // Simpler approach: find bare refs that are NOT inside ${...}
117
+ // Build list of ${...} ranges to exclude
118
+ const varRanges = []
119
+ let pos = 0
120
+ while (pos < processed.length) {
121
+ if (processed.substring(pos, pos + prefixLen) === varPrefix) {
122
+ const start = pos
123
+ let varDepth = 1
124
+ pos += prefixLen
125
+ while (pos < processed.length && varDepth > 0) {
126
+ if (processed.substring(pos, pos + prefixLen) === varPrefix) {
127
+ varDepth++
128
+ pos += prefixLen
129
+ } else if (processed.substring(pos, pos + suffixLen) === varSuffix) {
130
+ varDepth--
131
+ if (varDepth > 0) pos += suffixLen
132
+ } else {
133
+ pos++
134
+ }
135
+ }
136
+ pos += suffixLen
137
+ varRanges.push([start, pos])
138
+ } else {
139
+ pos++
140
+ }
141
+ }
142
+
143
+ // Build list of quoted string ranges to exclude
144
+ const quoteRanges = getQuoteRanges(fullContent)
145
+
146
+ // Comparison operators for detecting string comparison context
147
+ const comparisonOps = ['===', '!==', '==', '!=']
148
+
149
+ // Find and replace bare refs, skipping those inside ${...} or quoted strings
150
+ // Only quote bare refs that are in string comparison context
151
+ const simpleBarePat = /([a-zA-Z_][a-zA-Z0-9_]*(?:[.:][a-zA-Z_][a-zA-Z0-9_]*)+)/g
152
+ let offset = 0
153
+ let match
154
+ while ((match = simpleBarePat.exec(fullContent)) !== null) {
155
+ const bareRef = match[1]
156
+ const matchStart = match.index
157
+ const matchEnd = matchStart + bareRef.length
158
+
159
+ // Skip if inside a ${...} range
160
+ const insideVar = varRanges.some(([s, e]) => matchStart >= s && matchEnd <= e)
161
+ if (insideVar) continue
162
+
163
+ // Skip if inside a quoted string
164
+ const insideQuote = quoteRanges.some(([s, e]) => matchStart >= s && matchEnd <= e)
165
+ if (insideQuote) continue
166
+
167
+ // Skip reserved words
168
+ if (reserved.includes(bareRef)) continue
169
+
170
+ // Check if this ref is in a string comparison context
171
+ const afterRef = fullContent.substring(matchEnd).trimStart()
172
+ const beforeRef = fullContent.substring(0, matchStart).trimEnd()
173
+
174
+ const isComparedToString = comparisonOps.some(op => {
175
+ // Check if followed by: op "string"
176
+ if (afterRef.startsWith(op)) {
177
+ const afterOp = afterRef.substring(op.length).trimStart()
178
+ return afterOp.startsWith('"') || afterOp.startsWith("'")
179
+ }
180
+ // Check if preceded by: "string" op
181
+ for (const o of comparisonOps) {
182
+ const pattern = new RegExp(`["'][^"']*["']\\s*${o.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`)
183
+ if (pattern.test(beforeRef)) return true
184
+ }
185
+ return false
186
+ })
187
+
188
+ // Replace with var ref - quoted if string comparison, unquoted otherwise
189
+ const replacement = isComparedToString
190
+ ? `"${varPrefix}${bareRef}${varSuffix}"`
191
+ : `${varPrefix}${bareRef}${varSuffix}`
192
+ processed = processed.substring(0, matchStart + offset) + replacement + processed.substring(matchEnd + offset)
193
+ offset += replacement.length - bareRef.length
194
+ }
195
+
196
+ // 2. Quote unquoted ${...} refs that are used in string comparisons
197
+ // Pattern: ref followed by comparison operator and string, or string followed by operator and ref
198
+ // e.g., ${foo} === "bar" or "bar" === ${foo}
199
+ // Find ${...} refs that are in comparison context
200
+ pos = 0
201
+ let newProcessed = ''
202
+ while (pos < processed.length) {
203
+ if (processed.substring(pos, pos + prefixLen) === varPrefix) {
204
+ const precededByQuote = pos > 0 && processed[pos - 1] === '"'
205
+
206
+ // Find matching suffix
207
+ let varDepth = 1
208
+ let endPos = pos + prefixLen
209
+ while (endPos < processed.length && varDepth > 0) {
210
+ if (processed.substring(endPos, endPos + prefixLen) === varPrefix) {
211
+ varDepth++
212
+ endPos += prefixLen
213
+ } else if (processed.substring(endPos, endPos + suffixLen) === varSuffix) {
214
+ varDepth--
215
+ if (varDepth > 0) endPos += suffixLen
216
+ } else {
217
+ endPos++
218
+ }
219
+ }
220
+ endPos += suffixLen
221
+
222
+ const varRef = processed.substring(pos, endPos)
223
+ const followedByQuote = endPos < processed.length && processed[endPos] === '"'
224
+
225
+ // Check if this ref is in a string comparison context
226
+ const afterRef = processed.substring(endPos).trimStart()
227
+ const beforeRef = processed.substring(0, pos).trimEnd()
228
+
229
+ const isComparedToString = comparisonOps.some(op => {
230
+ // Check if followed by: op "string"
231
+ if (afterRef.startsWith(op)) {
232
+ const afterOp = afterRef.substring(op.length).trimStart()
233
+ return afterOp.startsWith('"') || afterOp.startsWith("'")
234
+ }
235
+ // Check if preceded by: "string" op
236
+ for (const o of comparisonOps) {
237
+ const pattern = new RegExp(`["'][^"']*["']\\s*${o.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`)
238
+ if (pattern.test(beforeRef)) return true
239
+ }
240
+ return false
241
+ })
242
+
243
+ if (!precededByQuote && !followedByQuote && isComparedToString) {
244
+ newProcessed += '"' + varRef + '"'
245
+ } else {
246
+ newProcessed += varRef
247
+ }
248
+ pos = endPos
249
+ } else {
250
+ newProcessed += processed[pos]
251
+ pos++
252
+ }
253
+ }
254
+ processed = newProcessed
255
+
256
+ // Reconstruct
257
+ result = result.substring(0, contentStart) + processed + result.substring(j)
258
+ i = contentStart + processed.length + suffixLen
259
+ } else {
260
+ i = ifStart + prefixLen
261
+ }
262
+ }
263
+
264
+ return result
265
+ }
266
+
54
267
  /**
55
268
  * Fix malformed fallback references in a string
56
269
  * @param {string} str - String potentially containing variables
@@ -154,9 +367,11 @@ function preProcess(configObject, variableSyntax, variableTypes) {
154
367
  */
155
368
  function traverseAndFix(obj) {
156
369
  if (typeof obj === 'string') {
157
- // First escape help() variables, then fix fallbacks
370
+ // First escape help() variables, convert bare refs in if(), then fix fallbacks
158
371
  const withHelpEscaped = escapeHelpVariables(obj)
159
- return fixFallbacksInString(withHelpEscaped)
372
+ const withBareRefsConverted = convertBareRefsInIf(withHelpEscaped)
373
+ // Skip fallback fixing for object configs (they handle bare refs differently)
374
+ return skipFallbackFix ? withBareRefsConverted : fixFallbacksInString(withBareRefsConverted)
160
375
  }
161
376
 
162
377
  if (Array.isArray(obj)) {
@@ -19,8 +19,12 @@ function resolveFilePath(pathToResolve, basePath) {
19
19
  fullFilePath = fs.realpathSync(fullFilePath)
20
20
  // Only use findUp for relative paths (not absolute paths)
21
21
  } else if (!path.isAbsolute(pathToResolve)) {
22
- const cleanName = path.basename(pathToResolve)
23
- const findUpResult = findUp.sync(cleanName, { cwd: basePath })
22
+ // Strip ./ and ../ prefixes for findUp, but preserve directory structure like utils/
23
+ let searchPath = pathToResolve
24
+ while (searchPath.startsWith('./') || searchPath.startsWith('../')) {
25
+ searchPath = searchPath.replace(/^\.\.?\//, '')
26
+ }
27
+ const findUpResult = findUp.sync(searchPath, { cwd: basePath })
24
28
  if (findUpResult) {
25
29
  fullFilePath = findUpResult
26
30
  }
@@ -109,6 +109,24 @@ test('resolveFilePath - relative path without ./ prefix triggers findUp', () =>
109
109
  assert.is(result, expected)
110
110
  })
111
111
 
112
+ test('resolveFilePath - preserves directory structure when using findUp', () => {
113
+ // Create additional structure for this test:
114
+ // _test-getFullFilePath/
115
+ // config.yml <- WRONG file
116
+ // utils/
117
+ // config.yml <- CORRECT file
118
+ // subdir/deepdir/ <- searching from here
119
+ const utilsDir = path.join(testDir, 'utils')
120
+ fs.mkdirSync(utilsDir, { recursive: true })
121
+ fs.writeFileSync(path.join(utilsDir, 'config.yml'), 'correct: true')
122
+
123
+ // From deepDir, request "utils/config.yml" - should find testDir/utils/config.yml
124
+ const result = resolveFilePath('utils/config.yml', deepDir)
125
+ const expected = path.join(utilsDir, 'config.yml')
126
+ assert.is(result, expected,
127
+ `Should preserve 'utils/' directory and find utils/config.yml, not root config.yml. Got ${result}`)
128
+ })
129
+
112
130
  // ==========================================
113
131
  // getFullPath - wrapper function
114
132
  // ==========================================