configorama 0.9.14 → 0.9.16

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.
@@ -0,0 +1,451 @@
1
+ // Variable metadata collection — traverses config to catalog variable usage
2
+ // Pure function that receives all data as arguments
3
+
4
+ const path = require('path')
5
+ const fs = require('fs')
6
+ const traverse = require('traverse')
7
+ const dotProp = require('dot-prop')
8
+ const { normalizePath, extractFilePath, resolveInnerVariables } = require('./utils/paths/filePathUtils')
9
+ const { findNestedVariables } = require('./utils/variables/findNestedVariables')
10
+ const { splitOnPipe } = require('./utils/strings/splitOnPipe')
11
+
12
+ /**
13
+ * Collect metadata about all variables found in the configuration
14
+ * @param {Object} params
15
+ * @param {RegExp} params.variableSyntax
16
+ * @param {Object} params.variablesKnownTypes
17
+ * @param {Object} params.variableTypes
18
+ * @param {RegExp|null} params.filterMatch
19
+ * @param {string} params.configFilePath
20
+ * @param {Object} params.displayConfig - rawOriginalConfig || originalConfig, used for traversal
21
+ * @param {Object} params.originalConfig - this.originalConfig, used for dotProp.get checks
22
+ * @param {string} params.varSuffix
23
+ * @param {RegExp} params.varSuffixWithSpacePattern
24
+ * @returns {Object} Metadata object containing variables, fileDependencies, and summary
25
+ */
26
+ function collectVariableMetadata({
27
+ variableSyntax,
28
+ variablesKnownTypes,
29
+ variableTypes,
30
+ filterMatch,
31
+ configFilePath,
32
+ displayConfig,
33
+ originalConfig,
34
+ varSuffix,
35
+ varSuffixWithSpacePattern,
36
+ }) {
37
+ const foundVariables = []
38
+ const variableData = {}
39
+ const fileRefs = []
40
+ const fileGlobPatterns = []
41
+ const preResolvedPaths = new Set()
42
+ const byConfigPath = []
43
+ const referencesMap = new Map()
44
+ let matchCount = 1
45
+
46
+ traverse(displayConfig).forEach(function (rawValue) {
47
+ if (typeof rawValue === 'string' && rawValue.match(variableSyntax)) {
48
+ const configValuePath = this.path.join('.')
49
+ /* Skip Fn::Sub variables */
50
+ if (configValuePath.endsWith('Fn::Sub')) {
51
+ return
52
+ }
53
+
54
+ const nested = findNestedVariables(
55
+ rawValue,
56
+ variableSyntax,
57
+ variablesKnownTypes,
58
+ configValuePath,
59
+ variableTypes
60
+ )
61
+
62
+ const lastItem = nested[nested.length - 1]
63
+ const lastKeyPath = this.path[this.path.length - 1]
64
+ const itemKey = (lastKeyPath.match(/[\d+]$/)) ? `${this.path[this.path.length - 2]}[${lastKeyPath}]` : lastKeyPath
65
+
66
+ // Extract filters from varMatch
67
+ const originalSrc = lastItem.varMatch || ''
68
+ const hasFilters = filterMatch && originalSrc.match(filterMatch)
69
+ let foundFilters = []
70
+ let keyWithoutFilters = originalSrc
71
+
72
+ if (hasFilters) {
73
+ // Extract filter names from the match (e.g., "| String}" -> ["String"])
74
+ const filterPart = hasFilters[0].replace(/}?$/, '') // Remove trailing }
75
+ foundFilters = splitOnPipe(filterPart)
76
+ .map((filter) => filter.trim())
77
+ .filter(Boolean)
78
+
79
+ // Remove filters from the key (replace "| String}" with suffix)
80
+ // Also clean up any trailing whitespace before the closing brace
81
+ keyWithoutFilters = originalSrc.replace(filterMatch, varSuffix).replace(varSuffixWithSpacePattern, varSuffix)
82
+ }
83
+
84
+ const key = keyWithoutFilters
85
+
86
+ // Helper to pre-resolve a variable from config
87
+ const preResolveFromConfig = (varString, varType) => {
88
+ if (!varString) return undefined
89
+ // Handle self: prefix
90
+ const varPath = varString.startsWith('self:') ? varString.slice(5) : varString
91
+ // Only pre-resolve dot.prop and self references
92
+ if (varType === 'dot.prop' || varType === 'self') {
93
+ const value = dotProp.get(displayConfig, varPath)
94
+ if (value !== undefined && typeof value !== 'object') {
95
+ return { resolved: value, path: varPath }
96
+ }
97
+ }
98
+ return undefined
99
+ }
100
+
101
+ // Strip filters from resolveDetails
102
+ const cleanedResolveDetails = nested.map(detail => {
103
+ const cleaned = { ...detail }
104
+ if (cleaned.varMatch && filterMatch) {
105
+ const match = cleaned.varMatch.match(filterMatch)
106
+ if (match) {
107
+ cleaned.varMatch = cleaned.varMatch.replace(filterMatch, '').replace(/\s+$/, '') + varSuffix
108
+ }
109
+ }
110
+ if (cleaned.variable && filterMatch) {
111
+ const match = cleaned.variable.match(filterMatch)
112
+ if (match) {
113
+ cleaned.variable = cleaned.variable.replace(filterMatch, '').replace(/\s+$/, '')
114
+ }
115
+ }
116
+ if (cleaned.varString && filterMatch) {
117
+ const match = cleaned.varString.match(filterMatch)
118
+ if (match) {
119
+ cleaned.varString = cleaned.varString.replace(filterMatch, '').trim()
120
+ }
121
+ }
122
+
123
+ // Pre-resolve dot.prop and self references
124
+ const preResolved = preResolveFromConfig(cleaned.varString || cleaned.variable, cleaned.variableType)
125
+ if (preResolved) {
126
+ cleaned.varResolved = preResolved.resolved
127
+ cleaned.varResolvedPath = preResolved.path
128
+ }
129
+
130
+ // Also clean fallbackValues if present
131
+ if (cleaned.fallbackValues && Array.isArray(cleaned.fallbackValues)) {
132
+ cleaned.fallbackValues = cleaned.fallbackValues.map(fb => {
133
+ const cleanedFb = { ...fb }
134
+ if (cleanedFb.varMatch && filterMatch) {
135
+ const match = cleanedFb.varMatch.match(filterMatch)
136
+ if (match) {
137
+ cleanedFb.varMatch = cleanedFb.varMatch.replace(filterMatch, '').trim()
138
+ }
139
+ }
140
+ if (cleanedFb.variable && filterMatch) {
141
+ const match = cleanedFb.variable.match(filterMatch)
142
+ if (match) {
143
+ cleanedFb.variable = cleanedFb.variable.replace(filterMatch, '').trim()
144
+ }
145
+ }
146
+ if (cleanedFb.stringValue && filterMatch) {
147
+ const match = cleanedFb.stringValue.match(filterMatch)
148
+ if (match) {
149
+ cleanedFb.stringValue = cleanedFb.stringValue.replace(filterMatch, '').trim()
150
+ }
151
+ }
152
+
153
+ // Pre-resolve fallback variable references
154
+ if (cleanedFb.stringValue && cleanedFb.stringValue.match(/^\$\{[^}]+\}$/)) {
155
+ const innerVar = cleanedFb.stringValue.slice(2, -1)
156
+ const fbPreResolved = preResolveFromConfig(innerVar, 'dot.prop')
157
+ if (fbPreResolved) {
158
+ cleanedFb.varResolved = fbPreResolved.resolved
159
+ cleanedFb.varResolvedPath = fbPreResolved.path
160
+ }
161
+ }
162
+
163
+ return cleanedFb
164
+ })
165
+ }
166
+ return cleaned
167
+ })
168
+
169
+ const varData = {
170
+ filters: foundFilters.length > 0 ? foundFilters : undefined,
171
+ path: configValuePath,
172
+ key: itemKey,
173
+ originalStringValue: rawValue,
174
+ variable: keyWithoutFilters,
175
+ variableWithFilters: originalSrc,
176
+ isRequired: false,
177
+ defaultValue: undefined,
178
+ defaultValueIsVar: undefined,
179
+ defaultValueSrc: undefined,
180
+ hasFallback: false,
181
+ matchIndex: matchCount++,
182
+ resolveOrder: [],
183
+ resolveDetails: cleanedResolveDetails,
184
+ }
185
+ let defaultValueIsVar = false
186
+
187
+ function calculateResolveOrder(item) {
188
+ // Helper to strip filters from variable strings
189
+ const stripFilters = (str) => {
190
+ if (!str || !filterMatch) return str
191
+ const match = str.match(filterMatch)
192
+ if (match) {
193
+ return str.replace(filterMatch, '').trim()
194
+ }
195
+ return str
196
+ }
197
+
198
+ if (item && item.fallbackValues) {
199
+ let hasResolvedFallback
200
+ let defaultValueSrc
201
+ const isSingleFallback = item.fallbackValues.length === 1
202
+ const order = ([stripFilters(item.valueBeforeFallback)]).concat(item.fallbackValues.map((f, i) => {
203
+ if (f.fallbackValues) {
204
+ const [nestedOrder, nestedResolvedFallback, nestedDefaultSrc] = calculateResolveOrder(f)
205
+ if (!hasResolvedFallback && nestedResolvedFallback) {
206
+ hasResolvedFallback = nestedResolvedFallback
207
+ defaultValueSrc = nestedDefaultSrc
208
+ }
209
+ return nestedOrder
210
+ }
211
+
212
+ const valueStr = stripFilters(f.stringValue || f.variable)
213
+
214
+ // Only set default from first resolvable fallback
215
+ if (!hasResolvedFallback && f.isResolvedFallback) {
216
+ if (f.varResolved !== undefined) {
217
+ hasResolvedFallback = f.varResolved
218
+ defaultValueSrc = f.varResolvedPath
219
+ } else if (!valueStr.match(/^\$\{[^}]+\}$/)) {
220
+ // Literal value - use as default
221
+ hasResolvedFallback = valueStr
222
+ }
223
+ // If variable can't resolve, don't set - let next fallback try
224
+ }
225
+
226
+ if (!hasResolvedFallback && f.isVariable) {
227
+ defaultValueIsVar = f
228
+ }
229
+
230
+ if (f.isResolvedFallback) {
231
+ if (isSingleFallback) {
232
+ // Single fallback: show "value (default)"
233
+ return `${valueStr} (default)`
234
+ } else {
235
+ // Multiple fallbacks: show resolved value if available
236
+ if (f.varResolved !== undefined) {
237
+ return `${valueStr} = ${f.varResolved}`
238
+ }
239
+ // If can't resolve, just show the value without annotation
240
+ return valueStr
241
+ }
242
+ }
243
+ return valueStr
244
+ })).flat()
245
+
246
+ return [order, hasResolvedFallback, defaultValueSrc]
247
+ }
248
+ return [[stripFilters(item.variable)], undefined, undefined]
249
+ }
250
+
251
+ const lastCleanedItem = cleanedResolveDetails[cleanedResolveDetails.length - 1]
252
+ const [resolveOrder, hasResolvedFallback, defaultValueSrc] = calculateResolveOrder(lastCleanedItem)
253
+ varData.resolveOrder = resolveOrder
254
+
255
+ if (defaultValueIsVar) {
256
+ varData.defaultValueIsVar = defaultValueIsVar
257
+ }
258
+
259
+ if (typeof hasResolvedFallback !== 'undefined') {
260
+ varData.defaultValue = hasResolvedFallback
261
+ }
262
+
263
+ if (defaultValueSrc) {
264
+ varData.defaultValueSrc = defaultValueSrc
265
+ }
266
+
267
+ if (typeof varData.defaultValue === 'undefined') {
268
+ varData.isRequired = true
269
+ }
270
+
271
+ if (varData.resolveOrder.length > 1) {
272
+ varData.hasFallback = true
273
+ }
274
+
275
+ // Extract file references
276
+ nested.forEach((detail) => {
277
+ // console.log('detail', detail)
278
+ if (detail.variableType && (detail.variableType === 'file' || detail.variableType === 'text')) {
279
+ const extracted = extractFilePath(detail.variable)
280
+ if (extracted) {
281
+ const normalizedPath = normalizePath(extracted.filePath)
282
+ if (!normalizedPath) return
283
+
284
+ // Handle variables in file paths - just record the pattern
285
+ if (!fileRefs.includes(normalizedPath)) {
286
+ fileRefs.push(normalizedPath)
287
+ }
288
+
289
+ // Check if path contains variables and create glob pattern
290
+ const containsVariables = !!normalizedPath.match(variableSyntax)
291
+ let globPattern
292
+ if (containsVariables) {
293
+ // Replace variable syntax ${...} with * for glob pattern
294
+ globPattern = normalizedPath.replace(variableSyntax, '*')
295
+ if (!fileGlobPatterns.includes(globPattern)) {
296
+ fileGlobPatterns.push(globPattern)
297
+ }
298
+ }
299
+
300
+ // Try to pre-resolve inner variables from originalConfig
301
+ let resolvedPath = normalizedPath
302
+ let resolvedVarString = rawValue
303
+ if (containsVariables) {
304
+ const pathResult = resolveInnerVariables(normalizedPath, variableSyntax, displayConfig, dotProp.get)
305
+ const varStringResult = resolveInnerVariables(rawValue, variableSyntax, displayConfig, dotProp.get)
306
+
307
+ if (pathResult.didResolve) {
308
+ resolvedPath = normalizePath(pathResult.resolved) || pathResult.resolved
309
+ resolvedVarString = varStringResult.resolved
310
+ preResolvedPaths.add(resolvedPath)
311
+ }
312
+ }
313
+
314
+ // Build byConfigPath entry
315
+ const absolutePath = configFilePath
316
+ ? path.resolve(path.dirname(configFilePath), resolvedPath)
317
+ : resolvedPath
318
+ const fileExists = configFilePath ? fs.existsSync(absolutePath) : false
319
+
320
+ const configPathEntry = {
321
+ location: configValuePath,
322
+ filePath: absolutePath,
323
+ relativePath: resolvedPath,
324
+ originalVariableString: rawValue,
325
+ resolvedVariableString: resolvedVarString,
326
+ containsVariables,
327
+ exists: fileExists,
328
+ }
329
+ if (globPattern) {
330
+ configPathEntry.pattern = globPattern
331
+ }
332
+ byConfigPath.push(configPathEntry)
333
+
334
+ // Build references entry (use resolvedPath as key when available)
335
+ const refKey = resolvedPath
336
+ if (!referencesMap.has(refKey)) {
337
+ referencesMap.set(refKey, {
338
+ resolvedPath: refKey,
339
+ refs: [],
340
+ })
341
+ }
342
+ const refEntry = referencesMap.get(refKey)
343
+ refEntry.refs.push({
344
+ location: configValuePath,
345
+ value: normalizedPath,
346
+ originalVariableString: rawValue,
347
+ })
348
+ }
349
+ }
350
+ })
351
+
352
+ variableData[key] = (variableData[key] || []).concat(varData)
353
+ foundVariables.push(rawValue)
354
+ }
355
+ })
356
+
357
+ // Make foundVariables array unique
358
+ const finalFoundVariables = [...new Set(foundVariables)]
359
+ const varKeys = Object.keys(variableData)
360
+
361
+ // Calculate summary using same logic as CLI display
362
+ let requiredCount = 0
363
+ let withDefaultsCount = 0
364
+ varKeys.forEach((key) => {
365
+ const instances = variableData[key]
366
+ const firstInstance = instances[0]
367
+
368
+ // Extract variable name from key (e.g. "${self:service}" -> "self:service")
369
+ const keyVarName = key.slice(2, -1).split(',')[0].trim()
370
+
371
+ // Find the resolveDetail that matches THIS variable (not any self-ref in the string)
372
+ let matchingDetail = null
373
+ for (const instance of instances) {
374
+ if (instance.resolveDetails && instance.resolveDetails.length > 0) {
375
+ const found = instance.resolveDetails.find((detail) => {
376
+ const detailVar = detail.valueBeforeFallback || detail.variable
377
+ return detailVar === keyVarName
378
+ })
379
+ if (found && (found.variableType === 'dot.prop' || found.variableType === 'self')) {
380
+ matchingDetail = found
381
+ break
382
+ }
383
+ }
384
+ }
385
+
386
+ // Also check defaultValueIsVar
387
+ if (!matchingDetail && firstInstance.defaultValueIsVar && (
388
+ firstInstance.defaultValueIsVar.variableType === 'self:' ||
389
+ firstInstance.defaultValueIsVar.variableType === 'dot.prop'
390
+ )) {
391
+ matchingDetail = firstInstance.defaultValueIsVar
392
+ }
393
+
394
+ // Check if truly required
395
+ let isTrulyRequired = false
396
+ if (matchingDetail) {
397
+ // Check if the self-reference resolves to a value
398
+ // Use valueBeforeFallback if present (strips inline fallback like ", false")
399
+ const varPath = matchingDetail.valueBeforeFallback || matchingDetail.variable
400
+ const cleanPath = varPath.replace('self:', '')
401
+ const dotPropValue = dotProp.get(originalConfig, cleanPath)
402
+ if (typeof dotPropValue === 'undefined') {
403
+ isTrulyRequired = true
404
+ } else {
405
+ // Enrich ALL instances with resolved self-reference value (overrides inline fallbacks)
406
+ instances.forEach((instance) => {
407
+ instance.defaultValueSrc = cleanPath
408
+ instance.defaultValue = dotPropValue
409
+ instance.isRequired = false
410
+ })
411
+ }
412
+ } else if (typeof firstInstance.defaultValue === 'undefined') {
413
+ isTrulyRequired = true
414
+ }
415
+
416
+ // Update isRequired based on computed isTrulyRequired
417
+ instances.forEach((instance) => {
418
+ instance.isRequired = isTrulyRequired
419
+ })
420
+
421
+ if (isTrulyRequired) {
422
+ requiredCount++
423
+ } else {
424
+ withDefaultsCount++
425
+ }
426
+ })
427
+
428
+ return {
429
+ variables: variableData,
430
+ uniqueVariables: {},
431
+ fileDependencies: {
432
+ globPatterns: fileGlobPatterns,
433
+ // all: fileRefs,
434
+ dynamicPaths: fileRefs.filter(ref => ref.indexOf('*') !== -1 || ref.match(variableSyntax)),
435
+ // Resolved paths: static paths + pre-resolved dynamic paths
436
+ resolvedPaths: [
437
+ ...fileRefs.filter(ref => ref.indexOf('*') === -1 && !ref.match(variableSyntax)),
438
+ ...preResolvedPaths
439
+ ],
440
+ byConfigPath,
441
+ references: Array.from(referencesMap.values()),
442
+ },
443
+ summary: {
444
+ totalVariables: varKeys.length,
445
+ requiredVariables: requiredCount,
446
+ variablesWithDefaults: withDefaultsCount
447
+ },
448
+ }
449
+ }
450
+
451
+ module.exports = { collectVariableMetadata }
@@ -7,6 +7,7 @@ const GitUrlParse = require('git-url-parse')
7
7
  const { functionRegex } = require('../utils/regex')
8
8
  const formatFunctionArgs = require('../utils/strings/formatFunctionArgs')
9
9
  const { findProjectRoot } = require('../utils/paths/findProjectRoot')
10
+ const BoundedMap = require('../utils/BoundedMap')
10
11
  const GIT_PREFIX = 'git'
11
12
  const gitVariableSyntax = RegExp(/^git:/g)
12
13
 
@@ -266,7 +267,7 @@ function createResolver(cwd) {
266
267
  return _getValueFromGit
267
268
  }
268
269
 
269
- const cache = new Map()
270
+ const cache = new BoundedMap(200)
270
271
 
271
272
  /**
272
273
  * Gets the last Git commit timestamp for a file
@@ -332,7 +333,7 @@ async function getGitTimestamp(_file, cwd, throwOnMissing = true) {
332
333
  }
333
334
  }
334
335
 
335
- const remoteCache = new Map()
336
+ const remoteCache = new BoundedMap(20)
336
337
 
337
338
  async function getGitRemote(name = 'origin') {
338
339
  if (remoteCache.has(name)) {
@@ -0,0 +1,25 @@
1
+ // Bounded Map with FIFO eviction when capacity exceeded
2
+ // Drop-in replacement for Map with get/set/has interface
3
+
4
+ class BoundedMap {
5
+ constructor(maxSize = 100) {
6
+ this._map = new Map()
7
+ this._maxSize = maxSize
8
+ }
9
+ get(key) {
10
+ return this._map.get(key)
11
+ }
12
+ has(key) {
13
+ return this._map.has(key)
14
+ }
15
+ set(key, value) {
16
+ if (this._map.size >= this._maxSize && !this._map.has(key)) {
17
+ const firstKey = this._map.keys().next().value
18
+ this._map.delete(firstKey)
19
+ }
20
+ this._map.set(key, value)
21
+ return this
22
+ }
23
+ }
24
+
25
+ module.exports = BoundedMap
@@ -0,0 +1,91 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const BoundedMap = require('./BoundedMap')
5
+
6
+ test('get returns undefined for missing key', () => {
7
+ const map = new BoundedMap(5)
8
+ assert.is(map.get('nope'), undefined)
9
+ })
10
+
11
+ test('set then get returns value', () => {
12
+ const map = new BoundedMap(5)
13
+ map.set('a', 1)
14
+ assert.is(map.get('a'), 1)
15
+ })
16
+
17
+ test('has returns true for existing, false for missing', () => {
18
+ const map = new BoundedMap(5)
19
+ map.set('a', 1)
20
+ assert.is(map.has('a'), true)
21
+ assert.is(map.has('b'), false)
22
+ })
23
+
24
+ test('set returns this for chaining', () => {
25
+ const map = new BoundedMap(5)
26
+ const ret = map.set('a', 1)
27
+ assert.is(ret, map)
28
+ })
29
+
30
+ test('evicts oldest entry when at maxSize', () => {
31
+ const map = new BoundedMap(3)
32
+ map.set('a', 1)
33
+ map.set('b', 2)
34
+ map.set('c', 3)
35
+ // At capacity — adding 'd' should evict 'a'
36
+ map.set('d', 4)
37
+ assert.is(map.has('a'), false, 'oldest entry should be evicted')
38
+ assert.is(map.get('b'), 2)
39
+ assert.is(map.get('c'), 3)
40
+ assert.is(map.get('d'), 4)
41
+ })
42
+
43
+ test('updating existing key does NOT evict', () => {
44
+ const map = new BoundedMap(3)
45
+ map.set('a', 1)
46
+ map.set('b', 2)
47
+ map.set('c', 3)
48
+ // Update existing key — no eviction should happen
49
+ map.set('a', 10)
50
+ assert.is(map.get('a'), 10)
51
+ assert.is(map.has('b'), true)
52
+ assert.is(map.has('c'), true)
53
+ })
54
+
55
+ test('eviction order is FIFO (insertion order)', () => {
56
+ const map = new BoundedMap(3)
57
+ map.set('a', 1)
58
+ map.set('b', 2)
59
+ map.set('c', 3)
60
+ map.set('d', 4) // evicts 'a'
61
+ map.set('e', 5) // evicts 'b'
62
+ assert.is(map.has('a'), false)
63
+ assert.is(map.has('b'), false)
64
+ assert.is(map.get('c'), 3)
65
+ assert.is(map.get('d'), 4)
66
+ assert.is(map.get('e'), 5)
67
+ })
68
+
69
+ test('constructor defaults to maxSize 100', () => {
70
+ const map = new BoundedMap()
71
+ // Fill to 100 — should be fine
72
+ for (let i = 0; i < 100; i++) {
73
+ map.set(`key${i}`, i)
74
+ }
75
+ assert.is(map.has('key0'), true)
76
+ // 101st entry evicts key0
77
+ map.set('overflow', 999)
78
+ assert.is(map.has('key0'), false)
79
+ assert.is(map.get('overflow'), 999)
80
+ })
81
+
82
+ test('maxSize of 1 works correctly', () => {
83
+ const map = new BoundedMap(1)
84
+ map.set('a', 1)
85
+ assert.is(map.get('a'), 1)
86
+ map.set('b', 2)
87
+ assert.is(map.has('a'), false)
88
+ assert.is(map.get('b'), 2)
89
+ })
90
+
91
+ test.run()
@@ -12,6 +12,50 @@ const cloudFormationSchema = require('./cloudformationSchema')
12
12
 
13
13
  const DEFAULT_VAR_SYNTAX = '\\${((?!AWS|stageVariables)[ ~:a-zA-Z0-9=+!@#%*<>?._\'",|\\-\\/\\(\\)\\\\]+?)}'
14
14
 
15
+ const KNOWN_EXTENSIONS = new Set([
16
+ '.yml', '.yaml', '.json', '.json5', '.jsonc',
17
+ '.toml', '.tml', '.ini',
18
+ '.tf', '.hcl',
19
+ '.js', '.cjs', '.mjs', '.esm',
20
+ '.ts', '.tsx', '.mts', '.cts',
21
+ '.md', '.mdx', '.markdown', '.mdown', '.mkdn', '.mkd', '.mdwn', '.markdn', '.mdtxt', '.mdtext'
22
+ ])
23
+
24
+ /**
25
+ * Detect config format from file contents when extension is missing
26
+ * @param {string} contents - Raw file contents
27
+ * @returns {string} Detected file extension (e.g. '.json', '.yml', '.toml')
28
+ */
29
+ function detectFormat(contents) {
30
+ const trimmed = contents.trimStart()
31
+
32
+ // JSON object: starts with {
33
+ if (trimmed[0] === '{') return '.json'
34
+
35
+ // TOML section headers must be checked before JSON array (both start with [)
36
+ // TOML: [section.subsection] (dots distinguish from INI)
37
+ if (/^\[[\w-]+\.[\w.-]+\]/.test(trimmed)) return '.toml'
38
+ // TOML: array-of-tables [[section]]
39
+ if (/^\[\[[\w.-]+\]\]/.test(trimmed)) return '.toml'
40
+
41
+ // JSON array: starts with [ followed by non-word char (quotes, numbers, braces, whitespace)
42
+ if (trimmed[0] === '[') return '.json'
43
+
44
+ // TOML: multi-line strings
45
+ if (trimmed.startsWith('"""')) return '.toml'
46
+ // TOML: key = value
47
+ if (/^\w[\w.-]*\s*=\s/m.test(trimmed)) return '.toml'
48
+
49
+ // YAML: starts with document marker
50
+ if (trimmed.startsWith('---')) return '.yml'
51
+
52
+ // HCL: terraform keywords
53
+ if (/^(resource|variable|locals|provider|data|module|output|terraform)\s/.test(trimmed)) return '.tf'
54
+
55
+ // Default: YAML (most permissive parser)
56
+ return '.yml'
57
+ }
58
+
15
59
  /**
16
60
  * @typedef {Object} ParseOptions
17
61
  * @property {string} contents - Raw file contents to parse
@@ -26,7 +70,13 @@ const DEFAULT_VAR_SYNTAX = '\\${((?!AWS|stageVariables)[ ~:a-zA-Z0-9=+!@#%*<>?._
26
70
  * @returns {Object} Parsed configuration object
27
71
  */
28
72
  function parseFileContents({ contents, filePath, varRegex, dynamicArgs }) {
29
- const fileType = path.extname(filePath)
73
+ let fileType = path.extname(filePath)
74
+
75
+ // Content-based detection for extensionless or unrecognized files
76
+ if (!fileType || !KNOWN_EXTENSIONS.has(fileType.toLowerCase())) {
77
+ fileType = detectFormat(contents)
78
+ }
79
+
30
80
  const regex = varRegex || new RegExp(DEFAULT_VAR_SYNTAX, 'g')
31
81
  let configObject
32
82