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.
- package/index.d.ts +4 -0
- package/package.json +1 -1
- package/src/display.js +485 -0
- package/src/index.js +2 -0
- package/src/main.js +55 -878
- package/src/metadata.js +451 -0
- package/src/resolvers/valueFromGit.js +3 -2
- package/src/utils/BoundedMap.js +25 -0
- package/src/utils/BoundedMap.test.js +91 -0
- package/src/utils/parsing/parse.js +51 -1
- package/src/utils/paths/findLineForKey.js +134 -1
- package/src/utils/regex/index.js +2 -2
- package/src/utils/strings/replaceAll.js +2 -1
- package/types/src/display.d.ts +62 -0
- package/types/src/display.d.ts.map +1 -0
- package/types/src/index.d.ts +8 -0
- package/types/src/index.d.ts.map +1 -1
- package/types/src/main.d.ts +2 -16
- package/types/src/main.d.ts.map +1 -1
- package/types/src/metadata.d.ts +26 -0
- package/types/src/metadata.d.ts.map +1 -0
- package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
- package/types/src/utils/BoundedMap.d.ts +10 -0
- package/types/src/utils/BoundedMap.d.ts.map +1 -0
- package/types/src/utils/parsing/parse.d.ts.map +1 -1
- package/types/src/utils/paths/findLineForKey.d.ts +9 -0
- package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
- package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
- package/types/src/resolvers/valueFromSelf.d.ts +0 -1
- package/types/src/resolvers/valueFromSelf.d.ts.map +0 -1
package/src/metadata.js
ADDED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|