@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,296 @@
1
+ /**
2
+ * Coverage Snapshot Extractor
3
+ * Parses SimpleCov JSON output and cross-references with structural
4
+ * data to produce per-file, per-method coverage analysis.
5
+ *
6
+ * @module coverage-snapshot
7
+ */
8
+
9
+ import { toOneDecimalPercent } from '../core/constants.js'
10
+
11
+ /**
12
+ * Extract coverage snapshot from SimpleCov output.
13
+ * @param {import('../providers/interface.js').FileProvider} provider
14
+ * @param {object} [modelExtractions] - Pre-extracted model data (for method line mapping)
15
+ * @param {object} [controllerExtractions] - Pre-extracted controller data
16
+ * @returns {object}
17
+ */
18
+ export function extractCoverageSnapshot(
19
+ provider,
20
+ modelExtractions = {},
21
+ controllerExtractions = {},
22
+ ) {
23
+ const result = {
24
+ available: false,
25
+ tool: null,
26
+ overall: {
27
+ line_coverage: null,
28
+ branch_coverage: null,
29
+ files_tracked: 0,
30
+ },
31
+ per_file: {},
32
+ uncovered_methods: [],
33
+ timestamp: null,
34
+ }
35
+
36
+ // Try to read SimpleCov JSON output
37
+ const coverageRaw = provider.readFile('coverage/coverage.json')
38
+ if (!coverageRaw) {
39
+ // Also try .resultset.json (older SimpleCov format)
40
+ const resultsetRaw = provider.readFile('coverage/.resultset.json')
41
+ if (!resultsetRaw) return result
42
+ return parseResultSet(
43
+ resultsetRaw,
44
+ result,
45
+ modelExtractions,
46
+ controllerExtractions,
47
+ )
48
+ }
49
+
50
+ let coverageData
51
+ try {
52
+ coverageData = JSON.parse(coverageRaw)
53
+ } catch {
54
+ result.parse_error = true
55
+ return result
56
+ }
57
+
58
+ result.available = true
59
+ result.tool = 'simplecov'
60
+ result.timestamp = coverageData.timestamp || null
61
+
62
+ // SimpleCov coverage.json structure varies by version
63
+ // Modern: { "coverage": { "file_path": { "lines": [...], "branches": {...} } } }
64
+ // Legacy: { "RSpec": { "coverage": { "file_path": { "lines": [...] } } } }
65
+ let fileCoverage = {}
66
+
67
+ if (coverageData.coverage) {
68
+ fileCoverage = coverageData.coverage
69
+ } else {
70
+ // Legacy format: find first test suite key
71
+ for (const key of Object.keys(coverageData)) {
72
+ if (coverageData[key] && coverageData[key].coverage) {
73
+ fileCoverage = coverageData[key].coverage
74
+ break
75
+ }
76
+ }
77
+ }
78
+
79
+ let totalLines = 0
80
+ let coveredLines = 0
81
+ let totalBranches = 0
82
+ let coveredBranches = 0
83
+
84
+ for (const [filePath, fileData] of Object.entries(fileCoverage)) {
85
+ const relativePath = normaliseToRelative(filePath)
86
+ if (!relativePath) continue
87
+
88
+ // Extract line coverage data
89
+ const lineData = Array.isArray(fileData) ? fileData : fileData.lines || []
90
+
91
+ let fileTotal = 0
92
+ let fileCovered = 0
93
+ const uncoveredLineNumbers = []
94
+
95
+ for (let i = 0; i < lineData.length; i++) {
96
+ const val = lineData[i]
97
+ if (val === null) continue // Non-relevant line (comments, blanks)
98
+ fileTotal++
99
+ totalLines++
100
+ if (val > 0) {
101
+ fileCovered++
102
+ coveredLines++
103
+ } else {
104
+ uncoveredLineNumbers.push(i + 1) // 1-indexed
105
+ }
106
+ }
107
+
108
+ const fileCoveragePercent = toOneDecimalPercent(fileCovered, fileTotal)
109
+
110
+ result.per_file[relativePath] = {
111
+ line_coverage: fileCoveragePercent,
112
+ lines_total: fileTotal,
113
+ lines_covered: fileCovered,
114
+ uncovered_lines: uncoveredLineNumbers,
115
+ }
116
+
117
+ // Branch coverage if available
118
+ if (fileData.branches && typeof fileData.branches === 'object') {
119
+ for (const [, branchData] of Object.entries(fileData.branches)) {
120
+ if (typeof branchData === 'object') {
121
+ for (const count of Object.values(branchData)) {
122
+ totalBranches++
123
+ if (count > 0) coveredBranches++
124
+ }
125
+ }
126
+ }
127
+
128
+ const fileBranchTotal = Object.values(fileData.branches).reduce(
129
+ (sum, bd) => {
130
+ return sum + (typeof bd === 'object' ? Object.keys(bd).length : 0)
131
+ },
132
+ 0,
133
+ )
134
+ const fileBranchCovered = Object.values(fileData.branches).reduce(
135
+ (sum, bd) => {
136
+ if (typeof bd !== 'object') return sum
137
+ return sum + Object.values(bd).filter((c) => c > 0).length
138
+ },
139
+ 0,
140
+ )
141
+
142
+ if (fileBranchTotal > 0) {
143
+ result.per_file[relativePath].branch_coverage = toOneDecimalPercent(
144
+ fileBranchCovered,
145
+ fileBranchTotal,
146
+ )
147
+ }
148
+ }
149
+
150
+ // Cross-reference uncovered lines with method line ranges
151
+ mapUncoveredMethods(
152
+ relativePath,
153
+ uncoveredLineNumbers,
154
+ modelExtractions,
155
+ controllerExtractions,
156
+ result.uncovered_methods,
157
+ )
158
+ }
159
+
160
+ result.overall.line_coverage = toOneDecimalPercent(coveredLines, totalLines)
161
+ result.overall.branch_coverage = toOneDecimalPercent(
162
+ coveredBranches,
163
+ totalBranches,
164
+ )
165
+ result.overall.files_tracked = Object.keys(result.per_file).length
166
+
167
+ return result
168
+ }
169
+
170
+ /**
171
+ * Map uncovered line numbers to specific methods using extractor data.
172
+ * @param {string} filePath
173
+ * @param {number[]} uncoveredLines
174
+ * @param {object} modelExtractions
175
+ * @param {object} controllerExtractions
176
+ * @param {Array} outputArray - mutated, results pushed here
177
+ */
178
+ function mapUncoveredMethods(
179
+ filePath,
180
+ uncoveredLines,
181
+ modelExtractions,
182
+ controllerExtractions,
183
+ outputArray,
184
+ ) {
185
+ if (uncoveredLines.length === 0) return
186
+
187
+ // Find the extraction for this file
188
+ let methodRanges = null
189
+ let entityName = null
190
+ let entityType = null
191
+
192
+ // Check models
193
+ for (const [name, model] of Object.entries(modelExtractions)) {
194
+ if (model.file === filePath && model.method_line_ranges) {
195
+ methodRanges = model.method_line_ranges
196
+ entityName = name
197
+ entityType = 'model'
198
+ break
199
+ }
200
+ }
201
+
202
+ // Check controllers
203
+ if (!methodRanges) {
204
+ for (const [name, ctrl] of Object.entries(controllerExtractions)) {
205
+ if (ctrl.file === filePath && ctrl.action_line_ranges) {
206
+ methodRanges = ctrl.action_line_ranges
207
+ entityName = name
208
+ entityType = 'controller'
209
+ break
210
+ }
211
+ }
212
+ }
213
+
214
+ if (!methodRanges) return
215
+
216
+ for (const [methodName, range] of Object.entries(methodRanges)) {
217
+ const uncoveredInMethod = uncoveredLines.filter(
218
+ (line) => line >= range.start && line <= range.end,
219
+ )
220
+ const totalMethodLines = range.end - range.start + 1
221
+
222
+ if (uncoveredInMethod.length > 0) {
223
+ outputArray.push({
224
+ file: filePath,
225
+ entity: entityName,
226
+ entity_type: entityType,
227
+ method: methodName,
228
+ uncovered_lines: uncoveredInMethod.length,
229
+ total_lines: totalMethodLines,
230
+ coverage: toOneDecimalPercent(
231
+ totalMethodLines - uncoveredInMethod.length,
232
+ totalMethodLines,
233
+ ),
234
+ })
235
+ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Normalise an absolute file path to a project-relative path.
241
+ * @param {string} filePath
242
+ * @returns {string|null}
243
+ */
244
+ function normaliseToRelative(filePath) {
245
+ if (filePath.startsWith('app/') || filePath.startsWith('lib/')) {
246
+ return filePath
247
+ }
248
+
249
+ const appIdx = filePath.lastIndexOf('/app/')
250
+ if (appIdx !== -1) return filePath.slice(appIdx + 1)
251
+
252
+ const libIdx = filePath.lastIndexOf('/lib/')
253
+ if (libIdx !== -1 && !filePath.includes('/gems/')) {
254
+ return filePath.slice(libIdx + 1)
255
+ }
256
+
257
+ return null
258
+ }
259
+
260
+ /**
261
+ * Parse legacy .resultset.json format.
262
+ * @param {string} raw
263
+ * @param {object} result
264
+ * @param {object} modelExtractions
265
+ * @param {object} controllerExtractions
266
+ * @returns {object}
267
+ */
268
+ function parseResultSet(raw, result, modelExtractions, controllerExtractions) {
269
+ try {
270
+ const data = JSON.parse(raw)
271
+ // .resultset.json: { "SuiteName": { "coverage": { ... }, "timestamp": ... } }
272
+ for (const key of Object.keys(data)) {
273
+ if (data[key] && data[key].coverage) {
274
+ // Re-use the main parser by constructing a coverage.json-like structure
275
+ const syntheticRaw = JSON.stringify({
276
+ coverage: data[key].coverage,
277
+ timestamp: data[key].timestamp || null,
278
+ })
279
+ const provider = {
280
+ readFile(path) {
281
+ if (path === 'coverage/coverage.json') return syntheticRaw
282
+ return null
283
+ },
284
+ }
285
+ return extractCoverageSnapshot(
286
+ provider,
287
+ modelExtractions,
288
+ controllerExtractions,
289
+ )
290
+ }
291
+ }
292
+ } catch {
293
+ result.parse_error = true
294
+ }
295
+ return result
296
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Email Extractor (#11)
3
+ * Extracts mailer metadata and Action Mailbox configuration.
4
+ */
5
+
6
+ import { EMAIL_PATTERNS } from '../core/patterns.js'
7
+
8
+ /**
9
+ * Extract email/mailer information.
10
+ * @param {import('../providers/interface.js').FileProvider} provider
11
+ * @param {Array<{path: string, category: string}>} entries
12
+ * @returns {object}
13
+ */
14
+ export function extractEmail(provider, entries) {
15
+ const result = {
16
+ mailers: [],
17
+ delivery: {},
18
+ interceptors: [],
19
+ observers: [],
20
+ mailbox: null,
21
+ }
22
+
23
+ // Extract mailers
24
+ const mailerEntries = entries.filter(
25
+ (e) => e.path.startsWith('app/mailers/') && e.path.endsWith('.rb'),
26
+ )
27
+
28
+ for (const entry of mailerEntries) {
29
+ const content = provider.readFile(entry.path)
30
+ if (!content) continue
31
+
32
+ const classMatch = content.match(EMAIL_PATTERNS.mailerClass)
33
+ if (!classMatch) continue
34
+
35
+ const mailer = {
36
+ class: classMatch[1],
37
+ file: entry.path,
38
+ superclass: classMatch[2],
39
+ methods: [],
40
+ default_from: null,
41
+ layout: null,
42
+ }
43
+
44
+ // Default from
45
+ const fromMatch = content.match(EMAIL_PATTERNS.defaultFrom)
46
+ if (fromMatch) mailer.default_from = fromMatch[1]
47
+
48
+ // Layout
49
+ const layoutMatch = content.match(EMAIL_PATTERNS.mailerLayout)
50
+ if (layoutMatch) mailer.layout = layoutMatch[1]
51
+
52
+ // Methods (actions) - extract all def methods, filter out private
53
+ const lines = content.split('\n')
54
+ let inPrivate = false
55
+ for (const line of lines) {
56
+ if (/^\s*private\b/.test(line) || /^\s*protected\b/.test(line)) {
57
+ inPrivate = true
58
+ continue
59
+ }
60
+ if (!inPrivate) {
61
+ const methodMatch = line.match(/^\s*def\s+(\w+)/)
62
+ if (methodMatch && methodMatch[1] !== 'initialize') {
63
+ mailer.methods.push(methodMatch[1])
64
+ }
65
+ }
66
+ }
67
+
68
+ result.mailers.push(mailer)
69
+ }
70
+
71
+ // Delivery config from environment files
72
+ for (const env of ['production', 'development', 'test']) {
73
+ const config = provider.readFile(`config/environments/${env}.rb`)
74
+ if (config) {
75
+ const deliveryMatch = config.match(EMAIL_PATTERNS.deliveryMethod)
76
+ if (deliveryMatch) {
77
+ result.delivery[env] = deliveryMatch[1]
78
+ }
79
+ }
80
+ }
81
+
82
+ // Interceptors and observers from initializers
83
+ const initContent =
84
+ provider.readFile('config/initializers/email.rb') ||
85
+ provider.readFile('config/initializers/mailer.rb') ||
86
+ ''
87
+ const appContent = provider.readFile('config/application.rb') || ''
88
+ const combined = initContent + '\n' + appContent
89
+
90
+ const intMatch = combined.match(EMAIL_PATTERNS.interceptor)
91
+ if (intMatch) result.interceptors.push(intMatch[1])
92
+
93
+ const obsMatch = combined.match(EMAIL_PATTERNS.observer)
94
+ if (obsMatch) result.observers.push(obsMatch[1])
95
+
96
+ // Action Mailbox
97
+ const mailboxEntries = entries.filter(
98
+ (e) => e.path.startsWith('app/mailboxes/') && e.path.endsWith('.rb'),
99
+ )
100
+ if (mailboxEntries.length > 0) {
101
+ result.mailbox = { present: true, mailboxes: [], routing: {} }
102
+ for (const entry of mailboxEntries) {
103
+ const content = provider.readFile(entry.path)
104
+ if (!content) continue
105
+ const classMatch = content.match(EMAIL_PATTERNS.mailboxClass)
106
+ if (classMatch && classMatch[1] !== 'ApplicationMailbox') {
107
+ result.mailbox.mailboxes.push(classMatch[1])
108
+ }
109
+ // Routing
110
+ const routingRe = new RegExp(EMAIL_PATTERNS.mailboxRouting.source, 'g')
111
+ let m
112
+ while ((m = routingRe.exec(content))) {
113
+ const routeStr = m[1].trim()
114
+ const routeMatch = routeStr.match(/(.+)\s*=>\s*:(\w+)/)
115
+ if (routeMatch) {
116
+ result.mailbox.routing[routeMatch[1].trim()] = routeMatch[2]
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ return result
123
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Factory Registry Extractor
3
+ * Parses FactoryBot factory definitions from spec/factories/ or test/factories/.
4
+ *
5
+ * @module factory-registry
6
+ */
7
+
8
+ import { FACTORY_PATTERNS } from '../core/patterns.js'
9
+ import { classify as inflectorClassify } from '../utils/inflector.js'
10
+
11
+ /**
12
+ * Extract factory definitions from factory files.
13
+ * @param {import('../providers/interface.js').FileProvider} provider
14
+ * @param {Array<{path: string, specCategory?: string}>} entries
15
+ * @returns {object}
16
+ */
17
+ export function extractFactoryRegistry(provider, entries) {
18
+ const result = {
19
+ factories: {},
20
+ total_factories: 0,
21
+ total_traits: 0,
22
+ factory_files: [],
23
+ missing_factories: [],
24
+ }
25
+
26
+ // Find factory files
27
+ const factoryEntries = entries.filter(
28
+ (e) =>
29
+ e.specCategory === 'factories' ||
30
+ (e.path.includes('factories/') && e.path.endsWith('.rb')),
31
+ )
32
+
33
+ for (const entry of factoryEntries) {
34
+ const content = provider.readFile(entry.path)
35
+ if (!content) continue
36
+
37
+ result.factory_files.push(entry.path)
38
+ const factories = parseFactoryFile(content, entry.path)
39
+
40
+ for (const factory of factories) {
41
+ result.factories[factory.name] = factory
42
+ result.total_factories++
43
+ result.total_traits += factory.traits.length
44
+ }
45
+ }
46
+
47
+ return result
48
+ }
49
+
50
+ /**
51
+ * Parse a single factory file for factory definitions.
52
+ * Handles nested factories and multiple factories per file.
53
+ * @param {string} content
54
+ * @param {string} filePath
55
+ * @returns {Array<object>}
56
+ */
57
+ function parseFactoryFile(content, filePath) {
58
+ const factories = []
59
+ const lines = content.split('\n')
60
+
61
+ let currentFactory = null
62
+ let inTransient = false
63
+ let depth = 0
64
+ let factoryDepth = 0
65
+
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const line = lines[i]
68
+ const trimmed = line.trim()
69
+
70
+ // Skip comments and blanks
71
+ if (!trimmed || trimmed.startsWith('#')) continue
72
+
73
+ // Skip FactoryBot.define wrapper — don't track its depth
74
+ if (/FactoryBot\.define\s+do/.test(trimmed)) continue
75
+
76
+ // Factory definition
77
+ const factoryMatch = trimmed.match(FACTORY_PATTERNS.factoryDef)
78
+ if (factoryMatch) {
79
+ // If we're already in a factory, save it first (nested factory)
80
+ if (currentFactory) {
81
+ factories.push(currentFactory)
82
+ }
83
+
84
+ const name = factoryMatch[1]
85
+ const explicitClass = factoryMatch[2] || null
86
+
87
+ currentFactory = {
88
+ name,
89
+ class_name: explicitClass || classify(name),
90
+ file: filePath,
91
+ traits: [],
92
+ sequences: [],
93
+ associations: [],
94
+ attributes: [],
95
+ has_transient: false,
96
+ has_after_create: false,
97
+ has_after_build: false,
98
+ }
99
+ factoryDepth = depth
100
+ depth++
101
+ inTransient = false
102
+ continue
103
+ }
104
+
105
+ if (!currentFactory) {
106
+ if (/\bdo\b/.test(trimmed) && !/\bend\b/.test(trimmed)) depth++
107
+ if (/\bend\b/.test(trimmed)) depth--
108
+ continue
109
+ }
110
+
111
+ // Trait definition
112
+ const traitMatch = trimmed.match(FACTORY_PATTERNS.trait)
113
+ if (traitMatch) {
114
+ currentFactory.traits.push(traitMatch[1])
115
+ depth++
116
+ continue
117
+ }
118
+
119
+ // Transient block
120
+ if (FACTORY_PATTERNS.transient.test(trimmed)) {
121
+ inTransient = true
122
+ currentFactory.has_transient = true
123
+ depth++
124
+ continue
125
+ }
126
+
127
+ // After create callback
128
+ if (FACTORY_PATTERNS.afterCreate.test(trimmed)) {
129
+ currentFactory.has_after_create = true
130
+ if (/\bdo\b/.test(trimmed) && !/\bend\b/.test(trimmed)) depth++
131
+ continue
132
+ }
133
+
134
+ // After build callback
135
+ if (FACTORY_PATTERNS.afterBuild.test(trimmed)) {
136
+ currentFactory.has_after_build = true
137
+ if (/\bdo\b/.test(trimmed) && !/\bend\b/.test(trimmed)) depth++
138
+ continue
139
+ }
140
+
141
+ // Association
142
+ const assocMatch = trimmed.match(FACTORY_PATTERNS.association)
143
+ if (assocMatch) {
144
+ currentFactory.associations.push({
145
+ name: assocMatch[1],
146
+ options: assocMatch[2] || null,
147
+ })
148
+ continue
149
+ }
150
+
151
+ // Sequence
152
+ const seqMatch =
153
+ trimmed.match(FACTORY_PATTERNS.sequence) ||
154
+ trimmed.match(FACTORY_PATTERNS.sequenceBlock)
155
+ if (seqMatch) {
156
+ currentFactory.sequences.push(seqMatch[1])
157
+ if (/\bdo\b/.test(trimmed) && !/\bend\b/.test(trimmed)) depth++
158
+ continue
159
+ }
160
+
161
+ // Track do...end nesting
162
+ if (/\bdo\b/.test(trimmed) && !/\bend\b/.test(trimmed)) {
163
+ depth++
164
+ }
165
+
166
+ if (/\bend\b/.test(trimmed)) {
167
+ depth--
168
+ if (depth <= factoryDepth) {
169
+ // Factory closed
170
+ if (inTransient) {
171
+ inTransient = false
172
+ } else {
173
+ factories.push(currentFactory)
174
+ currentFactory = null
175
+ }
176
+ }
177
+ }
178
+
179
+ // Attribute with block
180
+ if (!inTransient) {
181
+ const attrBlockMatch = trimmed.match(FACTORY_PATTERNS.attributeBlock)
182
+ if (
183
+ attrBlockMatch &&
184
+ !FACTORY_PATTERNS.trait.test(trimmed) &&
185
+ !FACTORY_PATTERNS.transient.test(trimmed) &&
186
+ !FACTORY_PATTERNS.afterCreate.test(trimmed) &&
187
+ !FACTORY_PATTERNS.afterBuild.test(trimmed)
188
+ ) {
189
+ const attrName = attrBlockMatch[1]
190
+ // Filter out ruby keywords and control structures
191
+ if (
192
+ ![
193
+ 'if',
194
+ 'unless',
195
+ 'do',
196
+ 'end',
197
+ 'def',
198
+ 'class',
199
+ 'module',
200
+ 'factory',
201
+ 'trait',
202
+ ].includes(attrName)
203
+ ) {
204
+ currentFactory.attributes.push(attrName)
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ // Handle unclosed factory
211
+ if (currentFactory) {
212
+ factories.push(currentFactory)
213
+ }
214
+
215
+ return factories
216
+ }
217
+
218
+ /**
219
+ * Convert a snake_case factory name to a PascalCase class name.
220
+ * @param {string} str
221
+ * @returns {string}
222
+ */
223
+ function classify(str) {
224
+ return inflectorClassify(str)
225
+ }