configorama 0.6.11 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configorama",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
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
@@ -2,6 +2,7 @@ const os = require('os')
2
2
  const path = require('path')
3
3
  const fs = require('fs')
4
4
  const enrichMetadata = require('./utils/enrichMetadata')
5
+ const { normalizePath, extractFilePath, resolveInnerVariables } = require('./utils/filePathUtils')
5
6
  /* // disable logs to find broken tests
6
7
  console.log = () => {}
7
8
  // process.exit(1)
@@ -753,6 +754,9 @@ class Configorama {
753
754
  if (varKeys.length > 0) {
754
755
  const fileName = this.configFilePath ? ` in ${this.configFilePath}` : ''
755
756
 
757
+ // Extract base variable name from varMatch key (e.g., '${env:FOO, default}' -> 'env:FOO')
758
+ const getBaseVarName = (key) => key.replace(/^\$\{/, '').replace(/\}$/, '').split(',')[0].trim()
759
+
756
760
  logHeader(`Found ${varKeys.length} Variables${fileName}`)
757
761
 
758
762
  // deepLog('variableData', variableData)
@@ -765,9 +769,7 @@ class Configorama {
765
769
 
766
770
  // Use uniqueVariables for simpler reference counting
767
771
  const referenceData = varKeys.map((k) => {
768
- // Map from varMatch (e.g., '${env:API_KEY}') to variable name (e.g., 'env:API_KEY')
769
- // Extract the variable name from the key by removing ${ and }
770
- const varName = k.replace(/^\$\{/, '').replace(/\}$/, '').split(',')[0].trim()
772
+ const varName = getBaseVarName(k)
771
773
  const uniqueVar = uniqueVariables[varName]
772
774
  const refCount = uniqueVar ? uniqueVar.occurrences.length : variableData[k].length
773
775
  const placesWord = refCount > 1 ? 'places' : 'place'
@@ -788,7 +790,7 @@ class Configorama {
788
790
  const firstInstance = variableInstances[0]
789
791
 
790
792
  // Get uniqueVariable data for description and other metadata
791
- const varName = key.replace(/^\$\{/, '').replace(/\}$/, '').split(',')[0].trim()
793
+ const varName = getBaseVarName(key)
792
794
  const uniqueVar = uniqueVariables[varName]
793
795
 
794
796
  // Build display message from enriched metadata
@@ -807,38 +809,16 @@ class Configorama {
807
809
  }
808
810
 
809
811
  // Show type filter if present (Boolean, String, Number, etc.)
810
- if (uniqueVar && uniqueVar.occurrences.length > 0) {
811
- const typeFilters = ['Boolean', 'String', 'Number', 'Array', 'Object']
812
- const foundTypes = new Set()
813
-
814
- uniqueVar.occurrences.forEach(occ => {
815
- if (occ.filters && Array.isArray(occ.filters)) {
816
- occ.filters.forEach(filter => {
817
- if (typeFilters.includes(filter)) {
818
- foundTypes.add(filter)
819
- }
820
- })
821
- }
822
- })
823
-
824
- if (foundTypes.size > 0) {
825
- const typeText = `${indent}${keyChalk('Type:'.padEnd(titleText.length, ' '))}`
826
- varMsg += `${typeText} ${valueChalk(Array.from(foundTypes).join(', '))}\n`
827
- }
812
+ if (uniqueVar && uniqueVar.types && uniqueVar.types.length > 0) {
813
+ const typeLabel = `${indent}${keyChalk('Type:'.padEnd(titleText.length, ' '))}`
814
+ varMsg += `${typeLabel} ${valueChalk(uniqueVar.types.join(', '))}\n`
828
815
  }
829
816
 
830
817
  // Show description from uniqueVariables if available
831
- if (uniqueVar && uniqueVar.occurrences.length > 0) {
832
- // Collect unique descriptions from all occurrences
833
- const descriptions = uniqueVar.occurrences
834
- .map(occ => occ.description)
835
- .filter((desc, index, self) => desc && self.indexOf(desc) === index)
836
-
837
- if (descriptions.length > 0) {
838
- const descText = `${indent}${keyChalk('Description:'.padEnd(titleText.length, ' '))}`
839
- const combinedDesc = descriptions.join('. ')
840
- varMsg += `${descText} ${valueChalk(combinedDesc)}\n`
841
- }
818
+ if (uniqueVar && uniqueVar.descriptions && uniqueVar.descriptions.length > 0) {
819
+ const descText = `${indent}${keyChalk('Description:'.padEnd(titleText.length, ' '))}`
820
+ const combinedDesc = uniqueVar.descriptions.join('. ')
821
+ varMsg += `${descText} ${valueChalk(combinedDesc)}\n`
842
822
  }
843
823
 
844
824
 
@@ -873,11 +853,7 @@ class Configorama {
873
853
  // Show type filter per path if different
874
854
  if (uniqueVar && uniqueVar.occurrences.length > 1) {
875
855
  const occurrence = uniqueVar.occurrences.find(occ => occ.path === v.path)
876
- const typeFilters = ['Boolean', 'String', 'Number', 'Array', 'Object']
877
- const pathType = occurrence && occurrence.filters
878
- ? occurrence.filters.find(f => typeFilters.includes(f))
879
- : null
880
-
856
+ const pathType = occurrence && occurrence.type
881
857
  typeText = pathType ? ` ${chalk.dim(`Type: ${pathType}`)}` : ''
882
858
  const prefix = idx === 0 ? '' : `${indent}${pathIndent}`
883
859
  return `${prefix}${valueChalk(`- ${v.path}`)}${typeText}`
@@ -888,12 +864,7 @@ class Configorama {
888
864
  locationRender = pathItems.join('\n')
889
865
  locationLabel = `${indent}${keyChalk('Config Paths:'.padEnd(titleText.length, ' '))}`
890
866
  } else {
891
- // look for type filter in the first instance
892
- const typeFilters = ['Boolean', 'String', 'Number', 'Array', 'Object']
893
- const pathType = firstInstance.filters
894
- ? firstInstance.filters.find(f => typeFilters.includes(f))
895
- : null
896
-
867
+ const pathType = firstInstance.type
897
868
  typeText = pathType ? ` ${chalk.dim(`Type: ${pathType}`)}` : ''
898
869
  }
899
870
  varMsg += `${locationLabel} ${locationRender}`
@@ -1129,10 +1100,15 @@ class Configorama {
1129
1100
  const variablesKnownTypes = this.variablesKnownTypes
1130
1101
  const variableTypes = this.variableTypes
1131
1102
  const filterMatch = this.filterMatch
1103
+ const configFilePath = this.configFilePath
1104
+ const originalConfig = this.originalConfig
1132
1105
  const foundVariables = []
1133
1106
  const variableData = {}
1134
1107
  const fileRefs = []
1135
1108
  const fileGlobPatterns = []
1109
+ const preResolvedPaths = new Set()
1110
+ const byConfigPath = []
1111
+ const referencesMap = new Map()
1136
1112
  let matchCount = 1
1137
1113
 
1138
1114
  traverse(this.originalConfig).forEach(function (rawValue) {
@@ -1304,46 +1280,75 @@ class Configorama {
1304
1280
  nested.forEach((detail) => {
1305
1281
  // console.log('detail', detail)
1306
1282
  if (detail.variableType && (detail.variableType === 'file' || detail.variableType === 'text')) {
1307
- const fileMatch = detail.variable.match(/^(?:file|text)\((.*?)\)/)
1308
- if (fileMatch && fileMatch[1]) {
1309
- let fileContent = fileMatch[1].trim()
1310
-
1311
- // Split by comma to separate file path from parameters/fallback values
1312
- const parts = splitCsv(fileContent)
1313
- let filePath = parts[0].trim()
1314
-
1315
- // Remove quotes if present
1316
- filePath = filePath.replace(/^['"]|['"]$/g, '')
1317
-
1318
- // Normalize path: ensure relative paths start with ./
1319
- let normalizedPath = filePath
1320
- if (
1321
- !filePath.startsWith('./') &&
1322
- !filePath.startsWith('../') &&
1323
- !filePath.startsWith('/') &&
1324
- !filePath.startsWith('~')
1325
- ) {
1326
- normalizedPath = './' + filePath
1327
- }
1283
+ const extracted = extractFilePath(detail.variable)
1284
+ if (extracted) {
1285
+ const normalizedPath = normalizePath(extracted.filePath)
1286
+ if (!normalizedPath) return
1328
1287
 
1329
- // file .//
1330
- if (normalizedPath.startsWith('.//')) {
1331
- normalizedPath = normalizedPath.replace('.//', './')
1332
- }
1333
-
1334
1288
  // Handle variables in file paths - just record the pattern
1335
1289
  if (!fileRefs.includes(normalizedPath)) {
1336
1290
  fileRefs.push(normalizedPath)
1337
1291
  }
1338
-
1292
+
1339
1293
  // Check if path contains variables and create glob pattern
1340
- if (normalizedPath.match(variableSyntax)) {
1294
+ const containsVariables = !!normalizedPath.match(variableSyntax)
1295
+ let globPattern
1296
+ if (containsVariables) {
1341
1297
  // Replace variable syntax ${...} with * for glob pattern
1342
- const globPattern = normalizedPath.replace(variableSyntax, '*')
1298
+ globPattern = normalizedPath.replace(variableSyntax, '*')
1343
1299
  if (!fileGlobPatterns.includes(globPattern)) {
1344
1300
  fileGlobPatterns.push(globPattern)
1345
1301
  }
1346
1302
  }
1303
+
1304
+ // Try to pre-resolve inner variables from originalConfig
1305
+ let resolvedPath = normalizedPath
1306
+ let resolvedVarString = rawValue
1307
+ if (containsVariables) {
1308
+ const pathResult = resolveInnerVariables(normalizedPath, variableSyntax, originalConfig, dotProp.get)
1309
+ const varStringResult = resolveInnerVariables(rawValue, variableSyntax, originalConfig, dotProp.get)
1310
+
1311
+ if (pathResult.didResolve) {
1312
+ resolvedPath = normalizePath(pathResult.resolved) || pathResult.resolved
1313
+ resolvedVarString = varStringResult.resolved
1314
+ preResolvedPaths.add(resolvedPath)
1315
+ }
1316
+ }
1317
+
1318
+ // Build byConfigPath entry
1319
+ const absolutePath = configFilePath
1320
+ ? path.resolve(path.dirname(configFilePath), resolvedPath)
1321
+ : resolvedPath
1322
+ const fileExists = configFilePath ? fs.existsSync(absolutePath) : false
1323
+
1324
+ const configPathEntry = {
1325
+ location: configValuePath,
1326
+ filePath: absolutePath,
1327
+ relativePath: resolvedPath,
1328
+ originalVariableString: rawValue,
1329
+ resolvedVariableString: resolvedVarString,
1330
+ containsVariables,
1331
+ exists: fileExists,
1332
+ }
1333
+ if (globPattern) {
1334
+ configPathEntry.pattern = globPattern
1335
+ }
1336
+ byConfigPath.push(configPathEntry)
1337
+
1338
+ // Build references entry (use resolvedPath as key when available)
1339
+ const refKey = resolvedPath
1340
+ if (!referencesMap.has(refKey)) {
1341
+ referencesMap.set(refKey, {
1342
+ resolvedPath: refKey,
1343
+ refs: [],
1344
+ })
1345
+ }
1346
+ const refEntry = referencesMap.get(refKey)
1347
+ refEntry.refs.push({
1348
+ location: configValuePath,
1349
+ value: normalizedPath,
1350
+ originalVariableString: rawValue,
1351
+ })
1347
1352
  }
1348
1353
  }
1349
1354
  })
@@ -1423,12 +1428,13 @@ class Configorama {
1423
1428
  globPatterns: fileGlobPatterns,
1424
1429
  // all: fileRefs,
1425
1430
  dynamicPaths: fileRefs.filter(ref => ref.indexOf('*') !== -1 || ref.match(variableSyntax)),
1426
- // resolve files are those that are paths with no * and no inner variables
1427
- resolvedPaths: fileRefs.filter(ref => ref.indexOf('*') === -1 && !ref.match(variableSyntax)),
1428
- // Set in enrichMetadata
1429
- byConfigPath: undefined,
1430
- // Set in enrichMetadata
1431
- references: undefined,
1431
+ // Resolved paths: static paths + pre-resolved dynamic paths
1432
+ resolvedPaths: [
1433
+ ...fileRefs.filter(ref => ref.indexOf('*') === -1 && !ref.match(variableSyntax)),
1434
+ ...preResolvedPaths
1435
+ ],
1436
+ byConfigPath,
1437
+ references: Array.from(referencesMap.values()),
1432
1438
  },
1433
1439
  summary: {
1434
1440
  totalVariables: varKeys.length,
@@ -223,17 +223,27 @@ function validateType(value, expectedType) {
223
223
  }
224
224
 
225
225
  /**
226
- * Extracts type from variable occurrences
227
- * @param {Array} occurrences - Variable occurrences
226
+ * Extracts type from variable data or occurrences
227
+ * @param {object} varData - Variable data with types array or occurrences
228
228
  * @returns {string|null} Expected type or null
229
229
  */
230
- function getExpectedType(occurrences) {
231
- if (!occurrences || occurrences.length === 0) return null
230
+ function getExpectedType(varData) {
231
+ // Use pre-computed types if available
232
+ if (varData && varData.types && varData.types.length > 0) {
233
+ return varData.types[0]
234
+ }
235
+
236
+ // Fallback to checking occurrences
237
+ const occurrences = varData && varData.occurrences ? varData.occurrences : varData
238
+ if (!occurrences || !Array.isArray(occurrences) || occurrences.length === 0) return null
232
239
 
233
240
  for (const occ of occurrences) {
241
+ // Check pre-computed type on occurrence
242
+ if (occ.type) return occ.type
243
+
244
+ // Fallback to filters
234
245
  if (occ.filters && Array.isArray(occ.filters)) {
235
246
  for (const filter of occ.filters) {
236
- // Check if filter starts with uppercase letter
237
247
  if (filter && typeof filter === 'string' && /^[A-Z]/.test(filter)) {
238
248
  return filter
239
249
  }
@@ -244,23 +254,28 @@ function getExpectedType(occurrences) {
244
254
  }
245
255
 
246
256
  /**
247
- * Extracts help text from variable occurrences
248
- * @param {Array} occurrences - Variable occurrences
257
+ * Extracts help text from variable data or occurrences
258
+ * @param {object} varData - Variable data with descriptions array or occurrences
249
259
  * @returns {string|null} Help text or null
250
260
  */
251
- function getHelpText(occurrences) {
252
- if (!occurrences || occurrences.length === 0) return null
261
+ function getHelpText(varData) {
262
+ // Use pre-computed descriptions if available
263
+ if (varData && varData.descriptions && varData.descriptions.length > 0) {
264
+ return varData.descriptions.join('. ')
265
+ }
266
+
267
+ // Fallback to checking occurrences
268
+ const occurrences = varData && varData.occurrences ? varData.occurrences : varData
269
+ if (!occurrences || !Array.isArray(occurrences) || occurrences.length === 0) return null
253
270
 
254
271
  for (const occ of occurrences) {
255
- // Check for description field first (preferred)
256
272
  if (occ.description) {
257
273
  return occ.description
258
274
  }
259
275
 
260
- // Fallback to checking filters array (for backwards compatibility)
276
+ // Fallback to checking filters array
261
277
  if (occ.filters && Array.isArray(occ.filters)) {
262
278
  for (const filter of occ.filters) {
263
- // Check if filter has help() syntax
264
279
  const helpMatch = filter.match(/^help\(['"](.+)['"]\)$/)
265
280
  if (helpMatch) {
266
281
  return helpMatch[1]
@@ -290,22 +305,20 @@ function createPromptMessage(varInfo) {
290
305
  typeLabel = 'Value'
291
306
  }
292
307
 
293
- // Check for type from filters
294
- const expectedType = getExpectedType(occurrences)
308
+ // Check for type - use pre-computed if available
309
+ const expectedType = getExpectedType(varInfo)
295
310
 
296
311
  // Append type to label if found
297
312
  if (expectedType) {
298
313
  typeLabel = `${typeLabel}:${expectedType}`
299
314
  }
300
315
 
301
- // Collect all unique descriptions from occurrences
302
- const descriptions = []
303
- if (occurrences && occurrences.length > 0) {
304
- occurrences.forEach(occ => {
305
- if (occ.description && !descriptions.includes(occ.description)) {
306
- descriptions.push(occ.description)
307
- }
308
- })
316
+ // Use pre-computed descriptions if available, otherwise collect from occurrences
317
+ let descriptions = varInfo.descriptions || []
318
+ if (descriptions.length === 0 && occurrences && occurrences.length > 0) {
319
+ descriptions = occurrences
320
+ .map(occ => occ.description)
321
+ .filter((d, i, a) => d && a.indexOf(d) === i)
309
322
  }
310
323
 
311
324
  // Build context from all occurrences
@@ -1,7 +1,36 @@
1
- const { splitCsv } = require('./splitCsv')
2
1
  const dotProp = require('dot-prop')
3
2
  const fs = require('fs')
4
3
  const path = require('path')
4
+ const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('./filePathUtils')
5
+
6
+ // Type filters that indicate expected value types
7
+ const TYPE_FILTERS = ['Boolean', 'String', 'Number', 'Array', 'Object', 'Json']
8
+
9
+ /**
10
+ * Extract type filter from filters array
11
+ * @param {Array} filters - Filters array from variable instance
12
+ * @returns {string|undefined} Type filter if found
13
+ */
14
+ function extractTypeFromFilters(filters) {
15
+ if (!filters || !Array.isArray(filters)) return undefined
16
+ return filters.find(f => TYPE_FILTERS.includes(f))
17
+ }
18
+
19
+ /**
20
+ * Extract description from filters array (help filter)
21
+ * @param {Array} filters - Filters array from variable instance
22
+ * @returns {string|undefined} Description if found
23
+ */
24
+ function extractDescriptionFromFilters(filters) {
25
+ if (!filters || !Array.isArray(filters)) return undefined
26
+ for (const filter of filters) {
27
+ if (filter && typeof filter === 'string') {
28
+ const helpMatch = filter.match(/^help\(['"](.+)['"]\)$/)
29
+ if (helpMatch) return helpMatch[1]
30
+ }
31
+ }
32
+ return undefined
33
+ }
5
34
 
6
35
  /**
7
36
  * Create a standardized occurrence object
@@ -29,6 +58,9 @@ function createOccurrence(instance, varMatch, options = {}) {
29
58
  }
30
59
  }
31
60
 
61
+ // Extract type from filters
62
+ const type = extractTypeFromFilters(filters)
63
+
32
64
  const occurrence = {
33
65
  originalString: instance.originalStringValue,
34
66
  varMatch: varMatch,
@@ -40,6 +72,10 @@ function createOccurrence(instance, varMatch, options = {}) {
40
72
  hasFallback: options.hasFallback !== undefined ? options.hasFallback : (instance.hasFallback || false),
41
73
  }
42
74
 
75
+ if (type) {
76
+ occurrence.type = type
77
+ }
78
+
43
79
  if (description) {
44
80
  occurrence.description = description
45
81
  }
@@ -51,56 +87,6 @@ function createOccurrence(instance, varMatch, options = {}) {
51
87
  return occurrence
52
88
  }
53
89
 
54
- /**
55
- * Extract file path from a file() or text() reference string
56
- * @param {string} propertyString - The property string containing file/text reference
57
- * @returns {object|null} Object with filePath, or null if no match
58
- */
59
- function extractFilePath(propertyString) {
60
- const fileMatch = propertyString.match(/^\$\{(?:file|text)\((.*?)\)/)
61
- if (!fileMatch || !fileMatch[1]) {
62
- return null
63
- }
64
-
65
- const fileContent = fileMatch[1].trim()
66
- const parts = splitCsv(fileContent)
67
- let filePath = parts[0].trim()
68
-
69
- // Remove quotes if present
70
- filePath = filePath.replace(/^['"]|['"]$/g, '')
71
-
72
- return { filePath }
73
- }
74
-
75
- /**
76
- * Normalize a file path (add ./ prefix, fix .//, skip deep refs)
77
- * @param {string} filePath - The file path to normalize
78
- * @returns {string|null} Normalized path, or null if should be skipped
79
- */
80
- function normalizePath(filePath) {
81
- // Skip deep references
82
- if (filePath.includes('deep:')) {
83
- return null
84
- }
85
-
86
- let normalized = filePath
87
-
88
- // Add ./ prefix for relative paths
89
- if (!filePath.startsWith('./') &&
90
- !filePath.startsWith('../') &&
91
- !filePath.startsWith('/') &&
92
- !filePath.startsWith('~')) {
93
- normalized = './' + filePath
94
- }
95
-
96
- // Fix double slashes
97
- if (normalized.startsWith('.//')) {
98
- normalized = normalized.replace('.//', './')
99
- }
100
-
101
- return normalized
102
- }
103
-
104
90
  /**
105
91
  * Enriches variable metadata with resolution tracking data.
106
92
  * @param {object} metadata - The metadata object from collectVariableMetadata.
@@ -173,6 +159,16 @@ function enrichMetadata(
173
159
  }
174
160
  }
175
161
  }
162
+
163
+ // Add type and description to individual variable instance
164
+ const instanceType = extractTypeFromFilters(varData.filters)
165
+ const instanceDescription = extractDescriptionFromFilters(varData.filters)
166
+ if (instanceType) {
167
+ varData.type = instanceType
168
+ }
169
+ if (instanceDescription) {
170
+ varData.description = instanceDescription
171
+ }
176
172
  }
177
173
  }
178
174
 
@@ -198,7 +194,8 @@ function enrichMetadata(
198
194
  }
199
195
 
200
196
  // Update fileDependencies.resolvedPaths with the resolved file refs
201
- if (metadata.fileDependencies) {
197
+ // Only overwrite if we have enriched data, otherwise keep original from collectVariableMetadata
198
+ if (metadata.fileDependencies && resolvedFileRefs.length > 0) {
202
199
  metadata.fileDependencies.resolvedPaths = resolvedFileRefs
203
200
  }
204
201
 
@@ -304,10 +301,14 @@ function enrichMetadata(
304
301
  }
305
302
  }
306
303
 
307
- // Update fileDependencies with the enriched data
304
+ // Update fileDependencies with the enriched data (only if we have data)
308
305
  if (metadata.fileDependencies) {
309
- metadata.fileDependencies.byConfigPath = byConfigPath
310
- metadata.fileDependencies.references = references
306
+ if (byConfigPath.length > 0) {
307
+ metadata.fileDependencies.byConfigPath = byConfigPath
308
+ }
309
+ if (references.length > 0) {
310
+ metadata.fileDependencies.references = references
311
+ }
311
312
  }
312
313
 
313
314
  // Build uniqueVariables rollup - group by base variable (without fallbacks)
@@ -338,24 +339,7 @@ function enrichMetadata(
338
339
  }
339
340
 
340
341
  // Normalize file() and text() references
341
- if (baseVar.match(/^(?:file|text)\(/)) {
342
- // Strip sub-key accessors like :topLevel, :nested.value, etc.
343
- baseVar = baseVar.replace(/:[\w.[\]]+$/, '')
344
-
345
- // Normalize path - remove quotes and ensure it starts with ./
346
- baseVar = baseVar.replace(/^(file|text)\((.+?)\)/, (match, funcName, filePath) => {
347
- // Remove surrounding quotes (single or double)
348
- let cleanPath = filePath.trim().replace(/^["']|["']$/g, '')
349
-
350
- // Use normalizePath for consistent normalization (handles ./, .// etc)
351
- const normalized = normalizePath(cleanPath)
352
- if (normalized) {
353
- return `${funcName}(${normalized})`
354
- }
355
-
356
- return match
357
- })
358
- }
342
+ baseVar = normalizeFileVariable(baseVar)
359
343
 
360
344
  if (!uniqueVariablesMap.has(baseVar)) {
361
345
  uniqueVariablesMap.set(baseVar, {
@@ -394,17 +378,7 @@ function enrichMetadata(
394
378
  const siblingBaseVar = detail.valueBeforeFallback || detail.variable
395
379
 
396
380
  // Normalize file/text references for sibling too
397
- let normalizedSiblingVar = siblingBaseVar
398
- if (normalizedSiblingVar.match(/^(?:file|text)\(/)) {
399
- // Strip sub-key accessor (e.g., :foo from file(./_inner.yml):foo)
400
- normalizedSiblingVar = normalizedSiblingVar.replace(/:[\w.[\]]+$/, '')
401
-
402
- normalizedSiblingVar = normalizedSiblingVar.replace(/^(file|text)\((.+?)\)/, (match, funcName, filePath) => {
403
- let cleanPath = filePath.trim().replace(/^["']|["']$/g, '')
404
- const normalized = normalizePath(cleanPath)
405
- return normalized ? `${funcName}(${normalized})` : match
406
- })
407
- }
381
+ const normalizedSiblingVar = normalizeFileVariable(siblingBaseVar)
408
382
 
409
383
  // Create or get entry for this sibling variable
410
384
  if (!uniqueVariablesMap.has(normalizedSiblingVar)) {
@@ -512,12 +486,7 @@ function enrichMetadata(
512
486
  }
513
487
 
514
488
  // Normalize file paths after variable substitution
515
- if (resolvedVariable.match(/^(?:file|text)\(/)) {
516
- resolvedVariable = resolvedVariable.replace(/^(file|text)\((.+?)\)/, (match, funcName, filePath) => {
517
- const normalized = normalizePath(filePath)
518
- return normalized ? `${funcName}(${normalized})` : match
519
- })
520
- }
489
+ resolvedVariable = normalizeFileVariable(resolvedVariable)
521
490
 
522
491
  // Update the variable to the resolved version and update map key
523
492
  if (resolvedVariable !== baseVar) {
@@ -577,6 +546,25 @@ function enrichMetadata(
577
546
  }
578
547
  }
579
548
 
549
+ // Aggregate types and descriptions for each uniqueVariable
550
+ for (const [, entry] of uniqueVariablesMap) {
551
+ // Collect unique types from occurrences
552
+ const types = entry.occurrences
553
+ .map(occ => occ.type)
554
+ .filter((t, i, a) => t && a.indexOf(t) === i)
555
+ if (types.length > 0) {
556
+ entry.types = types
557
+ }
558
+
559
+ // Collect unique descriptions from occurrences
560
+ const descriptions = entry.occurrences
561
+ .map(occ => occ.description)
562
+ .filter((d, i, a) => d && a.indexOf(d) === i)
563
+ if (descriptions.length > 0) {
564
+ entry.descriptions = descriptions
565
+ }
566
+ }
567
+
580
568
  // Convert map to object for metadata
581
569
  metadata.uniqueVariables = Object.fromEntries(uniqueVariablesMap)
582
570
 
@@ -0,0 +1,135 @@
1
+ // Utilities for parsing and normalizing file paths in variable references
2
+
3
+ const { splitCsv } = require('./splitCsv')
4
+
5
+ /**
6
+ * Normalize a file path (add ./ prefix, fix .//, skip deep refs)
7
+ * @param {string} filePath - The file path to normalize
8
+ * @returns {string|null} Normalized path, or null if should be skipped
9
+ */
10
+ function normalizePath(filePath) {
11
+ // Skip deep references
12
+ if (filePath.includes('deep:')) {
13
+ return null
14
+ }
15
+
16
+ let normalized = filePath
17
+
18
+ // Add ./ prefix for relative paths
19
+ if (
20
+ !filePath.startsWith('./') &&
21
+ !filePath.startsWith('../') &&
22
+ !filePath.startsWith('/') &&
23
+ !filePath.startsWith('~')
24
+ ) {
25
+ normalized = './' + filePath
26
+ }
27
+
28
+ // Fix double slashes
29
+ if (normalized.startsWith('.//')) {
30
+ normalized = normalized.replace('.//', './')
31
+ }
32
+
33
+ return normalized
34
+ }
35
+
36
+ /**
37
+ * Extract file path from a file() or text() variable string
38
+ * @param {string} variableString - The variable string (with or without ${} wrapper)
39
+ * @returns {object|null} Object with filePath, or null if no match
40
+ */
41
+ function extractFilePath(variableString) {
42
+ // Match both ${file(...)} and file(...) formats
43
+ const fileMatch = variableString.match(/^(?:\$\{)?(?:file|text)\((.*?)\)/)
44
+ if (!fileMatch || !fileMatch[1]) {
45
+ return null
46
+ }
47
+
48
+ const fileContent = fileMatch[1].trim()
49
+ const parts = splitCsv(fileContent)
50
+ let filePath = parts[0].trim()
51
+
52
+ // Remove quotes if present
53
+ filePath = filePath.replace(/^['"]|['"]$/g, '')
54
+
55
+ return { filePath }
56
+ }
57
+
58
+ /**
59
+ * Normalize a file() or text() variable string
60
+ * Strips key accessors and normalizes the path inside
61
+ * @param {string} variableString - e.g. "file('./config.json'):key" or "file(config.json)"
62
+ * @returns {string} Normalized variable string, e.g. "file(./config.json)"
63
+ */
64
+ function normalizeFileVariable(variableString) {
65
+ if (!variableString.match(/^(?:file|text)\(/)) {
66
+ return variableString
67
+ }
68
+
69
+ // Strip sub-key accessors like :topLevel, :nested.value, etc.
70
+ let normalized = variableString.replace(/:[\w.[\]]+$/, '')
71
+
72
+ // Normalize the path inside
73
+ normalized = normalized.replace(/^(file|text)\((.+?)\)/, (match, funcName, filePath) => {
74
+ let cleanPath = filePath.trim().replace(/^["']|["']$/g, '')
75
+ const normalizedPath = normalizePath(cleanPath)
76
+ return normalizedPath ? `${funcName}(${normalizedPath})` : match
77
+ })
78
+
79
+ return normalized
80
+ }
81
+
82
+ /**
83
+ * Resolve inner variables in a string from config values
84
+ * @param {string} str - String containing variables like ${self:stage}
85
+ * @param {RegExp} variableSyntax - Regex to match variable syntax
86
+ * @param {object} config - Config object to look up values
87
+ * @param {function} getProp - Function to get nested property (e.g. dotProp.get)
88
+ * @returns {{resolved: string, didResolve: boolean}} Resolved string and whether resolution happened
89
+ */
90
+ function resolveInnerVariables(str, variableSyntax, config, getProp) {
91
+ const varMatches = str.match(variableSyntax)
92
+ if (!varMatches) {
93
+ return { resolved: str, didResolve: false }
94
+ }
95
+
96
+ let canResolve = true
97
+ let resolved = str
98
+ for (const varMatch of varMatches) {
99
+ const innerVar = varMatch.slice(2, -1) // Remove ${ and }
100
+ let configPath = null
101
+
102
+ // Handle self: prefix
103
+ if (innerVar.startsWith('self:')) {
104
+ configPath = innerVar.slice(5)
105
+ } else if (!innerVar.includes(':')) {
106
+ // dot.prop style
107
+ configPath = innerVar
108
+ }
109
+
110
+ if (configPath) {
111
+ const configValue = getProp(config, configPath)
112
+ // Only use if it's a static value (not another variable)
113
+ if (configValue !== undefined &&
114
+ typeof configValue === 'string' &&
115
+ !configValue.match(variableSyntax)) {
116
+ resolved = resolved.replace(varMatch, configValue)
117
+ } else {
118
+ canResolve = false
119
+ break
120
+ }
121
+ } else {
122
+ canResolve = false
123
+ break
124
+ }
125
+ }
126
+
127
+ return { resolved: canResolve ? resolved : str, didResolve: canResolve }
128
+ }
129
+
130
+ module.exports = {
131
+ normalizePath,
132
+ extractFilePath,
133
+ normalizeFileVariable,
134
+ resolveInnerVariables,
135
+ }
@@ -0,0 +1,214 @@
1
+ // Tests for file path parsing and normalization utilities
2
+
3
+ const { test } = require('uvu')
4
+ const assert = require('uvu/assert')
5
+ const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('./filePathUtils')
6
+
7
+ // normalizePath tests
8
+
9
+ test('normalizePath - returns null for deep: references', () => {
10
+ assert.is(normalizePath('deep:1'), null)
11
+ assert.is(normalizePath('some/deep:path'), null)
12
+ })
13
+
14
+ test('normalizePath - adds ./ prefix to bare paths', () => {
15
+ assert.is(normalizePath('config.json'), './config.json')
16
+ assert.is(normalizePath('path/to/file.yml'), './path/to/file.yml')
17
+ })
18
+
19
+ test('normalizePath - keeps ./ paths unchanged', () => {
20
+ assert.is(normalizePath('./config.json'), './config.json')
21
+ assert.is(normalizePath('./path/to/file.yml'), './path/to/file.yml')
22
+ })
23
+
24
+ test('normalizePath - keeps ../ paths unchanged', () => {
25
+ assert.is(normalizePath('../config.json'), '../config.json')
26
+ assert.is(normalizePath('../../path/to/file.yml'), '../../path/to/file.yml')
27
+ })
28
+
29
+ test('normalizePath - keeps absolute paths unchanged', () => {
30
+ assert.is(normalizePath('/etc/config.json'), '/etc/config.json')
31
+ assert.is(normalizePath('/home/user/file.yml'), '/home/user/file.yml')
32
+ })
33
+
34
+ test('normalizePath - keeps ~ paths unchanged', () => {
35
+ assert.is(normalizePath('~/config.json'), '~/config.json')
36
+ assert.is(normalizePath('~/.config/file.yml'), '~/.config/file.yml')
37
+ })
38
+
39
+ test('normalizePath - fixes .// to ./', () => {
40
+ assert.is(normalizePath('.//config.json'), './config.json')
41
+ assert.is(normalizePath('.//path/to/file.yml'), './path/to/file.yml')
42
+ })
43
+
44
+ // extractFilePath tests
45
+
46
+ test('extractFilePath - extracts path from file(...) format', () => {
47
+ const result = extractFilePath('file(./config.json)')
48
+ assert.is(result.filePath, './config.json')
49
+ })
50
+
51
+ test('extractFilePath - extracts path from ${file(...)} format', () => {
52
+ const result = extractFilePath('${file(./config.json)}')
53
+ assert.is(result.filePath, './config.json')
54
+ })
55
+
56
+ test('extractFilePath - extracts path from text(...) format', () => {
57
+ const result = extractFilePath('text(./readme.txt)')
58
+ assert.is(result.filePath, './readme.txt')
59
+ })
60
+
61
+ test('extractFilePath - extracts path from ${text(...)} format', () => {
62
+ const result = extractFilePath('${text(./readme.txt)}')
63
+ assert.is(result.filePath, './readme.txt')
64
+ })
65
+
66
+ test('extractFilePath - handles single-quoted paths', () => {
67
+ const result = extractFilePath("file('./config.json')")
68
+ assert.is(result.filePath, './config.json')
69
+ })
70
+
71
+ test('extractFilePath - handles double-quoted paths', () => {
72
+ const result = extractFilePath('file("./config.json")')
73
+ assert.is(result.filePath, './config.json')
74
+ })
75
+
76
+ test('extractFilePath - extracts first path when fallback present', () => {
77
+ const result = extractFilePath("file(./env.yml, 'default')")
78
+ assert.is(result.filePath, './env.yml')
79
+ })
80
+
81
+ test('extractFilePath - handles path with key accessor', () => {
82
+ const result = extractFilePath('file(./env.yml):FOO')
83
+ assert.is(result.filePath, './env.yml')
84
+ })
85
+
86
+ test('extractFilePath - handles complex fallback with key accessor', () => {
87
+ const result = extractFilePath("file(./env.yml):SECRET, 'default-value'")
88
+ assert.is(result.filePath, './env.yml')
89
+ })
90
+
91
+ test('extractFilePath - returns null for non-file patterns', () => {
92
+ assert.is(extractFilePath('opt:stage'), null)
93
+ assert.is(extractFilePath('${self:provider.stage}'), null)
94
+ assert.is(extractFilePath('env:FOO'), null)
95
+ })
96
+
97
+ test('extractFilePath - returns null for empty input', () => {
98
+ assert.is(extractFilePath(''), null)
99
+ })
100
+
101
+ test('extractFilePath - handles bare filename', () => {
102
+ const result = extractFilePath('file(config.json)')
103
+ assert.is(result.filePath, 'config.json')
104
+ })
105
+
106
+ // normalizeFileVariable tests
107
+
108
+ test('normalizeFileVariable - returns non-file strings unchanged', () => {
109
+ assert.is(normalizeFileVariable('opt:stage'), 'opt:stage')
110
+ assert.is(normalizeFileVariable('self:provider.stage'), 'self:provider.stage')
111
+ })
112
+
113
+ test('normalizeFileVariable - normalizes bare path in file()', () => {
114
+ assert.is(normalizeFileVariable('file(config.json)'), 'file(./config.json)')
115
+ })
116
+
117
+ test('normalizeFileVariable - normalizes bare path in text()', () => {
118
+ assert.is(normalizeFileVariable('text(readme.txt)'), 'text(./readme.txt)')
119
+ })
120
+
121
+ test('normalizeFileVariable - strips key accessor', () => {
122
+ assert.is(normalizeFileVariable('file(./env.yml):FOO'), 'file(./env.yml)')
123
+ assert.is(normalizeFileVariable('file(./config.json):nested.value'), 'file(./config.json)')
124
+ })
125
+
126
+ test('normalizeFileVariable - strips key accessor with array notation', () => {
127
+ assert.is(normalizeFileVariable('file(./data.json):items[0]'), 'file(./data.json)')
128
+ })
129
+
130
+ test('normalizeFileVariable - removes quotes from path', () => {
131
+ assert.is(normalizeFileVariable("file('./config.json')"), 'file(./config.json)')
132
+ assert.is(normalizeFileVariable('file("./config.json")'), 'file(./config.json)')
133
+ })
134
+
135
+ test('normalizeFileVariable - handles combined normalization', () => {
136
+ assert.is(normalizeFileVariable("file('config.json'):key"), 'file(./config.json)')
137
+ })
138
+
139
+ test('normalizeFileVariable - keeps ./ paths unchanged', () => {
140
+ assert.is(normalizeFileVariable('file(./already-normalized.yml)'), 'file(./already-normalized.yml)')
141
+ })
142
+
143
+ test('normalizeFileVariable - keeps ../ paths unchanged', () => {
144
+ assert.is(normalizeFileVariable('file(../parent/config.yml)'), 'file(../parent/config.yml)')
145
+ })
146
+
147
+ // resolveInnerVariables tests
148
+
149
+ const variableSyntax = /\$\{([^}]+)\}/g
150
+ const getProp = (obj, path) => path.split('.').reduce((o, k) => o && o[k], obj)
151
+
152
+ test('resolveInnerVariables - returns unchanged when no variables', () => {
153
+ const result = resolveInnerVariables('./config.json', variableSyntax, {}, getProp)
154
+ assert.is(result.resolved, './config.json')
155
+ assert.is(result.didResolve, false)
156
+ })
157
+
158
+ test('resolveInnerVariables - resolves self: variables from config', () => {
159
+ const config = { stage: 'prod' }
160
+ const result = resolveInnerVariables('./database-${self:stage}.json', variableSyntax, config, getProp)
161
+ assert.is(result.resolved, './database-prod.json')
162
+ assert.is(result.didResolve, true)
163
+ })
164
+
165
+ test('resolveInnerVariables - resolves dot.prop style variables', () => {
166
+ const config = { stage: 'dev' }
167
+ const result = resolveInnerVariables('./database-${stage}.json', variableSyntax, config, getProp)
168
+ assert.is(result.resolved, './database-dev.json')
169
+ assert.is(result.didResolve, true)
170
+ })
171
+
172
+ test('resolveInnerVariables - resolves nested config paths', () => {
173
+ const config = { provider: { stage: 'test' } }
174
+ const result = resolveInnerVariables('./db-${self:provider.stage}.json', variableSyntax, config, getProp)
175
+ assert.is(result.resolved, './db-test.json')
176
+ assert.is(result.didResolve, true)
177
+ })
178
+
179
+ test('resolveInnerVariables - resolves multiple variables', () => {
180
+ const config = { stage: 'prod', region: 'us-east-1' }
181
+ const result = resolveInnerVariables('./config-${self:stage}-${self:region}.json', variableSyntax, config, getProp)
182
+ assert.is(result.resolved, './config-prod-us-east-1.json')
183
+ assert.is(result.didResolve, true)
184
+ })
185
+
186
+ test('resolveInnerVariables - does not resolve if config value is a variable', () => {
187
+ const config = { stage: '${opt:stage}' }
188
+ const result = resolveInnerVariables('./database-${self:stage}.json', variableSyntax, config, getProp)
189
+ assert.is(result.resolved, './database-${self:stage}.json')
190
+ assert.is(result.didResolve, false)
191
+ })
192
+
193
+ test('resolveInnerVariables - does not resolve if config value is undefined', () => {
194
+ const config = {}
195
+ const result = resolveInnerVariables('./database-${self:stage}.json', variableSyntax, config, getProp)
196
+ assert.is(result.resolved, './database-${self:stage}.json')
197
+ assert.is(result.didResolve, false)
198
+ })
199
+
200
+ test('resolveInnerVariables - does not resolve env: or opt: variables', () => {
201
+ const config = { stage: 'prod' }
202
+ const result = resolveInnerVariables('./database-${env:STAGE}.json', variableSyntax, config, getProp)
203
+ assert.is(result.resolved, './database-${env:STAGE}.json')
204
+ assert.is(result.didResolve, false)
205
+ })
206
+
207
+ test('resolveInnerVariables - partial resolution fails completely', () => {
208
+ const config = { stage: 'prod' }
209
+ const result = resolveInnerVariables('./config-${self:stage}-${env:REGION}.json', variableSyntax, config, getProp)
210
+ assert.is(result.resolved, './config-${self:stage}-${env:REGION}.json')
211
+ assert.is(result.didResolve, false)
212
+ })
213
+
214
+ test.run()