@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,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
+ }