configorama 0.6.12 → 0.6.14

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 (66) hide show
  1. package/README.md +196 -24
  2. package/cli.js +3 -3
  3. package/package.json +1 -1
  4. package/src/index.js +22 -32
  5. package/src/main.js +690 -778
  6. package/src/parsers/yaml.js +3 -47
  7. package/src/resolvers/valueFromCron.js +3 -1
  8. package/src/resolvers/valueFromEnv.js +1 -0
  9. package/src/resolvers/valueFromEval.js +1 -0
  10. package/src/resolvers/valueFromFile.js +423 -0
  11. package/src/resolvers/valueFromGit.js +3 -2
  12. package/src/resolvers/valueFromOptions.js +1 -0
  13. package/src/resolvers/valueFromString.js +2 -1
  14. package/src/sync.js +12 -5
  15. package/src/utils/parsing/arrayToJsonPath.test.js +56 -0
  16. package/src/utils/{enrichMetadata.js → parsing/enrichMetadata.js} +210 -18
  17. package/src/utils/{parse.js → parsing/parse.js} +13 -13
  18. package/src/utils/parsing/preProcess.js +165 -0
  19. package/src/utils/{filePathUtils.js → paths/filePathUtils.js} +3 -2
  20. package/src/utils/paths/findLineForKey.js +47 -0
  21. package/src/utils/paths/findLineForKey.test.js +126 -0
  22. package/src/utils/{getFullFilePath.js → paths/getFullFilePath.js} +24 -26
  23. package/src/utils/{resolveAlias.js → paths/resolveAlias.js} +1 -1
  24. package/src/utils/regex/index.js +23 -1
  25. package/src/utils/resolution/preResolveVariable.js +260 -0
  26. package/src/utils/resolution/preResolveVariable.test.js +98 -0
  27. package/src/utils/strings/bracketMatcher.js +86 -0
  28. package/src/utils/strings/bracketMatcher.test.js +135 -0
  29. package/src/utils/{formatFunctionArgs.js → strings/formatFunctionArgs.js} +3 -2
  30. package/src/utils/strings/formatFunctionArgs.test.js +77 -0
  31. package/src/utils/strings/quoteUtils.js +89 -0
  32. package/src/utils/strings/quoteUtils.test.js +217 -0
  33. package/src/utils/strings/replaceAll.test.js +82 -0
  34. package/src/utils/{splitByComma.js → strings/splitByComma.js} +1 -1
  35. package/src/utils/strings/splitCsv.js +38 -0
  36. package/src/utils/strings/splitCsv.test.js +96 -0
  37. package/src/utils/strings/textUtils.test.js +86 -0
  38. package/src/utils/{configWizard.js → ui/configWizard.js} +177 -38
  39. package/src/utils/{createEditorLink.js → ui/createEditorLink.js} +11 -2
  40. package/src/utils/{logs.js → ui/logs.js} +3 -3
  41. package/src/utils/validation/isValidValue.test.js +64 -0
  42. package/src/utils/validation/warnIfNotFound.js +52 -0
  43. package/src/utils/variables/appendDeepVariable.test.js +41 -0
  44. package/src/utils/{cleanVariable.js → variables/cleanVariable.js} +5 -26
  45. package/src/utils/{find-nested-variables.js → variables/findNestedVariables.js} +2 -2
  46. package/src/utils/{find-nested-variables.test.js → variables/findNestedVariables.test.js} +5 -5
  47. package/src/utils/variables/getVariableType.test.js +109 -0
  48. package/src/utils/variables/variableUtils.test.js +117 -0
  49. package/src/utils/isValidValue.js +0 -8
  50. package/src/utils/splitCsv.js +0 -29
  51. package/src/utils/trimSurroundingQuotes.js +0 -5
  52. /package/src/utils/{arrayToJsonPath.js → parsing/arrayToJsonPath.js} +0 -0
  53. /package/src/utils/{cloudformationSchema.js → parsing/cloudformationSchema.js} +0 -0
  54. /package/src/utils/{mergeByKeys.js → parsing/mergeByKeys.js} +0 -0
  55. /package/src/utils/{filePathUtils.test.js → paths/filePathUtils.test.js} +0 -0
  56. /package/src/utils/{find-project-root.js → paths/findProjectRoot.js} +0 -0
  57. /package/src/utils/{resolveAlias.test.js → paths/resolveAlias.test.js} +0 -0
  58. /package/src/utils/{replaceAll.js → strings/replaceAll.js} +0 -0
  59. /package/src/utils/{splitByComma.test.js → strings/splitByComma.test.js} +0 -0
  60. /package/src/utils/{textUtils.js → strings/textUtils.js} +0 -0
  61. /package/src/utils/{chalk.js → ui/chalk.js} +0 -0
  62. /package/src/utils/{deep-log.js → ui/deep-log.js} +0 -0
  63. /package/src/utils/{appendDeepVariable.js → variables/appendDeepVariable.js} +0 -0
  64. /package/src/utils/{cleanVariable.test.js → variables/cleanVariable.test.js} +0 -0
  65. /package/src/utils/{getVariableType.js → variables/getVariableType.js} +0 -0
  66. /package/src/utils/{variableUtils.js → variables/variableUtils.js} +0 -0
@@ -1,7 +1,8 @@
1
1
  const dotProp = require('dot-prop')
2
2
  const fs = require('fs')
3
3
  const path = require('path')
4
- const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('./filePathUtils')
4
+ const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('../paths/filePathUtils')
5
+ const { preResolveString, preResolveSingle } = require('../resolution/preResolveVariable')
5
6
 
6
7
  // Type filters that indicate expected value types
7
8
  const TYPE_FILTERS = ['Boolean', 'String', 'Number', 'Array', 'Object', 'Json']
@@ -87,6 +88,18 @@ function createOccurrence(instance, varMatch, options = {}) {
87
88
  return occurrence
88
89
  }
89
90
 
91
+ /**
92
+ * Get source type for a variable type
93
+ * @param {string} variableType - The variable type (e.g., 'env', 'opt', 'git')
94
+ * @param {Array} variableTypes - Array of variable type definitions
95
+ * @returns {string|undefined} The source type ('user', 'config', 'readonly', 'remote')
96
+ */
97
+ function getSourceForType(variableType, variableTypes) {
98
+ if (!variableTypes || !variableType) return undefined
99
+ const typeDef = variableTypes.find(vt => vt.type === variableType)
100
+ return typeDef?.source
101
+ }
102
+
90
103
  /**
91
104
  * Enriches variable metadata with resolution tracking data.
92
105
  * @param {object} metadata - The metadata object from collectVariableMetadata.
@@ -96,21 +109,31 @@ function createOccurrence(instance, varMatch, options = {}) {
96
109
  * @param {object} originalConfig - The original config object (before resolution) for self/dot.prop lookups.
97
110
  * @param {string} configPath - The path to the config file.
98
111
  * @param {Array} filterNames - Array of known filter names.
99
- * @returns {object} Enriched metadata with resolution details and a complete file reference list.
112
+ * @param {object} [resolvedConfig] - The resolved config object (optional, for post-resolution enrichment).
113
+ * @param {object} [options] - CLI options object for opt: resolution.
114
+ * @param {Array} [variableTypes] - Array of variable type definitions with source info.
115
+ * @returns {Promise<object>} Enriched metadata with resolution details and a complete file reference list.
100
116
  */
101
- function enrichMetadata(
117
+ async function enrichMetadata(
102
118
  metadata,
103
119
  resolutionTracking,
104
120
  variableSyntax,
105
121
  fileRefsFound = [],
106
122
  originalConfig = {},
107
123
  configPath,
108
- filterNames = []
124
+ filterNames = [],
125
+ resolvedConfig,
126
+ options = {},
127
+ variableTypes = []
109
128
  ) {
110
129
  if (!resolutionTracking) {
111
130
  return metadata
112
131
  }
113
132
 
133
+ // Create resolve context early for resolving descriptions
134
+ const configDir = configPath ? path.dirname(configPath) : undefined
135
+ const resolveContext = { config: originalConfig, variableSyntax, configDir, options }
136
+
114
137
  const varKeys = Object.keys(metadata.variables)
115
138
 
116
139
  for (const key of varKeys) {
@@ -124,6 +147,11 @@ function enrichMetadata(
124
147
  // For each resolveDetail, find the matching resolution history entry
125
148
  for (let i = 0; i < varData.resolveDetails.length; i++) {
126
149
  const detail = varData.resolveDetails[i]
150
+ // Add source type for this variable type
151
+ const sourceType = getSourceForType(detail.variableType, variableTypes)
152
+ if (sourceType) {
153
+ detail.variableSourceType = sourceType
154
+ }
127
155
  const isOutermost = i === varData.resolveDetails.length - 1
128
156
 
129
157
  if (isOutermost && trackingData.resolutionHistory.length > 0) {
@@ -167,7 +195,8 @@ function enrichMetadata(
167
195
  varData.type = instanceType
168
196
  }
169
197
  if (instanceDescription) {
170
- varData.description = instanceDescription
198
+ // Resolve any variables in the description (e.g., ${allowedValues} -> actual values)
199
+ varData.description = await preResolveString(instanceDescription, resolveContext)
171
200
  }
172
201
  }
173
202
  }
@@ -275,26 +304,37 @@ function enrichMetadata(
275
304
  }
276
305
  }
277
306
 
307
+ // Add overrideFilePath to references if file was overridden
308
+ for (const refData of references) {
309
+ const details = fileDetailsMap.get(refData.resolvedPath)
310
+ if (details && details.wasOverridden) {
311
+ refData.overrideFilePath = details.filePath
312
+ }
313
+ }
314
+
278
315
  const byConfigPath = []
279
316
  if (references.length > 0) {
280
317
  for (const resolvedFileData of references) {
281
318
  const details = fileDetailsMap.get(resolvedFileData.resolvedPath)
282
319
  if (details) {
283
320
  for (const ref of resolvedFileData.refs) {
321
+ // Use original file path if overridden, otherwise use the resolved path
322
+ const filePath = details.wasOverridden ? details.originalFilePath : details.filePath
284
323
  const confDetails = {
285
324
  location: ref.location,
286
- filePath: details.filePath,
325
+ filePath: filePath,
287
326
  relativePath: details.relativePath,
288
327
  originalVariableString: ref.originalVariableString,
289
328
  resolvedVariableString: details.resolvedVariableString,
290
329
  containsVariables: !!ref.hasInnerVariable,
291
330
  exists: details.exists,
292
- // Get glob patterns from the individual ref, default to empty array
293
-
294
331
  }
295
332
  if (ref.pattern) {
296
333
  confDetails.pattern = ref.pattern
297
334
  }
335
+ if (details.wasOverridden) {
336
+ confDetails.overrideFilePath = details.filePath
337
+ }
298
338
  byConfigPath.push(confDetails)
299
339
  }
300
340
  }
@@ -309,6 +349,25 @@ function enrichMetadata(
309
349
  if (references.length > 0) {
310
350
  metadata.fileDependencies.references = references
311
351
  }
352
+
353
+ // Collect unique overridden files from fileRefsFound
354
+ const overriddenFilesMap = new Map()
355
+ for (const ref of fileRefsFound) {
356
+ if (ref.wasOverridden) {
357
+ // Use originalFilePath as key to dedupe
358
+ if (!overriddenFilesMap.has(ref.originalFilePath)) {
359
+ overriddenFilesMap.set(ref.originalFilePath, {
360
+ originalPath: ref.originalFilePath,
361
+ overridePath: ref.filePath,
362
+ relativePath: ref.relativePath,
363
+ })
364
+ }
365
+ }
366
+ }
367
+
368
+ if (overriddenFilesMap.size > 0) {
369
+ metadata.fileDependencies.overriddenFiles = Array.from(overriddenFilesMap.values())
370
+ }
312
371
  }
313
372
 
314
373
  // Build uniqueVariables rollup - group by base variable (without fallbacks)
@@ -317,11 +376,32 @@ function enrichMetadata(
317
376
  for (const key of varKeys) {
318
377
  const varInstances = metadata.variables[key]
319
378
  const firstInstance = varInstances[0]
320
- const lastResolveDetail = firstInstance.resolveDetails[firstInstance.resolveDetails.length - 1]
379
+
380
+ // Extract variable name from key (e.g. "${self:service}" -> "self:service")
381
+ const keyVarName = key.slice(2, -1).split(',')[0].trim()
382
+
383
+ // Find the resolveDetail that matches THIS key's variable (not just outermost)
384
+ let matchingDetail = null
385
+ for (const instance of varInstances) {
386
+ if (instance.resolveDetails && instance.resolveDetails.length > 0) {
387
+ const found = instance.resolveDetails.find((detail) => {
388
+ const detailVar = detail.valueBeforeFallback || detail.variable
389
+ return detailVar === keyVarName
390
+ })
391
+ if (found) {
392
+ matchingDetail = found
393
+ break
394
+ }
395
+ }
396
+ }
397
+ // Fallback to last resolveDetail if no match found
398
+ if (!matchingDetail) {
399
+ matchingDetail = firstInstance.resolveDetails[firstInstance.resolveDetails.length - 1]
400
+ }
321
401
 
322
402
  // Get the base variable name without fallback
323
403
  // Use valueBeforeFallback if present, otherwise use the variable string
324
- let baseVar = lastResolveDetail.valueBeforeFallback || lastResolveDetail.variable
404
+ let baseVar = matchingDetail.valueBeforeFallback || matchingDetail.variable
325
405
 
326
406
  // Strip filters from baseVar using known filter names
327
407
  // e.g., "opt:stage | toUpperCase | help(...)" -> "opt:stage"
@@ -344,7 +424,8 @@ function enrichMetadata(
344
424
  if (!uniqueVariablesMap.has(baseVar)) {
345
425
  uniqueVariablesMap.set(baseVar, {
346
426
  variable: baseVar,
347
- variableType: lastResolveDetail.variableType,
427
+ variableType: matchingDetail.variableType,
428
+ variableSourceType: getSourceForType(matchingDetail.variableType, variableTypes),
348
429
  occurrences: [],
349
430
  innerVariables: [],
350
431
  })
@@ -365,9 +446,25 @@ function enrichMetadata(
365
446
  // The outermost variable is the last one in resolveDetails
366
447
  const outermostDetail = instance.resolveDetails[instance.resolveDetails.length - 1]
367
448
 
449
+ // Find position of first | (filter separator) in the outermost variable's original string
450
+ // Variables after this position are filter-internal (e.g., inside help('...${var}...'))
451
+ const originalStr = outermostDetail.originalStringValue || ''
452
+ const outerVarStart = outermostDetail.start || 0
453
+ // Find first | that's inside the outermost variable (after its opening ${)
454
+ const filterSeparatorPos = originalStr.indexOf('|', outerVarStart + 2)
455
+
368
456
  for (let i = 0; i < instance.resolveDetails.length - 1; i++) {
369
457
  const detail = instance.resolveDetails[i]
370
458
 
459
+ // Check if this variable is inside filter arguments (after the first |)
460
+ // These are meta-variables used to build filter values, not user-configurable variables
461
+ const isInsideFilter = filterSeparatorPos !== -1 && detail.start > filterSeparatorPos
462
+ if (isInsideFilter) {
463
+ // Mark this detail as filter-internal and skip adding to uniqueVariables
464
+ detail.isFilterInnerVariable = true
465
+ continue
466
+ }
467
+
371
468
  // Check if this variable is actually INSIDE the outermost variable's boundaries
372
469
  // A variable is "inner" only if it's contained within the parent's start/end range
373
470
  const isInnerVariable = detail.start >= outermostDetail.start && detail.end <= outermostDetail.end
@@ -385,6 +482,7 @@ function enrichMetadata(
385
482
  uniqueVariablesMap.set(normalizedSiblingVar, {
386
483
  variable: normalizedSiblingVar,
387
484
  variableType: detail.variableType,
485
+ variableSourceType: getSourceForType(detail.variableType, variableTypes),
388
486
  occurrences: [],
389
487
  innerVariables: [],
390
488
  })
@@ -393,11 +491,29 @@ function enrichMetadata(
393
491
  const siblingEntry = uniqueVariablesMap.get(normalizedSiblingVar)
394
492
 
395
493
  // Add occurrence for this sibling variable
494
+ // Check if this self-reference resolves to a value in originalConfig
495
+ let siblingDefaultValue = detail.hasFallback ? (detail.fallbackValues?.[0]?.stringValue || detail.fallbackValues?.[0]?.variable) : undefined
496
+ let siblingDefaultValueSrc
497
+ let siblingIsRequired = !detail.hasFallback
498
+
499
+ if (detail.variableType === 'self' || detail.variableType === 'dot.prop') {
500
+ const varPath = (detail.valueBeforeFallback || detail.variable).replace('self:', '')
501
+ const resolvedValue = dotProp.get(originalConfig, varPath)
502
+ if (resolvedValue !== undefined) {
503
+ siblingDefaultValue = resolvedValue
504
+ siblingDefaultValueSrc = varPath
505
+ siblingIsRequired = false
506
+ }
507
+ }
508
+
396
509
  const siblingOccurrence = createOccurrence(instance, detail.varMatch, {
397
- isRequired: !detail.hasFallback,
510
+ isRequired: siblingIsRequired,
398
511
  hasFallback: !!detail.hasFallback,
399
- defaultValue: detail.hasFallback ? (detail.fallbackValues?.[0]?.stringValue || detail.fallbackValues?.[0]?.variable) : undefined,
512
+ defaultValue: siblingDefaultValue,
400
513
  })
514
+ if (siblingDefaultValueSrc) {
515
+ siblingOccurrence.defaultValueSrc = siblingDefaultValueSrc
516
+ }
401
517
 
402
518
  // Check if this exact occurrence already exists
403
519
  const occurrenceExists = siblingEntry.occurrences.some(occ =>
@@ -547,7 +663,7 @@ function enrichMetadata(
547
663
  }
548
664
 
549
665
  // Aggregate types and descriptions for each uniqueVariable
550
- for (const [, entry] of uniqueVariablesMap) {
666
+ for (const [varKey, entry] of uniqueVariablesMap) {
551
667
  // Collect unique types from occurrences
552
668
  const types = entry.occurrences
553
669
  .map(occ => occ.type)
@@ -556,18 +672,94 @@ function enrichMetadata(
556
672
  entry.types = types
557
673
  }
558
674
 
559
- // Collect unique descriptions from occurrences
560
- const descriptions = entry.occurrences
675
+ // Collect unique descriptions from occurrences and resolve any variables
676
+ const rawDescriptions = entry.occurrences
561
677
  .map(occ => occ.description)
562
678
  .filter((d, i, a) => d && a.indexOf(d) === i)
563
- if (descriptions.length > 0) {
564
- entry.descriptions = descriptions
679
+
680
+ if (rawDescriptions.length > 0) {
681
+ // Build map from raw -> resolved description
682
+ const descriptionMap = new Map()
683
+ await Promise.all(
684
+ rawDescriptions.map(async desc => {
685
+ // Resolve any variables in the description
686
+ const resolved = await preResolveString(desc, resolveContext)
687
+ descriptionMap.set(desc, resolved)
688
+ })
689
+ )
690
+
691
+ // Update each occurrence's description with resolved version
692
+ for (const occ of entry.occurrences) {
693
+ if (occ.description && descriptionMap.has(occ.description)) {
694
+ occ.description = descriptionMap.get(occ.description)
695
+ }
696
+ }
697
+
698
+ entry.descriptions = Array.from(descriptionMap.values())
699
+ }
700
+
701
+ // Try to resolve defaultValue for variables that can be pre-resolved
702
+ // This applies to git:, env:, self: variables without existing defaults
703
+ const firstOcc = entry.occurrences[0]
704
+ if (firstOcc && firstOcc.isRequired && !firstOcc.defaultValue) {
705
+ const resolvableTypes = ['git', 'env', 'self', 'dot.prop']
706
+ if (resolvableTypes.includes(entry.variableType)) {
707
+ try {
708
+ const resolved = await preResolveSingle(entry.variable, resolveContext)
709
+ if (resolved !== undefined) {
710
+ const formattedValue = Array.isArray(resolved)
711
+ ? resolved.join(', ')
712
+ : (typeof resolved === 'object' ? JSON.stringify(resolved) : String(resolved))
713
+
714
+ // Update occurrences and entry
715
+ entry.occurrences.forEach(occ => {
716
+ occ.defaultValue = formattedValue
717
+ occ.isRequired = false
718
+ })
719
+ entry.resolvedValue = formattedValue
720
+ }
721
+ } catch (e) {
722
+ // Couldn't resolve, leave as required
723
+ }
724
+ }
565
725
  }
566
726
  }
567
727
 
568
728
  // Convert map to object for metadata
569
729
  metadata.uniqueVariables = Object.fromEntries(uniqueVariablesMap)
570
730
 
731
+ // Add resolvedPropertyValue to resolutionTracking if resolvedConfig provided
732
+ if (resolvedConfig) {
733
+ metadata.resolutionHistory = {}
734
+ for (const pathKey in resolutionTracking) {
735
+ const tracking = resolutionTracking[pathKey]
736
+ const keys = pathKey.split('.')
737
+ let resolvedValue = resolvedConfig
738
+
739
+ for (const key of keys) {
740
+ if (resolvedValue && typeof resolvedValue === 'object') {
741
+ resolvedValue = resolvedValue[key]
742
+ } else {
743
+ resolvedValue = undefined
744
+ break
745
+ }
746
+ }
747
+
748
+ // Unescape any __CONFIGVAR:...__ placeholders in tracking data
749
+ // (resolutionTracking uses escaped config during resolution)
750
+ const cleanTracking = JSON.parse(
751
+ JSON.stringify(tracking).replace(/__CONFIGVAR:([A-Za-z0-9+/=]+)__/g, (_, encoded) => {
752
+ return Buffer.from(encoded, 'base64').toString('utf8')
753
+ })
754
+ )
755
+
756
+ metadata.resolutionHistory[pathKey] = {
757
+ ...cleanTracking,
758
+ resolvedPropertyValue: resolvedValue
759
+ }
760
+ }
761
+ }
762
+
571
763
  return metadata
572
764
  }
573
765
 
@@ -1,9 +1,9 @@
1
- const YAML = require('../parsers/yaml')
2
- const TOML = require('../parsers/toml')
3
- const INI = require('../parsers/ini')
4
- const JSON5 = require('../parsers/json5')
5
- const { executeTypeScriptFileSync } = require('../parsers/typescript')
6
- const { executeESMFileSync } = require('../parsers/esm')
1
+ const YAML = require('../../parsers/yaml')
2
+ const TOML = require('../../parsers/toml')
3
+ const INI = require('../../parsers/ini')
4
+ const JSON5 = require('../../parsers/json5')
5
+ const { executeTypeScriptFileSync } = require('../../parsers/typescript')
6
+ const { executeESMFileSync } = require('../../parsers/esm')
7
7
  const cloudFormationSchema = require('./cloudformationSchema')
8
8
 
9
9
  /**
@@ -18,7 +18,7 @@ const cloudFormationSchema = require('./cloudformationSchema')
18
18
  function parseFileContents(fileContents, fileType, filePath, varRegex, opts = {}) {
19
19
  let configObject
20
20
 
21
- if (fileType.match(/\.(yml|yaml)/)) {
21
+ if (fileType.match(/\.(yml|yaml)/i)) {
22
22
  try {
23
23
  const ymlText = YAML.preProcess(fileContents, varRegex)
24
24
  configObject = YAML.parse(ymlText)
@@ -36,14 +36,14 @@ function parseFileContents(fileContents, fileType, filePath, varRegex, opts = {}
36
36
  configObject = result.data
37
37
  }
38
38
  }
39
- } else if (fileType.match(/\.(toml|tml)/)) {
39
+ } else if (fileType.match(/\.(toml|tml)/i)) {
40
40
  configObject = TOML.parse(fileContents)
41
- } else if (fileType.match(/\.(ini)/)) {
41
+ } else if (fileType.match(/\.(ini)/i)) {
42
42
  configObject = INI.parse(fileContents)
43
- } else if (fileType.match(/\.(json|json5)/)) {
43
+ } else if (fileType.match(/\.(json|json5)/i)) {
44
44
  configObject = JSON5.parse(fileContents)
45
45
  // TODO detect js syntax and use appropriate parser
46
- } else if (fileType.match(/\.(js|cjs)/)) {
46
+ } else if (fileType.match(/\.(js|cjs)/i)) {
47
47
  let jsFile
48
48
  try {
49
49
  jsFile = require(filePath)
@@ -60,7 +60,7 @@ function parseFileContents(fileContents, fileType, filePath, varRegex, opts = {}
60
60
  } catch (err) {
61
61
  throw new Error(err)
62
62
  }
63
- } else if (fileType.match(/\.(ts|tsx)/)) {
63
+ } else if (fileType.match(/\.(ts|tsx|mts|cts)/i)) {
64
64
  try {
65
65
  let jsArgs = opts.dynamicArgs || {}
66
66
  if (jsArgs && typeof jsArgs === 'function') {
@@ -76,7 +76,7 @@ function parseFileContents(fileContents, fileType, filePath, varRegex, opts = {}
76
76
  } catch (err) {
77
77
  throw new Error(`Failed to execute TypeScript file ${filePath}: ${err.message}`)
78
78
  }
79
- } else if (fileType.match(/\.(mjs|esm)/)) {
79
+ } else if (fileType.match(/\.(mjs|esm)/i)) {
80
80
  try {
81
81
  let jsArgs = opts.dynamicArgs || {}
82
82
  if (jsArgs && typeof jsArgs === 'function') {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Preprocesses config to fix malformed fallback references
3
+ * and escape variables inside help() filter arguments
4
+ */
5
+ const { splitByComma } = require('../strings/splitByComma')
6
+
7
+ /**
8
+ * Preprocess config to fix malformed fallback references
9
+ * @param {Object} configObject - The parsed configuration object
10
+ * @param {RegExp} variableSyntax - The variable syntax regex to use
11
+ * @returns {Object} The preprocessed configuration object
12
+ */
13
+ function preProcess(configObject, variableSyntax) {
14
+ // Known reference prefixes that should be wrapped in ${}
15
+ const refPrefixes = ['self:', 'opt:', 'env:', 'file:', 'text:', 'deep:']
16
+
17
+ /**
18
+ * Escape variables inside help() filter arguments so main resolver skips them
19
+ * Uses base64 encoding to preserve exact original syntax (supports custom variable syntax)
20
+ * @param {string} str - String potentially containing help() with variables
21
+ * @returns {string} String with help() variables escaped
22
+ */
23
+ function escapeHelpVariables(str) {
24
+ if (typeof str !== 'string') return str
25
+ if (!variableSyntax) return str
26
+
27
+ // Match help('...') or help("...") containing variables
28
+ const helpPattern = /help\(['"]([^'"]+)['"]\)/g
29
+
30
+ return str.replace(helpPattern, (match, helpContent) => {
31
+ // Check if help content contains variables
32
+ if (!helpContent.match(variableSyntax)) return match
33
+
34
+ // Replace each variable match with base64-encoded placeholder
35
+ const escaped = helpContent.replace(variableSyntax, (varMatch) => {
36
+ const encoded = Buffer.from(varMatch).toString('base64')
37
+ return `__CONFIGVAR:${encoded}__`
38
+ })
39
+ return `help('${escaped}')`
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Fix malformed fallback references in a string
45
+ * @param {string} str - String potentially containing variables
46
+ * @returns {string} String with fixed fallback references
47
+ */
48
+ function fixFallbacksInString(str) {
49
+ if (typeof str !== 'string') return str
50
+
51
+ let result = str
52
+ let changed = true
53
+
54
+ // Keep iterating until no more changes (to handle nested variables)
55
+ while (changed) {
56
+ changed = false
57
+
58
+ // Find innermost ${...} blocks (ones that don't contain other ${)
59
+ let i = 0
60
+ while (i < result.length) {
61
+ if (result[i] === '$' && result[i + 1] === '{') {
62
+ const start = i
63
+ let braceCount = 1
64
+ let j = i + 2
65
+
66
+ // Find the matching closing brace by counting { and }
67
+ while (j < result.length && braceCount > 0) {
68
+ if (result[j] === '{') {
69
+ braceCount++
70
+ } else if (result[j] === '}') {
71
+ braceCount--
72
+ }
73
+ j++
74
+ }
75
+
76
+ if (braceCount === 0) {
77
+ const end = j
78
+ const match = result.substring(start, end)
79
+ const content = result.substring(start + 2, end - 1)
80
+
81
+ // Only process if there's a comma (indicating fallback syntax)
82
+ if (content.includes(',')) {
83
+ // Split by comma
84
+ const parts = splitByComma(content, variableSyntax)
85
+
86
+ if (parts.length > 1) {
87
+ // Check if the first part has nested ${} - if so, skip this (process inner ones first)
88
+ const firstPart = parts[0]
89
+ if (firstPart.includes('${')) {
90
+ i = start + 2 // Move past ${ to find inner variables
91
+ continue
92
+ }
93
+
94
+ // Check each part after the first (these are fallback values)
95
+ const fixed = parts.map((part, index) => {
96
+ if (index === 0) {
97
+ return part // Keep the main reference as-is
98
+ }
99
+
100
+ const trimmed = part.trim()
101
+
102
+ // Check if this looks like a reference but is not wrapped
103
+ const looksLikeRef = refPrefixes.some(prefix => trimmed.startsWith(prefix))
104
+ const alreadyWrapped = trimmed.startsWith('${') && trimmed.endsWith('}')
105
+
106
+ if (looksLikeRef && !alreadyWrapped) {
107
+ return ` \${${trimmed}}`
108
+ }
109
+
110
+ return ` ${trimmed}`
111
+ })
112
+
113
+ const replacement = `\${${fixed.join(',')}}`
114
+ if (replacement !== match) {
115
+ result = result.substring(0, start) + replacement + result.substring(end)
116
+ changed = true
117
+ break // Restart search from beginning
118
+ }
119
+ }
120
+ }
121
+
122
+ i = start + 2 // Move past ${ to continue searching for nested variables
123
+ } else {
124
+ i++
125
+ }
126
+ } else {
127
+ i++
128
+ }
129
+ }
130
+ }
131
+
132
+ return result
133
+ }
134
+
135
+ /**
136
+ * Recursively traverse and fix the config object
137
+ */
138
+ function traverseAndFix(obj) {
139
+ if (typeof obj === 'string') {
140
+ // First escape help() variables, then fix fallbacks
141
+ const withHelpEscaped = escapeHelpVariables(obj)
142
+ return fixFallbacksInString(withHelpEscaped)
143
+ }
144
+
145
+ if (Array.isArray(obj)) {
146
+ return obj.map(item => traverseAndFix(item))
147
+ }
148
+
149
+ if (obj !== null && typeof obj === 'object') {
150
+ const result = {}
151
+ for (const key in obj) {
152
+ if (obj.hasOwnProperty(key)) {
153
+ result[key] = traverseAndFix(obj[key])
154
+ }
155
+ }
156
+ return result
157
+ }
158
+
159
+ return obj
160
+ }
161
+
162
+ return traverseAndFix(configObject)
163
+ }
164
+
165
+ module.exports = preProcess
@@ -1,6 +1,6 @@
1
1
  // Utilities for parsing and normalizing file paths in variable references
2
2
 
3
- const { splitCsv } = require('./splitCsv')
3
+ const { splitCsv } = require('../strings/splitCsv')
4
4
 
5
5
  /**
6
6
  * Normalize a file path (add ./ prefix, fix .//, skip deep refs)
@@ -45,12 +45,13 @@ function extractFilePath(variableString) {
45
45
  return null
46
46
  }
47
47
 
48
+ const { trimSurroundingQuotes } = require('../strings/quoteUtils')
48
49
  const fileContent = fileMatch[1].trim()
49
50
  const parts = splitCsv(fileContent)
50
51
  let filePath = parts[0].trim()
51
52
 
52
53
  // Remove quotes if present
53
- filePath = filePath.replace(/^['"]|['"]$/g, '')
54
+ filePath = trimSurroundingQuotes(filePath, false)
54
55
 
55
56
  return { filePath }
56
57
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Finds the line number for a given key in config file contents
3
+ */
4
+
5
+ /**
6
+ * Find the line number where a key is defined in config file contents
7
+ * @param {string} keyToFind - The key to search for
8
+ * @param {string[]} lines - Array of file lines
9
+ * @param {string} fileType - File extension (e.g., '.yml', '.json', '.toml')
10
+ * @returns {number} Line number (1-indexed) or 0 if not found
11
+ */
12
+ function findLineForKey(keyToFind, lines, fileType) {
13
+ if (!keyToFind || !lines || !lines.length) return 0
14
+
15
+ const escapedKey = keyToFind.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
16
+
17
+ const lineIdx = lines.findIndex((line) => {
18
+ // YAML: key: or key :
19
+ if (fileType === '.yml' || fileType === '.yaml') {
20
+ return new RegExp(`^\\s*${escapedKey}\\s*:`).test(line)
21
+ }
22
+ // TOML: key = or key=
23
+ if (fileType === '.toml') {
24
+ return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
25
+ }
26
+ // JSON: "key": or "key" :
27
+ if (fileType === '.json' || fileType === '.json5') {
28
+ return new RegExp(`"${escapedKey}"\\s*:`).test(line)
29
+ }
30
+ // INI: key = or key=
31
+ if (fileType === '.ini') {
32
+ return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
33
+ }
34
+ // JS/TS/ESM: key: or "key": or 'key': or `key`: or [`key`]:
35
+ if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(fileType)) {
36
+ return new RegExp(`(?:${escapedKey}|"${escapedKey}"|'${escapedKey}'|\`${escapedKey}\`|\\[\`${escapedKey}\`\\])\\s*:`).test(line)
37
+ }
38
+ // Default fallback: try YAML-style
39
+ return line.includes(`${keyToFind}:`)
40
+ })
41
+
42
+ return lineIdx !== -1 ? lineIdx + 1 : 0
43
+ }
44
+
45
+ module.exports = {
46
+ findLineForKey
47
+ }