@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,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Indexer Orchestrator
|
|
3
|
+
* Wires all 6 layers together into a complete index.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loadProjectContext } from './context-loader.js'
|
|
7
|
+
import { detectVersions } from './version-detector.js'
|
|
8
|
+
import { scanStructure } from './scanner.js'
|
|
9
|
+
import { buildGraph } from './graph.js'
|
|
10
|
+
import { detectDrift } from './drift-detector.js'
|
|
11
|
+
import { extractGemfile } from '../extractors/gemfile.js'
|
|
12
|
+
import { extractModel } from '../extractors/model.js'
|
|
13
|
+
import { extractController } from '../extractors/controller.js'
|
|
14
|
+
import { extractRoutes } from '../extractors/routes.js'
|
|
15
|
+
import { extractSchema } from '../extractors/schema.js'
|
|
16
|
+
import { extractComponent } from '../extractors/component.js'
|
|
17
|
+
import { extractStimulusController } from '../extractors/stimulus.js'
|
|
18
|
+
import { extractViews } from '../extractors/views.js'
|
|
19
|
+
import { extractAuth } from '../extractors/auth.js'
|
|
20
|
+
import { extractAuthorization } from '../extractors/authorization.js'
|
|
21
|
+
import { extractJobs } from '../extractors/jobs.js'
|
|
22
|
+
import { extractEmail } from '../extractors/email.js'
|
|
23
|
+
import { extractStorage } from '../extractors/storage.js'
|
|
24
|
+
import { extractCaching } from '../extractors/caching.js'
|
|
25
|
+
import { extractRealtime } from '../extractors/realtime.js'
|
|
26
|
+
import { extractApi } from '../extractors/api.js'
|
|
27
|
+
import { extractConfig } from '../extractors/config.js'
|
|
28
|
+
import { extractTier2 } from '../extractors/tier2.js'
|
|
29
|
+
import { extractTier3 } from '../extractors/tier3.js'
|
|
30
|
+
import { extractTestConventions } from '../extractors/test-conventions.js'
|
|
31
|
+
import { extractFactoryRegistry } from '../extractors/factory-registry.js'
|
|
32
|
+
import { extractCoverageSnapshot } from '../extractors/coverage-snapshot.js'
|
|
33
|
+
import { extractHelper } from '../extractors/helper.js'
|
|
34
|
+
import { extractWorker } from '../extractors/worker.js'
|
|
35
|
+
import {
|
|
36
|
+
extractUploader,
|
|
37
|
+
detectMountedUploaders,
|
|
38
|
+
} from '../extractors/uploader.js'
|
|
39
|
+
import { pathToClassName } from '../tools/handlers/helpers.js'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run an extractor with error boundary. Returns fallback value on failure.
|
|
43
|
+
* @param {string} name - Extractor name for logging
|
|
44
|
+
* @param {Function} extractorFn - Extractor function to call
|
|
45
|
+
* @param {*} fallback - Value to return on error
|
|
46
|
+
* @param {boolean} verbose - Whether to log errors
|
|
47
|
+
* @param {string[]} errors - Array to push error names into
|
|
48
|
+
* @returns {*} Extraction result or fallback
|
|
49
|
+
*/
|
|
50
|
+
function safeExtract(name, extractorFn, fallback, verbose, errors) {
|
|
51
|
+
try {
|
|
52
|
+
return extractorFn()
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (verbose) {
|
|
55
|
+
process.stderr.write(
|
|
56
|
+
`[railsinsight] Extractor '${name}' failed: ${err.message}\n`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
errors.push(name)
|
|
60
|
+
return fallback
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the complete index from a FileProvider.
|
|
66
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
67
|
+
* @param {Object} [options]
|
|
68
|
+
* @param {string} [options.claudeMdPath]
|
|
69
|
+
* @param {string[]} [options.skills]
|
|
70
|
+
* @param {boolean} [options.verbose]
|
|
71
|
+
* @returns {Object} Complete index object
|
|
72
|
+
*/
|
|
73
|
+
export async function buildIndex(provider, options = {}) {
|
|
74
|
+
const extractionErrors = []
|
|
75
|
+
|
|
76
|
+
// Layer 1: Context
|
|
77
|
+
const context = loadProjectContext(provider, options.claudeMdPath)
|
|
78
|
+
|
|
79
|
+
// Layer 2: Versions
|
|
80
|
+
const versions = detectVersions(provider)
|
|
81
|
+
|
|
82
|
+
// Layer 3: Manifest
|
|
83
|
+
const manifest = scanStructure(provider)
|
|
84
|
+
const entries = manifest.entries || []
|
|
85
|
+
|
|
86
|
+
// Layer 4: Extractors
|
|
87
|
+
const gemInfo = safeExtract(
|
|
88
|
+
'gemfile',
|
|
89
|
+
() => extractGemfile(provider),
|
|
90
|
+
{ gems: [] },
|
|
91
|
+
options.verbose,
|
|
92
|
+
extractionErrors,
|
|
93
|
+
)
|
|
94
|
+
// Convert gems array to object keyed by name for extractor lookups
|
|
95
|
+
const gems = {}
|
|
96
|
+
if (Array.isArray(gemInfo.gems)) {
|
|
97
|
+
for (const g of gemInfo.gems) {
|
|
98
|
+
gems[g.name] = g
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Extract schema first so it can be passed to auth extractor for cross-referencing
|
|
103
|
+
const schemaData = safeExtract(
|
|
104
|
+
'schema',
|
|
105
|
+
() => extractSchema(provider),
|
|
106
|
+
{},
|
|
107
|
+
options.verbose,
|
|
108
|
+
extractionErrors,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const extractions = {
|
|
112
|
+
gemfile: gemInfo,
|
|
113
|
+
config: safeExtract(
|
|
114
|
+
'config',
|
|
115
|
+
() => extractConfig(provider),
|
|
116
|
+
{},
|
|
117
|
+
options.verbose,
|
|
118
|
+
extractionErrors,
|
|
119
|
+
),
|
|
120
|
+
schema: schemaData,
|
|
121
|
+
routes: safeExtract(
|
|
122
|
+
'routes',
|
|
123
|
+
() => extractRoutes(provider),
|
|
124
|
+
{},
|
|
125
|
+
options.verbose,
|
|
126
|
+
extractionErrors,
|
|
127
|
+
),
|
|
128
|
+
views: safeExtract(
|
|
129
|
+
'views',
|
|
130
|
+
() => extractViews(provider, entries),
|
|
131
|
+
{},
|
|
132
|
+
options.verbose,
|
|
133
|
+
extractionErrors,
|
|
134
|
+
),
|
|
135
|
+
auth: safeExtract(
|
|
136
|
+
'auth',
|
|
137
|
+
() => extractAuth(provider, entries, { gems }, schemaData),
|
|
138
|
+
{},
|
|
139
|
+
options.verbose,
|
|
140
|
+
extractionErrors,
|
|
141
|
+
),
|
|
142
|
+
authorization: safeExtract(
|
|
143
|
+
'authorization',
|
|
144
|
+
() => extractAuthorization(provider, entries, { gems }, schemaData),
|
|
145
|
+
{},
|
|
146
|
+
options.verbose,
|
|
147
|
+
extractionErrors,
|
|
148
|
+
),
|
|
149
|
+
jobs: safeExtract(
|
|
150
|
+
'jobs',
|
|
151
|
+
() => extractJobs(provider, entries, { gems }),
|
|
152
|
+
{},
|
|
153
|
+
options.verbose,
|
|
154
|
+
extractionErrors,
|
|
155
|
+
),
|
|
156
|
+
email: safeExtract(
|
|
157
|
+
'email',
|
|
158
|
+
() => extractEmail(provider, entries),
|
|
159
|
+
{ mailers: [] },
|
|
160
|
+
options.verbose,
|
|
161
|
+
extractionErrors,
|
|
162
|
+
),
|
|
163
|
+
storage: safeExtract(
|
|
164
|
+
'storage',
|
|
165
|
+
() => extractStorage(provider, entries, { gems }),
|
|
166
|
+
{},
|
|
167
|
+
options.verbose,
|
|
168
|
+
extractionErrors,
|
|
169
|
+
),
|
|
170
|
+
caching: safeExtract(
|
|
171
|
+
'caching',
|
|
172
|
+
() => extractCaching(provider, entries),
|
|
173
|
+
{},
|
|
174
|
+
options.verbose,
|
|
175
|
+
extractionErrors,
|
|
176
|
+
),
|
|
177
|
+
realtime: safeExtract(
|
|
178
|
+
'realtime',
|
|
179
|
+
() => extractRealtime(provider, entries, { gems }),
|
|
180
|
+
{ channels: [] },
|
|
181
|
+
options.verbose,
|
|
182
|
+
extractionErrors,
|
|
183
|
+
),
|
|
184
|
+
api: safeExtract(
|
|
185
|
+
'api',
|
|
186
|
+
() => extractApi(provider, entries, { gems }),
|
|
187
|
+
{},
|
|
188
|
+
options.verbose,
|
|
189
|
+
extractionErrors,
|
|
190
|
+
),
|
|
191
|
+
tier2: safeExtract(
|
|
192
|
+
'tier2',
|
|
193
|
+
() => extractTier2(provider, entries, { gems }),
|
|
194
|
+
{},
|
|
195
|
+
options.verbose,
|
|
196
|
+
extractionErrors,
|
|
197
|
+
),
|
|
198
|
+
tier3: safeExtract(
|
|
199
|
+
'tier3',
|
|
200
|
+
() => extractTier3(provider, entries, { gems }),
|
|
201
|
+
{},
|
|
202
|
+
options.verbose,
|
|
203
|
+
extractionErrors,
|
|
204
|
+
),
|
|
205
|
+
models: {},
|
|
206
|
+
controllers: {},
|
|
207
|
+
components: {},
|
|
208
|
+
stimulus_controllers: [],
|
|
209
|
+
helpers: {},
|
|
210
|
+
workers: {},
|
|
211
|
+
uploaders: { uploaders: {}, mounted: [] },
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Per-file extractors (categoryName is the string label, category is the number)
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
if (entry.categoryName === 'models') {
|
|
217
|
+
const className = pathToClassName(entry.path)
|
|
218
|
+
const model = safeExtract(
|
|
219
|
+
`model:${className}`,
|
|
220
|
+
() => extractModel(provider, entry.path, className),
|
|
221
|
+
null,
|
|
222
|
+
options.verbose,
|
|
223
|
+
extractionErrors,
|
|
224
|
+
)
|
|
225
|
+
if (model) extractions.models[className] = model
|
|
226
|
+
} else if (entry.categoryName === 'controllers') {
|
|
227
|
+
const ctrl = safeExtract(
|
|
228
|
+
`controller:${entry.path}`,
|
|
229
|
+
() => extractController(provider, entry.path),
|
|
230
|
+
null,
|
|
231
|
+
options.verbose,
|
|
232
|
+
extractionErrors,
|
|
233
|
+
)
|
|
234
|
+
if (ctrl) {
|
|
235
|
+
const name = pathToClassName(entry.path)
|
|
236
|
+
extractions.controllers[name] = ctrl
|
|
237
|
+
}
|
|
238
|
+
} else if (entry.categoryName === 'components') {
|
|
239
|
+
const comp = extractComponent(provider, entry.path)
|
|
240
|
+
if (comp) {
|
|
241
|
+
const name = pathToClassName(entry.path)
|
|
242
|
+
extractions.components[name] = comp
|
|
243
|
+
}
|
|
244
|
+
} else if (entry.categoryName === 'stimulus') {
|
|
245
|
+
const sc = extractStimulusController(provider, entry.path)
|
|
246
|
+
if (sc) extractions.stimulus_controllers.push(sc)
|
|
247
|
+
} else if (
|
|
248
|
+
entry.categoryName === 'views' &&
|
|
249
|
+
entry.path.startsWith('app/helpers/')
|
|
250
|
+
) {
|
|
251
|
+
const helper = extractHelper(provider, entry.path)
|
|
252
|
+
if (helper) extractions.helpers[helper.module] = helper
|
|
253
|
+
} else if (
|
|
254
|
+
entry.categoryName === 'jobs' &&
|
|
255
|
+
entry.workerType === 'sidekiq_native'
|
|
256
|
+
) {
|
|
257
|
+
const worker = extractWorker(provider, entry.path)
|
|
258
|
+
if (worker) extractions.workers[worker.class] = worker
|
|
259
|
+
} else if (
|
|
260
|
+
entry.categoryName === 'storage' &&
|
|
261
|
+
entry.path.startsWith('app/uploaders/')
|
|
262
|
+
) {
|
|
263
|
+
const uploader = extractUploader(provider, entry.path)
|
|
264
|
+
if (uploader) extractions.uploaders.uploaders[uploader.class] = uploader
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Test convention and factory analysis
|
|
269
|
+
extractions.test_conventions = extractTestConventions(provider, entries, {
|
|
270
|
+
gems,
|
|
271
|
+
})
|
|
272
|
+
extractions.factory_registry = extractFactoryRegistry(provider, entries)
|
|
273
|
+
|
|
274
|
+
// Cross-reference CarrierWave mount_uploader in models
|
|
275
|
+
extractions.uploaders.mounted = detectMountedUploaders(
|
|
276
|
+
provider,
|
|
277
|
+
extractions.models,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
// Coverage snapshot (depends on models and controllers being extracted first
|
|
281
|
+
// for method line range cross-referencing)
|
|
282
|
+
extractions.coverage_snapshot = extractCoverageSnapshot(
|
|
283
|
+
provider,
|
|
284
|
+
extractions.models,
|
|
285
|
+
extractions.controllers,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
// STI relationships detection
|
|
289
|
+
detectSTIRelationships(extractions.models)
|
|
290
|
+
|
|
291
|
+
// Layer 5: Graph + Rankings
|
|
292
|
+
const { graph, relationships, rankings } = buildGraph(
|
|
293
|
+
extractions,
|
|
294
|
+
manifest,
|
|
295
|
+
options.skills,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
// Drift detection
|
|
299
|
+
const drift = detectDrift(context, versions, extractions)
|
|
300
|
+
|
|
301
|
+
// PWA detection
|
|
302
|
+
const hasPwa = entries.some((e) => e.pwaFile === true)
|
|
303
|
+
|
|
304
|
+
// File-to-entity mapping for blast radius analysis
|
|
305
|
+
const fileEntityMap = buildFileEntityMap(extractions, manifest)
|
|
306
|
+
|
|
307
|
+
// Statistics
|
|
308
|
+
const statistics = computeStatistics(manifest, extractions, relationships)
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
version: '1.0.0',
|
|
312
|
+
generated_at: new Date().toISOString(),
|
|
313
|
+
context,
|
|
314
|
+
versions,
|
|
315
|
+
manifest: {
|
|
316
|
+
entries,
|
|
317
|
+
byCategory: manifest.byCategory,
|
|
318
|
+
stats: manifest.stats,
|
|
319
|
+
total_files: entries.length,
|
|
320
|
+
},
|
|
321
|
+
extractions,
|
|
322
|
+
relationships,
|
|
323
|
+
rankings,
|
|
324
|
+
graph,
|
|
325
|
+
drift,
|
|
326
|
+
statistics,
|
|
327
|
+
fileEntityMap,
|
|
328
|
+
extraction_errors: extractionErrors,
|
|
329
|
+
pwa: { detected: hasPwa },
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Detect STI (Single Table Inheritance) relationships among extracted models.
|
|
335
|
+
* Marks base classes with sti_base=true and sti_subclasses, and children with sti_parent.
|
|
336
|
+
* @param {Object<string, Object>} models
|
|
337
|
+
*/
|
|
338
|
+
function detectSTIRelationships(models) {
|
|
339
|
+
const stiSubclasses = {}
|
|
340
|
+
for (const [name, model] of Object.entries(models)) {
|
|
341
|
+
if (
|
|
342
|
+
model.superclass &&
|
|
343
|
+
model.superclass !== 'ApplicationRecord' &&
|
|
344
|
+
models[model.superclass]
|
|
345
|
+
) {
|
|
346
|
+
if (!stiSubclasses[model.superclass]) stiSubclasses[model.superclass] = []
|
|
347
|
+
stiSubclasses[model.superclass].push(name)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const [baseName, subclasses] of Object.entries(stiSubclasses)) {
|
|
351
|
+
models[baseName].sti_base = true
|
|
352
|
+
models[baseName].sti_subclasses = subclasses
|
|
353
|
+
for (const sub of subclasses) {
|
|
354
|
+
models[sub].sti_parent = baseName
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Build a reverse mapping from file paths to their graph entities.
|
|
361
|
+
* @param {Object} extractions
|
|
362
|
+
* @param {Object} manifest
|
|
363
|
+
* @returns {Object<string, {entity: string, type: string}>}
|
|
364
|
+
*/
|
|
365
|
+
function buildFileEntityMap(extractions, manifest) {
|
|
366
|
+
const map = {}
|
|
367
|
+
|
|
368
|
+
mapEntities(map, extractions.models, 'model')
|
|
369
|
+
mapEntities(map, extractions.controllers, 'controller')
|
|
370
|
+
mapEntities(map, extractions.components, 'component')
|
|
371
|
+
mapEntities(map, extractions.helpers, 'helper')
|
|
372
|
+
mapEntities(map, extractions.workers, 'worker')
|
|
373
|
+
mapUploaderFiles(map, extractions.uploaders?.uploaders)
|
|
374
|
+
mapStimulusControllers(map, extractions.stimulus_controllers)
|
|
375
|
+
mapConcernFiles(map, extractions, manifest)
|
|
376
|
+
mapSpecialFiles(map, extractions)
|
|
377
|
+
mapViewFiles(map, extractions.controllers, manifest)
|
|
378
|
+
mapJobFiles(map, extractions.jobs)
|
|
379
|
+
mapMailerFiles(map, extractions.email)
|
|
380
|
+
mapChannelFiles(map, extractions.realtime)
|
|
381
|
+
mapPolicyFiles(map, manifest)
|
|
382
|
+
mapServiceFiles(map, manifest)
|
|
383
|
+
mapMigrationFiles(map, manifest)
|
|
384
|
+
|
|
385
|
+
return map
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Map extracted entities (models, controllers, components) to their file paths.
|
|
390
|
+
* @param {Object<string, Object>} map - Accumulator: file path → { entity, type }
|
|
391
|
+
* @param {Object<string, {file?: string}>} entities - Extraction results keyed by name
|
|
392
|
+
* @param {string} type - Entity type label (e.g. 'model', 'controller')
|
|
393
|
+
*/
|
|
394
|
+
function mapEntities(map, entities, type) {
|
|
395
|
+
if (!entities) return
|
|
396
|
+
for (const [name, entity] of Object.entries(entities)) {
|
|
397
|
+
if (entity.file) map[entity.file] = { entity: name, type }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Map uploader files to their class entities.
|
|
403
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
404
|
+
* @param {Object<string, {file?: string}>} uploaders - Uploader extraction results
|
|
405
|
+
*/
|
|
406
|
+
function mapUploaderFiles(map, uploaders) {
|
|
407
|
+
if (!uploaders) return
|
|
408
|
+
for (const [name, uploader] of Object.entries(uploaders)) {
|
|
409
|
+
if (uploader.file) map[uploader.file] = { entity: name, type: 'uploader' }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Map Stimulus controller files to their controller identifiers.
|
|
415
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
416
|
+
* @param {Array<{file?: string, name?: string}>} controllers - Stimulus extraction results
|
|
417
|
+
*/
|
|
418
|
+
function mapStimulusControllers(map, controllers) {
|
|
419
|
+
if (!Array.isArray(controllers)) return
|
|
420
|
+
for (const sc of controllers) {
|
|
421
|
+
if (sc.file && sc.name) {
|
|
422
|
+
map[sc.file] = { entity: sc.name, type: 'stimulus_controller' }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Map concern files from the manifest to their derived class names.
|
|
429
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
430
|
+
* @param {Object} extractions - All extraction results (unused but kept for signature consistency)
|
|
431
|
+
* @param {Object} manifest - Scanner manifest with classified entries
|
|
432
|
+
*/
|
|
433
|
+
function mapConcernFiles(map, extractions, manifest) {
|
|
434
|
+
const entries = manifest?.entries || []
|
|
435
|
+
for (const entry of entries) {
|
|
436
|
+
if (entry.path.includes('/concerns/') && entry.path.endsWith('.rb')) {
|
|
437
|
+
const className = pathToClassName(entry.path)
|
|
438
|
+
map[entry.path] = { entity: className, type: 'concern' }
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Map well-known singleton files (schema, routes, Gemfile) to fixed entity IDs. */
|
|
444
|
+
function mapSpecialFiles(map, extractions) {
|
|
445
|
+
map['db/schema.rb'] = { entity: '__schema__', type: 'schema' }
|
|
446
|
+
map['config/routes.rb'] = { entity: '__routes__', type: 'routes' }
|
|
447
|
+
map['Gemfile'] = { entity: '__gemfile__', type: 'gemfile' }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Derive a Rails controller class name from a view directory path.
|
|
452
|
+
* 'admin/users' → 'Admin::UsersController', 'posts' → 'PostsController'
|
|
453
|
+
* @param {string} viewDir
|
|
454
|
+
* @returns {string}
|
|
455
|
+
*/
|
|
456
|
+
function deriveControllerClassName(viewDir) {
|
|
457
|
+
const parts = viewDir.split('/')
|
|
458
|
+
const classified = parts.map((segment) =>
|
|
459
|
+
segment
|
|
460
|
+
.split('_')
|
|
461
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
462
|
+
.join(''),
|
|
463
|
+
)
|
|
464
|
+
return classified.join('::') + 'Controller'
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Map view templates to their owning controller using Rails directory conventions.
|
|
469
|
+
* e.g. app/views/users/show.html.erb → UsersController
|
|
470
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
471
|
+
* @param {Object<string, Object>} controllers - Extracted controllers keyed by class name
|
|
472
|
+
* @param {Object} manifest - Scanner manifest with classified entries
|
|
473
|
+
*/
|
|
474
|
+
function mapViewFiles(map, controllers, manifest) {
|
|
475
|
+
const entries = manifest?.entries || []
|
|
476
|
+
for (const entry of entries) {
|
|
477
|
+
if (!entry.path.startsWith('app/views/')) continue
|
|
478
|
+
const relativePath = entry.path.replace('app/views/', '')
|
|
479
|
+
const segments = relativePath.split('/')
|
|
480
|
+
if (segments.length < 2) continue
|
|
481
|
+
|
|
482
|
+
const viewDir = segments.slice(0, -1).join('/')
|
|
483
|
+
const ctrlClassName = deriveControllerClassName(viewDir)
|
|
484
|
+
|
|
485
|
+
if (controllers && controllers[ctrlClassName]) {
|
|
486
|
+
map[entry.path] = { entity: ctrlClassName, type: 'view' }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Map job files to their class entities.
|
|
493
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
494
|
+
* @param {Object} jobs - Jobs extraction results
|
|
495
|
+
*/
|
|
496
|
+
function mapJobFiles(map, jobs) {
|
|
497
|
+
if (!jobs?.jobs) return
|
|
498
|
+
for (const job of jobs.jobs) {
|
|
499
|
+
if (job.file && job.class) {
|
|
500
|
+
map[job.file] = { entity: job.class, type: 'job' }
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Map mailer files to their class entities.
|
|
507
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
508
|
+
* @param {Object} email - Email extraction results
|
|
509
|
+
*/
|
|
510
|
+
function mapMailerFiles(map, email) {
|
|
511
|
+
if (!email?.mailers) return
|
|
512
|
+
for (const mailer of email.mailers) {
|
|
513
|
+
if (mailer.file && mailer.class) {
|
|
514
|
+
map[mailer.file] = { entity: mailer.class, type: 'mailer' }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Map channel files to their class entities.
|
|
521
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
522
|
+
* @param {Object} realtime - Realtime extraction results
|
|
523
|
+
*/
|
|
524
|
+
function mapChannelFiles(map, realtime) {
|
|
525
|
+
if (!realtime?.channels) return
|
|
526
|
+
for (const channel of realtime.channels) {
|
|
527
|
+
if (channel.file && channel.class) {
|
|
528
|
+
map[channel.file] = { entity: channel.class, type: 'channel' }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Map policy files from manifest entries.
|
|
535
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
536
|
+
* @param {Object} manifest - Scanner manifest
|
|
537
|
+
*/
|
|
538
|
+
function mapPolicyFiles(map, manifest) {
|
|
539
|
+
const entries = manifest?.entries || []
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
if (entry.path.startsWith('app/policies/') && entry.path.endsWith('.rb')) {
|
|
542
|
+
const className = pathToClassName(entry.path)
|
|
543
|
+
map[entry.path] = { entity: className, type: 'policy' }
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Map service object files from manifest entries.
|
|
550
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
551
|
+
* @param {Object} manifest - Scanner manifest
|
|
552
|
+
*/
|
|
553
|
+
function mapServiceFiles(map, manifest) {
|
|
554
|
+
const entries = manifest?.entries || []
|
|
555
|
+
for (const entry of entries) {
|
|
556
|
+
if (entry.path.startsWith('app/services/') && entry.path.endsWith('.rb')) {
|
|
557
|
+
const className = pathToClassName(entry.path)
|
|
558
|
+
map[entry.path] = { entity: className, type: 'service' }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Map migration files to the __schema__ entity.
|
|
565
|
+
* @param {Object<string, Object>} map - Accumulator
|
|
566
|
+
* @param {Object} manifest - Scanner manifest
|
|
567
|
+
*/
|
|
568
|
+
function mapMigrationFiles(map, manifest) {
|
|
569
|
+
const entries = manifest?.entries || []
|
|
570
|
+
for (const entry of entries) {
|
|
571
|
+
if (entry.path.startsWith('db/migrate/') && entry.path.endsWith('.rb')) {
|
|
572
|
+
map[entry.path] = { entity: '__schema__', type: 'migration' }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Compute summary statistics.
|
|
579
|
+
*/
|
|
580
|
+
function computeStatistics(manifest, extractions, relationships) {
|
|
581
|
+
const entries = manifest.entries || []
|
|
582
|
+
return {
|
|
583
|
+
total_files: entries.length,
|
|
584
|
+
models: Object.keys(extractions.models || {}).length,
|
|
585
|
+
controllers: Object.keys(extractions.controllers || {}).length,
|
|
586
|
+
components: Object.keys(extractions.components || {}).length,
|
|
587
|
+
relationships: relationships.length,
|
|
588
|
+
gems: Array.isArray(extractions.gemfile?.gems)
|
|
589
|
+
? extractions.gemfile.gems.length
|
|
590
|
+
: Object.keys(extractions.gemfile?.gems || {}).length,
|
|
591
|
+
helpers: Object.keys(extractions.helpers || {}).length,
|
|
592
|
+
workers: Object.keys(extractions.workers || {}).length,
|
|
593
|
+
uploaders: Object.keys(extractions.uploaders?.uploaders || {}).length,
|
|
594
|
+
}
|
|
595
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex patterns for API and GraphQL extraction.
|
|
3
|
+
*/
|
|
4
|
+
export const API_PATTERNS = {
|
|
5
|
+
apiOnly: /config\.api_only\s*=\s*true/,
|
|
6
|
+
serializerClass: /class\s+(\w+Serializer)\s*<\s*(\w+)/,
|
|
7
|
+
blueprintClass: /class\s+(\w+Blueprint)\s*<\s*(\w+)/,
|
|
8
|
+
serializerAttributes: /^\s*attributes?\s+(.+)/m,
|
|
9
|
+
pagyUsage: /pagy\s*\((.+)\)/g,
|
|
10
|
+
kaminariUsage: /\.page\s*\((.+)\)\.per\s*\((.+)\)/g,
|
|
11
|
+
rackAttackThrottle: /Rack::Attack\.throttle\s*\((.+)\)/g,
|
|
12
|
+
rackAttackBlocklist: /Rack::Attack\.blocklist\s*\((.+)\)/g,
|
|
13
|
+
corsConfig:
|
|
14
|
+
/Rails\.application\.config\.middleware\.insert_before.*Rack::Cors/,
|
|
15
|
+
corsOrigins: /allow\s+do\s*\n\s*origins\s+(.+)/g,
|
|
16
|
+
graphqlSchema: /class\s+(\w+Schema)\s*<\s*GraphQL::Schema/,
|
|
17
|
+
graphqlType: /class\s+Types::(\w+)\s*<\s*Types::BaseObject/g,
|
|
18
|
+
graphqlMutation: /class\s+Mutations::(\w+)\s*<\s*Mutations::BaseMutation/g,
|
|
19
|
+
renderJson: /render\s+json:/g,
|
|
20
|
+
respondTo: /respond_to\s+do/g,
|
|
21
|
+
jbuilder: /json\.extract!|json\.\w+/g,
|
|
22
|
+
apiNamespace: /namespace\s+:api/g,
|
|
23
|
+
apiVersion: /namespace\s+:v\d+/g,
|
|
24
|
+
skipCsrf: /skip_before_action\s+:verify_authenticity_token/g,
|
|
25
|
+
grapeApi: /Grape::API/,
|
|
26
|
+
graphqlField: /field\s+:\w+/g,
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex patterns for authentication extraction.
|
|
3
|
+
*/
|
|
4
|
+
export const AUTH_PATTERNS = {
|
|
5
|
+
// Devise
|
|
6
|
+
deviseConfig: /config\.(\w+)\s*=\s*(.+)/g,
|
|
7
|
+
deviseModules: /^\s*devise\s+(.+)/m,
|
|
8
|
+
deviseController:
|
|
9
|
+
/class\s+\w+::(\w+Controller)\s*<\s*Devise::(\w+Controller)/,
|
|
10
|
+
omniauthProvider: /provider\s+:(\w+)/g,
|
|
11
|
+
omniauthProviders: /omniauth_providers:\s*\[([^\]]+)\]/,
|
|
12
|
+
|
|
13
|
+
// Native Rails 8
|
|
14
|
+
currentAttributes: /class\s+Current\s*<\s*ActiveSupport::CurrentAttributes/,
|
|
15
|
+
currentAttribute: /attribute\s+:(\w+)/g,
|
|
16
|
+
requireAuth: /before_action\s+:require_authentication/,
|
|
17
|
+
authenticatedMethod: /def\s+authenticated\?/,
|
|
18
|
+
requireAuthMethod: /def\s+require_authentication/,
|
|
19
|
+
sessionsController: /class\s+SessionsController/,
|
|
20
|
+
|
|
21
|
+
// General
|
|
22
|
+
hasSecurePassword: /^\s*has_secure_password/m,
|
|
23
|
+
jwtDecode: /JWT\.decode/,
|
|
24
|
+
jwtEncode: /JWT\.encode/,
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex patterns for authorization extraction.
|
|
3
|
+
*/
|
|
4
|
+
export const AUTHORIZATION_PATTERNS = {
|
|
5
|
+
// Pundit
|
|
6
|
+
policyClass: /class\s+(\w+)Policy\s*<\s*(\w+)/,
|
|
7
|
+
policyMethod: /def\s+(index|show|create|new|update|edit|destroy)\?/g,
|
|
8
|
+
policyScopeClass: /class\s+Scope\s*<\s*(?:ApplicationPolicy::)?Scope/,
|
|
9
|
+
policyScopeResolve: /def\s+resolve/,
|
|
10
|
+
authorize: /authorize\s+(@?\w+)(?:,\s*:(\w+)\?)?/g,
|
|
11
|
+
policyScope: /policy_scope\s*\((.+)\)/g,
|
|
12
|
+
|
|
13
|
+
// CanCanCan
|
|
14
|
+
abilityClass: /class\s+Ability/,
|
|
15
|
+
includeCanCan: /include\s+CanCan::Ability/,
|
|
16
|
+
canDef: /^\s*can\s+(.+)/gm,
|
|
17
|
+
cannotDef: /^\s*cannot\s+(.+)/gm,
|
|
18
|
+
loadAndAuthorize: /load_and_authorize_resource/g,
|
|
19
|
+
authorizeAction: /authorize!\s+(.+)/g,
|
|
20
|
+
|
|
21
|
+
// Roles
|
|
22
|
+
enumRole: /enum\s+:?role/,
|
|
23
|
+
hasRole: /has_role\s/g,
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex patterns for Rails caching extraction.
|
|
3
|
+
*/
|
|
4
|
+
export const CACHING_PATTERNS = {
|
|
5
|
+
cacheStore: /config\.cache_store\s*=\s*:(\w+)(?:,\s*(.+))?/,
|
|
6
|
+
fragmentCache: /<%\s*cache\s+(.+?)\s*do\s*%>/g,
|
|
7
|
+
fragmentCacheRuby: /cache\s+(.+?)\s+do/g,
|
|
8
|
+
railsCacheFetch: /Rails\.cache\.fetch\s*\((.+?)\)/g,
|
|
9
|
+
railsCacheOps: /Rails\.cache\.(?:read|write|delete|exist\?)\s*\((.+?)\)/g,
|
|
10
|
+
touch: /touch:\s*true/,
|
|
11
|
+
stale: /stale\?\s*\((.+?)\)/g,
|
|
12
|
+
freshWhen: /fresh_when\s*\((.+?)\)/g,
|
|
13
|
+
expiresIn: /expires_in\s+(.+)/g,
|
|
14
|
+
httpCacheForever: /http_cache_forever/,
|
|
15
|
+
railsCache: /Rails\.cache\./g,
|
|
16
|
+
russianDoll: /<%\s*cache\s+\[(.+?)\]\s*do\s*%>/g,
|
|
17
|
+
cacheKey: /cache_key/g,
|
|
18
|
+
cachesAction: /caches_action\s+:(\w+)/g,
|
|
19
|
+
}
|