@reinteractive/rails-insight 1.0.1

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 (90) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +210 -0
  3. package/bin/railsinsight.js +128 -0
  4. package/package.json +62 -0
  5. package/src/core/blast-radius.js +496 -0
  6. package/src/core/constants.js +39 -0
  7. package/src/core/context-loader.js +227 -0
  8. package/src/core/drift-detector.js +168 -0
  9. package/src/core/formatter.js +197 -0
  10. package/src/core/graph.js +510 -0
  11. package/src/core/indexer.js +595 -0
  12. package/src/core/patterns/api.js +27 -0
  13. package/src/core/patterns/auth.js +25 -0
  14. package/src/core/patterns/authorization.js +24 -0
  15. package/src/core/patterns/caching.js +19 -0
  16. package/src/core/patterns/component.js +18 -0
  17. package/src/core/patterns/config.js +15 -0
  18. package/src/core/patterns/controller.js +42 -0
  19. package/src/core/patterns/email.js +20 -0
  20. package/src/core/patterns/factory.js +31 -0
  21. package/src/core/patterns/gemfile.js +9 -0
  22. package/src/core/patterns/helper.js +10 -0
  23. package/src/core/patterns/job.js +12 -0
  24. package/src/core/patterns/model.js +123 -0
  25. package/src/core/patterns/realtime.js +17 -0
  26. package/src/core/patterns/route.js +27 -0
  27. package/src/core/patterns/schema.js +25 -0
  28. package/src/core/patterns/stimulus.js +13 -0
  29. package/src/core/patterns/storage.js +16 -0
  30. package/src/core/patterns/uploader.js +16 -0
  31. package/src/core/patterns/view.js +20 -0
  32. package/src/core/patterns/worker.js +12 -0
  33. package/src/core/patterns.js +27 -0
  34. package/src/core/scanner.js +394 -0
  35. package/src/core/version-detector.js +295 -0
  36. package/src/extractors/api.js +284 -0
  37. package/src/extractors/auth.js +853 -0
  38. package/src/extractors/authorization.js +785 -0
  39. package/src/extractors/caching.js +84 -0
  40. package/src/extractors/component.js +221 -0
  41. package/src/extractors/config.js +81 -0
  42. package/src/extractors/controller.js +273 -0
  43. package/src/extractors/coverage-snapshot.js +296 -0
  44. package/src/extractors/email.js +123 -0
  45. package/src/extractors/factory-registry.js +225 -0
  46. package/src/extractors/gemfile.js +440 -0
  47. package/src/extractors/helper.js +55 -0
  48. package/src/extractors/jobs.js +122 -0
  49. package/src/extractors/model.js +506 -0
  50. package/src/extractors/realtime.js +102 -0
  51. package/src/extractors/routes.js +251 -0
  52. package/src/extractors/schema.js +178 -0
  53. package/src/extractors/stimulus.js +149 -0
  54. package/src/extractors/storage.js +100 -0
  55. package/src/extractors/test-conventions.js +340 -0
  56. package/src/extractors/tier2.js +417 -0
  57. package/src/extractors/tier3.js +84 -0
  58. package/src/extractors/uploader.js +138 -0
  59. package/src/extractors/views.js +131 -0
  60. package/src/extractors/worker.js +62 -0
  61. package/src/git/diff-parser.js +132 -0
  62. package/src/providers/interface.js +12 -0
  63. package/src/providers/local-fs.js +318 -0
  64. package/src/server.js +71 -0
  65. package/src/tools/blast-radius-tools.js +129 -0
  66. package/src/tools/free-tools.js +44 -0
  67. package/src/tools/handlers/get-controller.js +93 -0
  68. package/src/tools/handlers/get-coverage-gaps.js +100 -0
  69. package/src/tools/handlers/get-deep-analysis.js +294 -0
  70. package/src/tools/handlers/get-domain-clusters.js +113 -0
  71. package/src/tools/handlers/get-factory-registry.js +43 -0
  72. package/src/tools/handlers/get-full-index.js +28 -0
  73. package/src/tools/handlers/get-model.js +108 -0
  74. package/src/tools/handlers/get-overview.js +153 -0
  75. package/src/tools/handlers/get-routes.js +18 -0
  76. package/src/tools/handlers/get-schema.js +40 -0
  77. package/src/tools/handlers/get-subgraph.js +82 -0
  78. package/src/tools/handlers/get-test-conventions.js +18 -0
  79. package/src/tools/handlers/get-well-tested-examples.js +51 -0
  80. package/src/tools/handlers/helpers.js +115 -0
  81. package/src/tools/handlers/index-project.js +36 -0
  82. package/src/tools/handlers/search-patterns.js +104 -0
  83. package/src/tools/index.js +34 -0
  84. package/src/tools/pro-tools.js +13 -0
  85. package/src/utils/file-reader.js +20 -0
  86. package/src/utils/inflector.js +223 -0
  87. package/src/utils/ruby-parser.js +115 -0
  88. package/src/utils/spec-style-detector.js +26 -0
  89. package/src/utils/token-counter.js +46 -0
  90. package/src/utils/yaml-parser.js +135 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * English Inflection Module
3
+ * Provides pluralization, singularization, and case conversion
4
+ * for Rails-style naming conventions.
5
+ *
6
+ * @module inflector
7
+ */
8
+
9
+ /** @type {Array<[RegExp, string]>} Pluralization rules (applied last-to-first). */
10
+ const PLURAL_RULES = [
11
+ [/quiz$/i, 'quizzes'],
12
+ [/^(ox)$/i, '$1en'],
13
+ [/(matr|vert|append)ix$/i, '$1ices'],
14
+ [/(x|ch|ss|sh)$/i, '$1es'],
15
+ [/([^aeiouy])y$/i, '$1ies'],
16
+ [/(hive)$/i, '$1s'],
17
+ [/([lr])f$/i, '$1ves'],
18
+ [/(shea|lea|wol|cal)f$/i, '$1ves'],
19
+ [/([^f])fe$/i, '$1ves'],
20
+ [/sis$/i, 'ses'],
21
+ [/([ti])um$/i, '$1a'],
22
+ [/(buffal|tomat|volcan|potat|ech|her|vet)o$/i, '$1oes'],
23
+ [/(bu|mis|gas)s$/i, '$1ses'],
24
+ [/(alias|status)$/i, '$1es'],
25
+ [/(octop|vir)us$/i, '$1i'],
26
+ [/(ax|test)is$/i, '$1es'],
27
+ [/s$/i, 's'],
28
+ [/$/, 's'],
29
+ ]
30
+
31
+ /** @type {Array<[RegExp, string]>} Singularization rules (applied last-to-first). */
32
+ const SINGULAR_RULES = [
33
+ [/(database)s$/i, '$1'],
34
+ [/(quiz)zes$/i, '$1'],
35
+ [/(matr)ices$/i, '$1ix'],
36
+ [/(vert|append)ices$/i, '$1ex'],
37
+ [/^(ox)en/i, '$1'],
38
+ [/(alias|status)es$/i, '$1'],
39
+ [/(octop|vir)i$/i, '$1us'],
40
+ [/(cris|ax|test)es$/i, '$1is'],
41
+ [/(shoe)s$/i, '$1'],
42
+ [/(o)es$/i, '$1'],
43
+ [/(bus)es$/i, '$1'],
44
+ [/([mlr])ives$/i, '$1ife'],
45
+ [/(x|ch|ss|sh)es$/i, '$1'],
46
+ [/(m)ovies$/i, '$1ovie'],
47
+ [/(s)eries$/i, '$1eries'],
48
+ [/([^aeiouy])ies$/i, '$1y'],
49
+ [/([lr])ves$/i, '$1f'],
50
+ [/(tive)s$/i, '$1'],
51
+ [/(hive)s$/i, '$1'],
52
+ [/([^f])ves$/i, '$1fe'],
53
+ [/(^analy)ses$/i, '$1sis'],
54
+ [/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i, '$1$2sis'],
55
+ [/([ti])a$/i, '$1um'],
56
+ [/(n)ews$/i, '$1ews'],
57
+ [/s$/i, ''],
58
+ ]
59
+
60
+ /** @type {Array<[string, string]>} Irregular singular/plural pairs. */
61
+ const IRREGULARS = [
62
+ ['person', 'people'],
63
+ ['man', 'men'],
64
+ ['woman', 'women'],
65
+ ['child', 'children'],
66
+ ['sex', 'sexes'],
67
+ ['move', 'moves'],
68
+ ['zombie', 'zombies'],
69
+ ['goose', 'geese'],
70
+ ['mouse', 'mice'],
71
+ ['tooth', 'teeth'],
72
+ ['foot', 'feet'],
73
+ ]
74
+
75
+ /** @type {Set<string>} Words that do not change between singular and plural. */
76
+ const UNCOUNTABLES = new Set([
77
+ 'equipment',
78
+ 'information',
79
+ 'rice',
80
+ 'money',
81
+ 'species',
82
+ 'series',
83
+ 'fish',
84
+ 'sheep',
85
+ 'jeans',
86
+ 'police',
87
+ 'news',
88
+ 'data',
89
+ 'feedback',
90
+ 'staff',
91
+ 'advice',
92
+ 'furniture',
93
+ 'homework',
94
+ 'knowledge',
95
+ 'luggage',
96
+ 'progress',
97
+ 'research',
98
+ 'software',
99
+ 'weather',
100
+ ])
101
+
102
+ /**
103
+ * Check if a word is uncountable (case-insensitive).
104
+ * @param {string} word
105
+ * @returns {boolean}
106
+ */
107
+ function isUncountable(word) {
108
+ return UNCOUNTABLES.has(word.toLowerCase())
109
+ }
110
+
111
+ /**
112
+ * Check irregular words in the given direction.
113
+ * @param {string} word
114
+ * @param {'toPlural'|'toSingular'} direction
115
+ * @returns {string|null} Replacement word or null
116
+ */
117
+ function checkIrregular(word, direction) {
118
+ const lower = word.toLowerCase()
119
+ for (const [singular, plural] of IRREGULARS) {
120
+ const from = direction === 'toPlural' ? singular : plural
121
+ const to = direction === 'toPlural' ? plural : singular
122
+ if (lower === from) {
123
+ return preserveCase(word, to)
124
+ }
125
+ }
126
+ return null
127
+ }
128
+
129
+ /**
130
+ * Preserve the first-letter casing of the original word on the replacement.
131
+ * @param {string} original
132
+ * @param {string} replacement
133
+ * @returns {string}
134
+ */
135
+ function preserveCase(original, replacement) {
136
+ if (!original || !replacement) return replacement
137
+ if (original[0] === original[0].toUpperCase()) {
138
+ return replacement.charAt(0).toUpperCase() + replacement.slice(1)
139
+ }
140
+ return replacement
141
+ }
142
+
143
+ /**
144
+ * Apply inflection rules to a word (first match wins, most specific first).
145
+ * @param {string} word
146
+ * @param {Array<[RegExp, string]>} rules
147
+ * @returns {string}
148
+ */
149
+ function applyRules(word, rules) {
150
+ for (const [pattern, replacement] of rules) {
151
+ if (pattern.test(word)) {
152
+ return word.replace(pattern, replacement)
153
+ }
154
+ }
155
+ return word
156
+ }
157
+
158
+ /**
159
+ * Pluralize an English word.
160
+ * @param {string} word - Singular English word
161
+ * @returns {string} Plural form
162
+ */
163
+ export function pluralize(word) {
164
+ if (!word) return ''
165
+ if (isUncountable(word)) return word
166
+ const irregular = checkIrregular(word, 'toPlural')
167
+ if (irregular) return irregular
168
+ return applyRules(word, PLURAL_RULES)
169
+ }
170
+
171
+ /**
172
+ * Singularize an English word.
173
+ * @param {string} word - Plural English word
174
+ * @returns {string} Singular form
175
+ */
176
+ export function singularize(word) {
177
+ if (!word) return ''
178
+ if (isUncountable(word)) return word
179
+ const irregular = checkIrregular(word, 'toSingular')
180
+ if (irregular) return irregular
181
+ return applyRules(word, SINGULAR_RULES)
182
+ }
183
+
184
+ /**
185
+ * Convert a PascalCase string to snake_case.
186
+ * @param {string} str
187
+ * @returns {string}
188
+ */
189
+ export function underscore(str) {
190
+ if (!str) return ''
191
+ return str
192
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
193
+ .replace(/([a-z\d])([A-Z])/g, '$1_$2')
194
+ .toLowerCase()
195
+ }
196
+
197
+ /**
198
+ * Convert a snake_case or plural string to a PascalCase singular class name.
199
+ * 'user_profiles' → 'UserProfile', 'comments' → 'Comment'
200
+ * @param {string} str - snake_case or plural string
201
+ * @returns {string} PascalCase singular class name
202
+ */
203
+ export function classify(str) {
204
+ if (!str) return ''
205
+ return str
206
+ .split(/[_\s]+/)
207
+ .map((segment, idx, arr) => {
208
+ const word = idx === arr.length - 1 ? singularize(segment) : segment
209
+ return word.charAt(0).toUpperCase() + word.slice(1)
210
+ })
211
+ .join('')
212
+ }
213
+
214
+ /**
215
+ * Convert a PascalCase class name to a snake_case plural table name.
216
+ * 'UserProfile' → 'user_profiles', 'Person' → 'people'
217
+ * @param {string} className - PascalCase class name
218
+ * @returns {string} snake_case plural table name
219
+ */
220
+ export function tableize(className) {
221
+ if (!className) return ''
222
+ return pluralize(underscore(className))
223
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Regex-based Ruby declaration extractor.
3
+ * All functions accept string content (not file paths).
4
+ */
5
+
6
+ /**
7
+ * Extract class declaration from Ruby source.
8
+ * @param {string} content - Ruby file content
9
+ * @returns {{ name: string, superclass: string|null }|null}
10
+ */
11
+ export function extractClassDeclaration(content) {
12
+ const match = content.match(/class\s+(\w+(?:::\w+)*)\s*<\s*(\w+(?:::\w+)*)/)
13
+ if (match) {
14
+ return { name: match[1], superclass: match[2] }
15
+ }
16
+ // Class without superclass
17
+ const basic = content.match(/class\s+(\w+(?:::\w+)*)\s*$/m)
18
+ if (basic) {
19
+ return { name: basic[1], superclass: null }
20
+ }
21
+ return null
22
+ }
23
+
24
+ /**
25
+ * Extract module declaration from Ruby source.
26
+ * @param {string} content
27
+ * @returns {string|null} Module name
28
+ */
29
+ export function extractModuleDeclaration(content) {
30
+ const match = content.match(/module\s+(\w+(?:::\w+)*)/)
31
+ return match ? match[1] : null
32
+ }
33
+
34
+ /**
35
+ * Extract all method names from Ruby source.
36
+ * @param {string} content
37
+ * @returns {string[]}
38
+ */
39
+ export function extractMethodNames(content) {
40
+ const methods = []
41
+ const regex = /^\s*def\s+(?:self\.)?(\w+[?!=]?)/gm
42
+ let match
43
+ while ((match = regex.exec(content)) !== null) {
44
+ methods.push(match[1])
45
+ }
46
+ return methods
47
+ }
48
+
49
+ /**
50
+ * Extract DSL calls (like has_many, validates, scope, etc).
51
+ * @param {string} content
52
+ * @param {RegExp} pattern - The DSL pattern to match
53
+ * @returns {RegExpExecArray[]} All matches
54
+ */
55
+ export function extractDSLCalls(content, pattern) {
56
+ const results = []
57
+ const regex = new RegExp(
58
+ pattern.source,
59
+ pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g',
60
+ )
61
+ let match
62
+ while ((match = regex.exec(content)) !== null) {
63
+ results.push(match)
64
+ }
65
+ return results
66
+ }
67
+
68
+ /**
69
+ * Extract include and extend statements.
70
+ * @param {string} content
71
+ * @returns {{ includes: string[], extends: string[] }}
72
+ */
73
+ export function extractIncludesExtends(content) {
74
+ const includes = []
75
+ const extends_ = []
76
+
77
+ const includeRegex = /^\s*include\s+(\w+(?:::\w+)*)/gm
78
+ let match
79
+ while ((match = includeRegex.exec(content)) !== null) {
80
+ includes.push(match[1])
81
+ }
82
+
83
+ const extendRegex = /^\s*extend\s+(\w+(?:::\w+)*)/gm
84
+ while ((match = extendRegex.exec(content)) !== null) {
85
+ extends_.push(match[1])
86
+ }
87
+
88
+ return { includes, extends: extends_ }
89
+ }
90
+
91
+ /**
92
+ * Extract the visibility sections (public/private/protected) from Ruby source.
93
+ * Returns methods grouped by visibility.
94
+ * @param {string} content
95
+ * @returns {{ public: string[], private: string[], protected: string[] }}
96
+ */
97
+ export function extractMethodsByVisibility(content) {
98
+ const result = { public: [], private: [], protected: [] }
99
+ let currentVisibility = 'public'
100
+ const lines = content.split('\n')
101
+
102
+ for (const line of lines) {
103
+ const visMatch = line.match(/^\s*(private|protected)\s*$/)
104
+ if (visMatch) {
105
+ currentVisibility = visMatch[1]
106
+ continue
107
+ }
108
+ const methodMatch = line.match(/^\s*def\s+(?:self\.)?(\w+[?!=]?)/)
109
+ if (methodMatch) {
110
+ result[currentVisibility].push(methodMatch[1])
111
+ }
112
+ }
113
+
114
+ return result
115
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared spec style detection utility.
3
+ * Detects whether a project uses request specs or controller specs.
4
+ *
5
+ * @module spec-style-detector
6
+ */
7
+
8
+ /**
9
+ * Detect spec style (request vs controller specs).
10
+ * @param {Array<{path: string}>} entries
11
+ * @returns {{primary: string, request_count: number, controller_count: number, has_mixed: boolean}}
12
+ */
13
+ export function detectSpecStyle(entries) {
14
+ const requestCount = entries.filter((e) =>
15
+ e.path.startsWith('spec/requests/'),
16
+ ).length
17
+ const controllerCount = entries.filter((e) =>
18
+ e.path.startsWith('spec/controllers/'),
19
+ ).length
20
+ return {
21
+ primary: requestCount >= controllerCount ? 'request' : 'controller',
22
+ request_count: requestCount,
23
+ controller_count: controllerCount,
24
+ has_mixed: requestCount > 0 && controllerCount > 0,
25
+ }
26
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Token estimation utilities.
3
+ * Uses content-aware character-per-token ratios for more accurate estimation.
4
+ */
5
+
6
+ /** Characters-per-token ratio for different content types. */
7
+ const CHARS_PER_TOKEN_PROSE = 4.0
8
+ const CHARS_PER_TOKEN_JSON = 3.0
9
+ const CHARS_PER_TOKEN_CODE = 3.5
10
+
11
+ /**
12
+ * Detect content type and return appropriate chars-per-token ratio.
13
+ * @param {string} text
14
+ * @returns {number}
15
+ */
16
+ function detectContentRatio(text) {
17
+ if (text.length < 10) return CHARS_PER_TOKEN_PROSE
18
+ const sample = text.slice(0, 200)
19
+ const jsonIndicators = (sample.match(/[{}\[\]:,"]/g) || []).length
20
+ const ratio = jsonIndicators / sample.length
21
+ if (ratio > 0.15) return CHARS_PER_TOKEN_JSON
22
+ if (ratio > 0.05) return CHARS_PER_TOKEN_CODE
23
+ return CHARS_PER_TOKEN_PROSE
24
+ }
25
+
26
+ /**
27
+ * Estimate tokens for a text string.
28
+ * @param {string} text
29
+ * @returns {number}
30
+ */
31
+ export function estimateTokens(text) {
32
+ if (!text) return 0
33
+ const ratio = detectContentRatio(text)
34
+ return Math.ceil(text.length / ratio)
35
+ }
36
+
37
+ /**
38
+ * Estimate tokens for a JSON-serializable object.
39
+ * @param {Object} obj
40
+ * @returns {number}
41
+ */
42
+ export function estimateTokensForObject(obj) {
43
+ if (obj === null || obj === undefined) return 0
44
+ const json = JSON.stringify(obj)
45
+ return estimateTokens(json)
46
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Simple YAML parser for Rails config files.
3
+ * Handles key-value pairs, arrays, nested structures, and anchors/aliases.
4
+ * Not a full YAML parser — designed for Rails convention YAML files.
5
+ */
6
+
7
+ /**
8
+ * Parse a simple YAML string into a nested object.
9
+ * @param {string} content - YAML content
10
+ * @returns {Object}
11
+ */
12
+ export function parseYaml(content) {
13
+ if (!content || typeof content !== 'string') return {}
14
+
15
+ const lines = content.split('\n')
16
+ const result = {}
17
+ const anchors = {}
18
+ const stack = [{ obj: result, indent: -1 }]
19
+
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i]
22
+
23
+ // Skip empty lines and comments
24
+ if (/^\s*$/.test(line) || /^\s*#/.test(line)) continue
25
+
26
+ // Handle ERB tags by removing them
27
+ const cleanLine = line.replace(/<%.*?%>/g, '')
28
+ if (/^\s*$/.test(cleanLine)) continue
29
+
30
+ const indentMatch = cleanLine.match(/^(\s*)/)
31
+ const indent = indentMatch ? indentMatch[1].length : 0
32
+
33
+ // Pop stack to correct parent
34
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
35
+ stack.pop()
36
+ }
37
+
38
+ // Merge key: <<: *alias
39
+ const mergeMatch = cleanLine.match(/^\s*<<:\s*\*(\w+)/)
40
+ if (mergeMatch) {
41
+ const aliasName = mergeMatch[1]
42
+ const source = anchors[aliasName]
43
+ if (source && typeof source === 'object') {
44
+ const parent = stack[stack.length - 1].obj
45
+ Object.assign(parent, structuredClone(source))
46
+ }
47
+ continue
48
+ }
49
+
50
+ // Array item
51
+ const arrayMatch = cleanLine.match(/^(\s*)-\s*(.+)$/)
52
+ if (arrayMatch) {
53
+ const parent = stack[stack.length - 1]
54
+ if (parent.arrayKey) {
55
+ if (!Array.isArray(parent.obj[parent.arrayKey])) {
56
+ parent.obj[parent.arrayKey] = []
57
+ }
58
+ parent.obj[parent.arrayKey].push(parseYamlValue(arrayMatch[2].trim()))
59
+ }
60
+ continue
61
+ }
62
+
63
+ // Key-value with anchor: key: &anchor value
64
+ const kvAnchorMatch = cleanLine.match(
65
+ /^(\s*)(\w[\w\s-]*):\s*&(\w+)\s*(.*)$/,
66
+ )
67
+ if (kvAnchorMatch) {
68
+ const key = kvAnchorMatch[2].trim()
69
+ const anchorName = kvAnchorMatch[3]
70
+ const value = kvAnchorMatch[4].trim()
71
+ const parent = stack[stack.length - 1].obj
72
+
73
+ if (value === '' || value === '|' || value === '>') {
74
+ parent[key] = {}
75
+ anchors[anchorName] = parent[key]
76
+ stack.push({ obj: parent[key], indent, arrayKey: null })
77
+ } else {
78
+ parent[key] = parseYamlValue(value)
79
+ anchors[anchorName] = parent[key]
80
+ }
81
+ continue
82
+ }
83
+
84
+ // Key-value with alias: key: *alias
85
+ const kvAliasMatch = cleanLine.match(/^(\s*)(\w[\w\s-]*):\s*\*(\w+)/)
86
+ if (kvAliasMatch) {
87
+ const key = kvAliasMatch[2].trim()
88
+ const aliasName = kvAliasMatch[3]
89
+ const parent = stack[stack.length - 1].obj
90
+ const source = anchors[aliasName]
91
+ parent[key] = source !== undefined ? structuredClone(source) : null
92
+ continue
93
+ }
94
+
95
+ // Key-value pair
96
+ const kvMatch = cleanLine.match(/^(\s*)(\w[\w\s-]*):\s*(.*)$/)
97
+ if (kvMatch) {
98
+ const key = kvMatch[2].trim()
99
+ const value = kvMatch[3].trim()
100
+ const parent = stack[stack.length - 1].obj
101
+
102
+ if (value === '' || value === '|' || value === '>') {
103
+ parent[key] = {}
104
+ stack.push({ obj: parent[key], indent, arrayKey: null })
105
+ } else {
106
+ parent[key] = parseYamlValue(value)
107
+ }
108
+ }
109
+ }
110
+
111
+ // Resolve anchors: for any anchor that points to an object, update the reference
112
+ // since nested keys were added after the anchor was stored
113
+ return result
114
+ }
115
+
116
+ /**
117
+ * Parse a YAML value string into appropriate JS type.
118
+ * @param {string} value
119
+ * @returns {string|number|boolean|null}
120
+ */
121
+ function parseYamlValue(value) {
122
+ if (value === 'true') return true
123
+ if (value === 'false') return false
124
+ if (value === 'null' || value === '~') return null
125
+ if (/^\d+$/.test(value)) return parseInt(value, 10)
126
+ if (/^\d+\.\d+$/.test(value)) return parseFloat(value)
127
+ // Remove quotes
128
+ if (
129
+ (value.startsWith('"') && value.endsWith('"')) ||
130
+ (value.startsWith("'") && value.endsWith("'"))
131
+ ) {
132
+ return value.slice(1, -1)
133
+ }
134
+ return value
135
+ }