configorama 0.11.0 → 1.0.0

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 (78) hide show
  1. package/README.md +429 -123
  2. package/cli.js +282 -49
  3. package/index.d.ts +43 -1
  4. package/package.json +5 -1
  5. package/src/capabilities.js +59 -0
  6. package/src/capabilities.test.js +44 -0
  7. package/src/display.js +70 -7
  8. package/src/display.test.js +82 -0
  9. package/src/errors.js +73 -0
  10. package/src/index.js +91 -1
  11. package/src/main.js +159 -19
  12. package/src/parsers/esm.js +1 -16
  13. package/src/parsers/typescript.js +1 -48
  14. package/src/resolvers/valueFromCron.js +4 -25
  15. package/src/resolvers/valueFromEval.js +11 -1
  16. package/src/resolvers/valueFromFile.js +8 -1
  17. package/src/resolvers/valueFromGit.js +43 -17
  18. package/src/resolvers/valueFromOptions.js +5 -4
  19. package/src/utils/filters/filterArgs.js +57 -0
  20. package/src/utils/filters/oneOf.js +77 -0
  21. package/src/utils/introspection/audit.js +78 -0
  22. package/src/utils/introspection/graph.js +43 -0
  23. package/src/utils/introspection/model.js +150 -0
  24. package/src/utils/introspection/model.test.js +93 -0
  25. package/src/utils/parsing/commentAnnotations.js +107 -0
  26. package/src/utils/parsing/commentAnnotations.test.js +123 -0
  27. package/src/utils/parsing/enrichMetadata.js +64 -1
  28. package/src/utils/parsing/enrichMetadata.test.js +84 -0
  29. package/src/utils/parsing/extractComment.js +145 -0
  30. package/src/utils/parsing/extractComment.test.js +182 -0
  31. package/src/utils/parsing/preProcess.js +2 -1
  32. package/src/utils/paths/findLineForKey.js +2 -2
  33. package/src/utils/paths/ignorePaths.js +22 -9
  34. package/src/utils/redaction/redact.js +78 -0
  35. package/src/utils/redaction/redact.test.js +38 -0
  36. package/src/utils/redaction/setupRedaction.js +47 -0
  37. package/src/utils/redaction/setupRedaction.test.js +68 -0
  38. package/src/utils/requirements/configRequirements.js +351 -0
  39. package/src/utils/requirements/configRequirements.test.js +380 -0
  40. package/src/utils/requirements/serializeRequirements.js +120 -0
  41. package/src/utils/requirements/serializeRequirements.test.js +211 -0
  42. package/src/utils/security/evalSafety.js +86 -0
  43. package/src/utils/security/evalSafety.test.js +61 -0
  44. package/src/utils/security/safetyPolicy.js +110 -0
  45. package/src/utils/security/safetyPolicy.test.js +29 -0
  46. package/src/utils/strings/didYouMean.js +70 -0
  47. package/src/utils/strings/didYouMean.test.js +52 -0
  48. package/src/utils/strings/formatFunctionArgs.js +6 -1
  49. package/src/utils/strings/splitByComma.js +5 -0
  50. package/src/utils/ui/configWizard.js +208 -34
  51. package/src/utils/ui/createEditorLink.js +17 -1
  52. package/src/utils/ui/promptDescriptors.js +196 -0
  53. package/src/utils/ui/promptDescriptors.test.js +162 -0
  54. package/src/utils/variables/cleanVariable.js +22 -0
  55. package/src/utils/variables/getVariableType.js +1 -0
  56. package/types/src/index.d.ts +0 -24
  57. package/types/src/index.d.ts.map +1 -1
  58. package/types/src/main.d.ts +16 -8
  59. package/types/src/main.d.ts.map +1 -1
  60. package/types/src/resolvers/valueFromFile.d.ts +0 -2
  61. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  62. package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
  63. package/types/src/resolvers/valueFromSelf.d.ts +1 -0
  64. package/types/src/resolvers/valueFromSelf.d.ts.map +1 -0
  65. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  66. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  67. package/types/src/utils/paths/findLineForKey.d.ts +0 -9
  68. package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
  69. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  70. package/types/src/utils/variables/variableUtils.d.ts +1 -1
  71. package/types/src/display.d.ts +0 -62
  72. package/types/src/display.d.ts.map +0 -1
  73. package/types/src/metadata.d.ts +0 -28
  74. package/types/src/metadata.d.ts.map +0 -1
  75. package/types/src/utils/BoundedMap.d.ts +0 -10
  76. package/types/src/utils/BoundedMap.d.ts.map +0 -1
  77. package/types/src/utils/paths/ignorePaths.d.ts +0 -5
  78. package/types/src/utils/paths/ignorePaths.d.ts.map +0 -1
@@ -0,0 +1,107 @@
1
+ const KNOWN_TAGS = new Set([
2
+ 'description',
3
+ 'from',
4
+ 'example',
5
+ 'default',
6
+ 'sensitive',
7
+ 'group',
8
+ 'deprecated',
9
+ ])
10
+
11
+ function toLines(input) {
12
+ if (Array.isArray(input)) return input
13
+ if (input === undefined || input === null) return []
14
+ return String(input).split(/\r?\n/)
15
+ }
16
+
17
+ function firstNonEmpty(values) {
18
+ return (values || []).find(value => value !== undefined && value !== null && String(value).trim() !== '')
19
+ }
20
+
21
+ function uniqueNonEmpty(values) {
22
+ const seen = new Set()
23
+ const result = []
24
+ for (const value of values || []) {
25
+ if (value === undefined || value === null) continue
26
+ const normalized = String(value).trim()
27
+ if (!normalized || seen.has(normalized)) continue
28
+ seen.add(normalized)
29
+ result.push(normalized)
30
+ }
31
+ return result
32
+ }
33
+
34
+ function parseSensitive(value) {
35
+ if (value === undefined || value === null) return undefined
36
+ const normalized = String(value).trim().toLowerCase()
37
+ if (normalized === 'true') return true
38
+ if (normalized === 'false') return false
39
+ return undefined
40
+ }
41
+
42
+ function normalizeAnnotations(tags, plainText) {
43
+ const descriptionFromTag = uniqueNonEmpty(tags.description).join(' ')
44
+ const obtainHint = firstNonEmpty(tags.from)
45
+ const examples = uniqueNonEmpty(tags.example)
46
+ const defaultHint = firstNonEmpty(tags.default)
47
+ const sensitive = parseSensitive(firstNonEmpty(tags.sensitive))
48
+ const group = firstNonEmpty(tags.group)
49
+ const deprecationMessage = firstNonEmpty(tags.deprecated)
50
+
51
+ const annotations = {}
52
+ if (descriptionFromTag) annotations.description = descriptionFromTag
53
+ if (obtainHint) annotations.obtainHint = String(obtainHint).trim()
54
+ if (examples.length) annotations.examples = examples
55
+ if (defaultHint) annotations.defaultHint = String(defaultHint).trim()
56
+ if (sensitive !== undefined) annotations.sensitive = sensitive
57
+ if (group) annotations.group = String(group).trim()
58
+ if (deprecationMessage) annotations.deprecationMessage = String(deprecationMessage).trim()
59
+
60
+ return {
61
+ annotations,
62
+ description: annotations.description || plainText || null,
63
+ descriptionSource: annotations.description ? 'commentTag' : null,
64
+ }
65
+ }
66
+
67
+ function parseCommentAnnotations(input) {
68
+ const tags = {}
69
+ const plainLines = []
70
+
71
+ for (const line of toLines(input)) {
72
+ const text = String(line || '').trim()
73
+ if (!text) continue
74
+
75
+ const match = text.match(/^@([a-z][a-z0-9_-]*)\b([\s\S]*)$/)
76
+ if (!match) {
77
+ plainLines.push(text)
78
+ continue
79
+ }
80
+
81
+ const tagName = match[1]
82
+ if (!KNOWN_TAGS.has(tagName)) {
83
+ plainLines.push(text)
84
+ continue
85
+ }
86
+
87
+ if (!tags[tagName]) tags[tagName] = []
88
+ tags[tagName].push(match[2].replace(/^\s/, ''))
89
+ }
90
+
91
+ const plainText = plainLines.join(' ').trim() || null
92
+ const normalized = normalizeAnnotations(tags, plainText)
93
+
94
+ return {
95
+ plainText,
96
+ tags,
97
+ annotations: normalized.annotations,
98
+ description: normalized.description,
99
+ descriptionSource: normalized.descriptionSource,
100
+ }
101
+ }
102
+
103
+ module.exports = {
104
+ KNOWN_TAGS,
105
+ parseCommentAnnotations,
106
+ parseSensitive,
107
+ }
@@ -0,0 +1,123 @@
1
+ const { test } = require('uvu')
2
+ const assert = require('uvu/assert')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { parseCommentAnnotations, parseSensitive } = require('./commentAnnotations')
6
+
7
+ test('parseCommentAnnotations parses supported tags into normalized annotation fields', () => {
8
+ const result = parseCommentAnnotations([
9
+ '@description Stripe live secret key',
10
+ '@from Stripe dashboard > Developers > API keys',
11
+ '@example sk_live_...',
12
+ '@default Set this in CI',
13
+ '@sensitive true',
14
+ '@group Payments',
15
+ '@deprecated Use STRIPE_RESTRICTED_KEY instead',
16
+ ])
17
+
18
+ assert.equal(result.tags, {
19
+ description: ['Stripe live secret key'],
20
+ from: ['Stripe dashboard > Developers > API keys'],
21
+ example: ['sk_live_...'],
22
+ default: ['Set this in CI'],
23
+ sensitive: ['true'],
24
+ group: ['Payments'],
25
+ deprecated: ['Use STRIPE_RESTRICTED_KEY instead'],
26
+ })
27
+ assert.equal(result.annotations, {
28
+ description: 'Stripe live secret key',
29
+ obtainHint: 'Stripe dashboard > Developers > API keys',
30
+ examples: ['sk_live_...'],
31
+ defaultHint: 'Set this in CI',
32
+ sensitive: true,
33
+ group: 'Payments',
34
+ deprecationMessage: 'Use STRIPE_RESTRICTED_KEY instead',
35
+ })
36
+ assert.is(result.description, 'Stripe live secret key')
37
+ assert.is(result.descriptionSource, 'commentTag')
38
+ })
39
+
40
+ test('parseCommentAnnotations keeps plain text separate from tag lines', () => {
41
+ const result = parseCommentAnnotations([
42
+ 'Stripe live secret key',
43
+ '@from Stripe dashboard > Developers > API keys',
44
+ ])
45
+
46
+ assert.is(result.plainText, 'Stripe live secret key')
47
+ assert.is(result.description, 'Stripe live secret key')
48
+ assert.is(result.descriptionSource, null)
49
+ assert.equal(result.annotations, {
50
+ obtainHint: 'Stripe dashboard > Developers > API keys',
51
+ })
52
+ })
53
+
54
+ test('parseCommentAnnotations treats unknown tag-shaped lines as plain description text', () => {
55
+ const result = parseCommentAnnotations([
56
+ '@david rotate this key after launch',
57
+ 'ping @ops before rotating this key',
58
+ ])
59
+
60
+ assert.is(result.plainText, '@david rotate this key after launch ping @ops before rotating this key')
61
+ assert.is(result.description, '@david rotate this key after launch ping @ops before rotating this key')
62
+ assert.equal(result.tags, {})
63
+ assert.equal(result.annotations, {})
64
+ })
65
+
66
+ test('parseCommentAnnotations preserves empty tag values in raw tags and omits normalized empty fields', () => {
67
+ const result = parseCommentAnnotations([
68
+ '@from',
69
+ '@default ',
70
+ '@group Platform',
71
+ ])
72
+
73
+ assert.equal(result.tags, {
74
+ from: [''],
75
+ default: [''],
76
+ group: ['Platform'],
77
+ })
78
+ assert.equal(result.annotations, {
79
+ group: 'Platform',
80
+ })
81
+ })
82
+
83
+ test('parseCommentAnnotations joins repeated descriptions and dedupes examples', () => {
84
+ const result = parseCommentAnnotations([
85
+ '@description Stripe live',
86
+ '@description secret key',
87
+ '@example sk_live_...',
88
+ '@example sk_live_...',
89
+ '@example stripe-key',
90
+ ])
91
+
92
+ assert.is(result.annotations.description, 'Stripe live secret key')
93
+ assert.equal(result.annotations.examples, ['sk_live_...', 'stripe-key'])
94
+ })
95
+
96
+ test('parseSensitive accepts only true and false case-insensitively', () => {
97
+ assert.is(parseSensitive('true'), true)
98
+ assert.is(parseSensitive('TRUE'), true)
99
+ assert.is(parseSensitive('false'), false)
100
+ assert.is(parseSensitive('False'), false)
101
+ assert.is(parseSensitive('yes'), undefined)
102
+ assert.is(parseSensitive('1'), undefined)
103
+ assert.is(parseSensitive(''), undefined)
104
+ })
105
+
106
+ test('parseCommentAnnotations ignores invalid sensitive values so heuristics can apply later', () => {
107
+ const result = parseCommentAnnotations([
108
+ '@sensitive yes',
109
+ '@description API token',
110
+ ])
111
+
112
+ assert.equal(result.tags.sensitive, ['yes'])
113
+ assert.equal(result.annotations, {
114
+ description: 'API token',
115
+ })
116
+ })
117
+
118
+ test('comment annotation parser does not require jsdoc-parser', () => {
119
+ const source = fs.readFileSync(path.join(__dirname, 'commentAnnotations.js'), 'utf8')
120
+ assert.not.match(source, /jsdoc-parser|doxxx|jsdoctypeparser/)
121
+ })
122
+
123
+ test.run()
@@ -3,6 +3,8 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('../paths/filePathUtils')
5
5
  const { preResolveString, preResolveSingle } = require('../resolution/preResolveVariable')
6
+ const { parseOneOfFilter } = require('../filters/oneOf')
7
+ const { extractComment } = require('./extractComment')
6
8
 
7
9
  // Type filters that indicate expected value types
8
10
  const TYPE_FILTERS = ['Boolean', 'String', 'Number', 'Array', 'Object', 'Json']
@@ -61,6 +63,8 @@ function createOccurrence(instance, varMatch, options = {}) {
61
63
 
62
64
  // Extract type from filters
63
65
  const type = extractTypeFromFilters(filters)
66
+ const oneOfFilter = filters && filters.find(filter => typeof filter === 'string' && filter.match(/^oneOf\(/))
67
+ const oneOf = parseOneOfFilter(oneOfFilter)
64
68
 
65
69
  const occurrence = {
66
70
  originalString: instance.originalStringValue,
@@ -79,6 +83,11 @@ function createOccurrence(instance, varMatch, options = {}) {
79
83
 
80
84
  if (description) {
81
85
  occurrence.description = description
86
+ occurrence.descriptionSource = 'help'
87
+ }
88
+
89
+ if (oneOf && oneOf.allowedValues) {
90
+ occurrence.allowedValues = oneOf.allowedValues
82
91
  }
83
92
 
84
93
  if (instance.defaultValueSrc) {
@@ -133,6 +142,56 @@ async function enrichMetadata(
133
142
  // Create resolve context early for resolving descriptions
134
143
  const configDir = configPath ? path.dirname(configPath) : undefined
135
144
  const resolveContext = { config: originalConfig, variableSyntax, configDir, options }
145
+ const commentFileType = configPath ? path.extname(configPath) : ''
146
+ let commentLines = []
147
+ if (configPath) {
148
+ try {
149
+ commentLines = fs.readFileSync(configPath, 'utf8').split('\n')
150
+ } catch (error) {
151
+ commentLines = []
152
+ }
153
+ }
154
+
155
+ function applyCommentFallback(occurrence) {
156
+ if (!occurrence) return occurrence
157
+ const comment = extractComment(occurrence.path, commentLines, commentFileType)
158
+ if (comment) {
159
+ if (comment.description && (!occurrence.description || comment.descriptionSource === 'commentTag')) {
160
+ occurrence.description = comment.description
161
+ occurrence.descriptionSource = comment.descriptionSource
162
+ }
163
+ if (comment.obtainHint) occurrence.obtainHint = comment.obtainHint
164
+ if (comment.examples) occurrence.examples = comment.examples
165
+ if (comment.defaultHint) occurrence.defaultHint = comment.defaultHint
166
+ if (comment.sensitive !== undefined) {
167
+ occurrence.sensitive = comment.sensitive
168
+ occurrence.sensitiveSource = 'commentTag'
169
+ }
170
+ if (comment.group) occurrence.group = comment.group
171
+ if (comment.deprecationMessage) occurrence.deprecationMessage = comment.deprecationMessage
172
+ }
173
+ return occurrence
174
+ }
175
+
176
+ async function applyDynamicOneOfAllowedValues(occurrence) {
177
+ if (!occurrence || occurrence.allowedValues || !occurrence.filters) return occurrence
178
+ const oneOfFilter = occurrence.filters.find(filter => typeof filter === 'string' && filter.match(/^oneOf\(/))
179
+ if (!oneOfFilter) return occurrence
180
+
181
+ const match = oneOfFilter.match(/^oneOf\(\s*\$\{([^}]+)\}\s*\)$/)
182
+ if (!match) return occurrence
183
+
184
+ try {
185
+ const resolved = await preResolveSingle(match[1], resolveContext)
186
+ if (Array.isArray(resolved)) {
187
+ occurrence.allowedValues = resolved.map(String)
188
+ }
189
+ } catch (error) {
190
+ // Leave unresolved dynamic oneOf args without allowedValues; runtime will report errors.
191
+ }
192
+
193
+ return occurrence
194
+ }
136
195
 
137
196
  const varKeys = Object.keys(metadata.variables)
138
197
 
@@ -439,6 +498,8 @@ async function enrichMetadata(
439
498
  for (const instance of varInstances) {
440
499
  // Add this occurrence with its full context
441
500
  const occurrence = createOccurrence(instance, key)
501
+ applyCommentFallback(occurrence)
502
+ await applyDynamicOneOfAllowedValues(occurrence)
442
503
  entry.occurrences.push(occurrence)
443
504
 
444
505
  // Find inner variables in resolveDetails (excluding the outermost variable itself)
@@ -511,6 +572,8 @@ async function enrichMetadata(
511
572
  hasFallback: !!detail.hasFallback,
512
573
  defaultValue: siblingDefaultValue,
513
574
  })
575
+ applyCommentFallback(siblingOccurrence)
576
+ await applyDynamicOneOfAllowedValues(siblingOccurrence)
514
577
  if (siblingDefaultValueSrc) {
515
578
  siblingOccurrence.defaultValueSrc = siblingDefaultValueSrc
516
579
  }
@@ -763,4 +826,4 @@ async function enrichMetadata(
763
826
  return metadata
764
827
  }
765
828
 
766
- module.exports = enrichMetadata
829
+ module.exports = enrichMetadata
@@ -0,0 +1,84 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const configorama = require('../../index')
8
+
9
+ function writeTempConfig(lines) {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'configorama-annotations-'))
11
+ const configPath = path.join(dir, 'config.yml')
12
+ fs.writeFileSync(configPath, lines.join('\n'))
13
+ return { dir, configPath }
14
+ }
15
+
16
+ test('enriched metadata maps comment annotations onto occurrences', async () => {
17
+ const { dir, configPath } = writeTempConfig([
18
+ '# Stripe live secret key',
19
+ '# @from Stripe dashboard > Developers > API keys',
20
+ '# @example sk_live_...',
21
+ '# @default Set in CI',
22
+ '# @sensitive false',
23
+ '# @group Payments',
24
+ '# @deprecated Use STRIPE_RESTRICTED_KEY instead',
25
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY}',
26
+ ])
27
+
28
+ try {
29
+ const analysis = await configorama.analyze(configPath, { options: {} })
30
+ const occurrence = analysis.uniqueVariables['env:STRIPE_SECRET_KEY'].occurrences[0]
31
+
32
+ assert.is(occurrence.description, 'Stripe live secret key')
33
+ assert.is(occurrence.descriptionSource, 'leadingComment')
34
+ assert.is(occurrence.obtainHint, 'Stripe dashboard > Developers > API keys')
35
+ assert.equal(occurrence.examples, ['sk_live_...'])
36
+ assert.is(occurrence.defaultHint, 'Set in CI')
37
+ assert.is(occurrence.defaultValue, undefined)
38
+ assert.is(occurrence.sensitive, false)
39
+ assert.is(occurrence.sensitiveSource, 'commentTag')
40
+ assert.is(occurrence.group, 'Payments')
41
+ assert.is(occurrence.deprecationMessage, 'Use STRIPE_RESTRICTED_KEY instead')
42
+ } finally {
43
+ fs.rmSync(dir, { recursive: true, force: true })
44
+ }
45
+ })
46
+
47
+ test('enriched metadata lets @description override help on the same occurrence', async () => {
48
+ const { dir, configPath } = writeTempConfig([
49
+ '# Plain comment loses',
50
+ '# @description Comment tag wins',
51
+ 'apiKey: ${env:CONFIGORAMA_ANNOTATED_API_KEY | help("Help loses")}',
52
+ ])
53
+
54
+ try {
55
+ const analysis = await configorama.analyze(configPath, { options: {} })
56
+ const occurrence = analysis.uniqueVariables['env:CONFIGORAMA_ANNOTATED_API_KEY'].occurrences[0]
57
+
58
+ assert.is(occurrence.description, 'Comment tag wins')
59
+ assert.is(occurrence.descriptionSource, 'commentTag')
60
+ } finally {
61
+ fs.rmSync(dir, { recursive: true, force: true })
62
+ }
63
+ })
64
+
65
+ test('enriched metadata keeps normal comment below help while preserving independent tags', async () => {
66
+ const { dir, configPath } = writeTempConfig([
67
+ '# Plain comment loses to help',
68
+ '# @from Dashboard > Tokens',
69
+ 'apiToken: ${env:CONFIGORAMA_ANNOTATED_TOKEN | help("Help wins")}',
70
+ ])
71
+
72
+ try {
73
+ const analysis = await configorama.analyze(configPath, { options: {} })
74
+ const occurrence = analysis.uniqueVariables['env:CONFIGORAMA_ANNOTATED_TOKEN'].occurrences[0]
75
+
76
+ assert.is(occurrence.description, 'Help wins')
77
+ assert.is(occurrence.descriptionSource, 'help')
78
+ assert.is(occurrence.obtainHint, 'Dashboard > Tokens')
79
+ } finally {
80
+ fs.rmSync(dir, { recursive: true, force: true })
81
+ }
82
+ })
83
+
84
+ test.run()
@@ -0,0 +1,145 @@
1
+ const { findLineByPath, findLineForKey } = require('../paths/findLineForKey')
2
+ const { parseCommentAnnotations } = require('./commentAnnotations')
3
+
4
+ function getCommentMarkers(fileType) {
5
+ if (fileType === '.json') return []
6
+ if (fileType === '.json5' || fileType === '.jsonc') return ['//']
7
+ if (fileType === '.hcl') return ['//', '#']
8
+ if (['.yml', '.yaml', '.toml', '.ini'].includes(fileType)) return ['#']
9
+ return ['#', '//']
10
+ }
11
+
12
+ function isDecorationComment(text) {
13
+ return /^[\s\-=_*]{3,}$/.test(text)
14
+ }
15
+
16
+ function stripCommentMarker(line, marker) {
17
+ return line.trim().slice(marker.length).replace(/^\s?/, '')
18
+ }
19
+
20
+ function findCommentStart(line, markers) {
21
+ let quote = null
22
+ let variableDepth = 0
23
+
24
+ for (let i = 0; i < line.length; i++) {
25
+ const char = line[i]
26
+ const prev = i > 0 ? line[i - 1] : ''
27
+
28
+ if (!quote && char === '$' && line[i + 1] === '{') {
29
+ variableDepth++
30
+ i++
31
+ continue
32
+ }
33
+ if (!quote && variableDepth > 0 && char === '}') {
34
+ variableDepth--
35
+ continue
36
+ }
37
+ if ((char === "'" || char === '"') && prev !== '\\') {
38
+ if (!quote) quote = char
39
+ else if (quote === char) quote = null
40
+ continue
41
+ }
42
+ if (quote || variableDepth > 0) continue
43
+
44
+ for (const marker of markers) {
45
+ if (line.slice(i, i + marker.length) === marker) {
46
+ return { index: i, marker }
47
+ }
48
+ }
49
+ }
50
+
51
+ return null
52
+ }
53
+
54
+ function getLineNumber(configPath, lines, fileType) {
55
+ if (['.yml', '.yaml', '.json5', '.jsonc'].includes(fileType)) {
56
+ const byPath = findLineByPath(configPath, lines, fileType)
57
+ if (byPath) return byPath
58
+ }
59
+
60
+ if (fileType === '.json') return 0
61
+ const key = String(configPath).split('.').pop()
62
+ return findLineForKey(key, lines, fileType)
63
+ }
64
+
65
+ function getLeadingComment(lines, lineIndex, markers) {
66
+ const comments = getLeadingCommentLines(lines, lineIndex, markers)
67
+ return comments.length ? comments.join(' ') : null
68
+ }
69
+
70
+ function getLeadingCommentLines(lines, lineIndex, markers) {
71
+ const comments = []
72
+ for (let i = lineIndex - 1; i >= 0; i--) {
73
+ const line = lines[i]
74
+ if (!line || line.trim() === '') break
75
+
76
+ const trimmed = line.trim()
77
+ const marker = markers.find(item => trimmed.startsWith(item))
78
+ if (!marker) break
79
+
80
+ const text = stripCommentMarker(trimmed, marker).trim()
81
+ if (text && !isDecorationComment(text)) comments.unshift(text)
82
+ }
83
+
84
+ return comments
85
+ }
86
+
87
+ function hasAnnotations(annotations) {
88
+ return annotations && Object.keys(annotations).length > 0
89
+ }
90
+
91
+ function buildCommentResult(commentLines, fallbackDescriptionSource) {
92
+ const parsed = parseCommentAnnotations(commentLines)
93
+ if (!parsed.description && !hasAnnotations(parsed.annotations)) return null
94
+
95
+ const result = {}
96
+ if (parsed.description) {
97
+ result.description = parsed.description
98
+ result.descriptionSource = parsed.descriptionSource || fallbackDescriptionSource
99
+ }
100
+
101
+ if (hasAnnotations(parsed.annotations)) {
102
+ result.annotations = parsed.annotations
103
+ for (const [key, value] of Object.entries(parsed.annotations)) {
104
+ if (key === 'description') continue
105
+ result[key] = value
106
+ }
107
+ }
108
+
109
+ return result
110
+ }
111
+
112
+ function extractComment(configPath, lines, fileType) {
113
+ try {
114
+ const markers = getCommentMarkers(fileType)
115
+ if (!markers.length || !configPath || !Array.isArray(lines) || !lines.length) return null
116
+
117
+ const lineNumber = getLineNumber(configPath, lines, fileType)
118
+ if (!lineNumber) return null
119
+
120
+ const lineIndex = lineNumber - 1
121
+ const line = lines[lineIndex] || ''
122
+ const inlineStart = findCommentStart(line, markers)
123
+ if (inlineStart) {
124
+ const text = stripCommentMarker(line.slice(inlineStart.index), inlineStart.marker).trim()
125
+ if (text && !isDecorationComment(text)) {
126
+ return buildCommentResult([text], 'comment')
127
+ }
128
+ }
129
+
130
+ const leading = getLeadingCommentLines(lines, lineIndex, markers)
131
+ const result = buildCommentResult(leading, 'leadingComment')
132
+ if (result) return result
133
+ } catch (error) {
134
+ return null
135
+ }
136
+
137
+ return null
138
+ }
139
+
140
+ module.exports = {
141
+ extractComment,
142
+ findCommentStart,
143
+ getCommentMarkers,
144
+ getLeadingComment,
145
+ }