@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.
- package/LICENSE +15 -0
- package/README.md +210 -0
- package/bin/railsinsight.js +128 -0
- package/package.json +62 -0
- package/src/core/blast-radius.js +496 -0
- package/src/core/constants.js +39 -0
- package/src/core/context-loader.js +227 -0
- package/src/core/drift-detector.js +168 -0
- package/src/core/formatter.js +197 -0
- package/src/core/graph.js +510 -0
- package/src/core/indexer.js +595 -0
- package/src/core/patterns/api.js +27 -0
- package/src/core/patterns/auth.js +25 -0
- package/src/core/patterns/authorization.js +24 -0
- package/src/core/patterns/caching.js +19 -0
- package/src/core/patterns/component.js +18 -0
- package/src/core/patterns/config.js +15 -0
- package/src/core/patterns/controller.js +42 -0
- package/src/core/patterns/email.js +20 -0
- package/src/core/patterns/factory.js +31 -0
- package/src/core/patterns/gemfile.js +9 -0
- package/src/core/patterns/helper.js +10 -0
- package/src/core/patterns/job.js +12 -0
- package/src/core/patterns/model.js +123 -0
- package/src/core/patterns/realtime.js +17 -0
- package/src/core/patterns/route.js +27 -0
- package/src/core/patterns/schema.js +25 -0
- package/src/core/patterns/stimulus.js +13 -0
- package/src/core/patterns/storage.js +16 -0
- package/src/core/patterns/uploader.js +16 -0
- package/src/core/patterns/view.js +20 -0
- package/src/core/patterns/worker.js +12 -0
- package/src/core/patterns.js +27 -0
- package/src/core/scanner.js +394 -0
- package/src/core/version-detector.js +295 -0
- package/src/extractors/api.js +284 -0
- package/src/extractors/auth.js +853 -0
- package/src/extractors/authorization.js +785 -0
- package/src/extractors/caching.js +84 -0
- package/src/extractors/component.js +221 -0
- package/src/extractors/config.js +81 -0
- package/src/extractors/controller.js +273 -0
- package/src/extractors/coverage-snapshot.js +296 -0
- package/src/extractors/email.js +123 -0
- package/src/extractors/factory-registry.js +225 -0
- package/src/extractors/gemfile.js +440 -0
- package/src/extractors/helper.js +55 -0
- package/src/extractors/jobs.js +122 -0
- package/src/extractors/model.js +506 -0
- package/src/extractors/realtime.js +102 -0
- package/src/extractors/routes.js +251 -0
- package/src/extractors/schema.js +178 -0
- package/src/extractors/stimulus.js +149 -0
- package/src/extractors/storage.js +100 -0
- package/src/extractors/test-conventions.js +340 -0
- package/src/extractors/tier2.js +417 -0
- package/src/extractors/tier3.js +84 -0
- package/src/extractors/uploader.js +138 -0
- package/src/extractors/views.js +131 -0
- package/src/extractors/worker.js +62 -0
- package/src/git/diff-parser.js +132 -0
- package/src/providers/interface.js +12 -0
- package/src/providers/local-fs.js +318 -0
- package/src/server.js +71 -0
- package/src/tools/blast-radius-tools.js +129 -0
- package/src/tools/free-tools.js +44 -0
- package/src/tools/handlers/get-controller.js +93 -0
- package/src/tools/handlers/get-coverage-gaps.js +100 -0
- package/src/tools/handlers/get-deep-analysis.js +294 -0
- package/src/tools/handlers/get-domain-clusters.js +113 -0
- package/src/tools/handlers/get-factory-registry.js +43 -0
- package/src/tools/handlers/get-full-index.js +28 -0
- package/src/tools/handlers/get-model.js +108 -0
- package/src/tools/handlers/get-overview.js +153 -0
- package/src/tools/handlers/get-routes.js +18 -0
- package/src/tools/handlers/get-schema.js +40 -0
- package/src/tools/handlers/get-subgraph.js +82 -0
- package/src/tools/handlers/get-test-conventions.js +18 -0
- package/src/tools/handlers/get-well-tested-examples.js +51 -0
- package/src/tools/handlers/helpers.js +115 -0
- package/src/tools/handlers/index-project.js +36 -0
- package/src/tools/handlers/search-patterns.js +104 -0
- package/src/tools/index.js +34 -0
- package/src/tools/pro-tools.js +13 -0
- package/src/utils/file-reader.js +20 -0
- package/src/utils/inflector.js +223 -0
- package/src/utils/ruby-parser.js +115 -0
- package/src/utils/spec-style-detector.js +26 -0
- package/src/utils/token-counter.js +46 -0
- 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
|
+
}
|