@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,506 @@
1
+ /**
2
+ * Model Extractor (#1)
3
+ * Extracts all ActiveRecord model patterns from Ruby model files.
4
+ */
5
+
6
+ import { MODEL_PATTERNS } from '../core/patterns.js'
7
+
8
+ /**
9
+ * Extract all model information from a single model file.
10
+ * @param {import('../providers/interface.js').FileProvider} provider
11
+ * @param {string} filePath
12
+ * @param {string} [className]
13
+ * @returns {object|null}
14
+ */
15
+ export function extractModel(provider, filePath, className) {
16
+ const content = provider.readFile(filePath)
17
+ if (!content) return null
18
+
19
+ const isConcern =
20
+ /module\s+\w+/.test(content) &&
21
+ /extend\s+ActiveSupport::Concern/.test(content)
22
+
23
+ // Class/module declaration
24
+ let detectedClass = className || null
25
+ let superclass = null
26
+ const classMatch = content.match(MODEL_PATTERNS.classDeclaration)
27
+ if (classMatch) {
28
+ detectedClass = classMatch[1]
29
+ superclass = classMatch[2]
30
+ } else if (isConcern) {
31
+ const moduleMatch = content.match(/module\s+(\w+(?:::\w+)*)/)
32
+ if (moduleMatch) detectedClass = moduleMatch[1]
33
+ }
34
+
35
+ // Concerns (include/extend)
36
+ const concerns = []
37
+ const extends_ = []
38
+ const includeRe = new RegExp(MODEL_PATTERNS.include.source, 'gm')
39
+ const extendRe = new RegExp(MODEL_PATTERNS.extend.source, 'gm')
40
+ let m
41
+ while ((m = includeRe.exec(content))) {
42
+ const mod = m[1]
43
+ if (
44
+ mod !== 'ActiveSupport::Concern' &&
45
+ mod !== 'Discard::Model' &&
46
+ mod !== 'AASM' &&
47
+ mod !== 'PgSearch::Model'
48
+ ) {
49
+ concerns.push(mod)
50
+ }
51
+ }
52
+ while ((m = extendRe.exec(content))) {
53
+ const mod = m[1]
54
+ if (mod !== 'ActiveSupport::Concern' && mod !== 'FriendlyId') {
55
+ extends_.push(mod)
56
+ }
57
+ }
58
+
59
+ // Associations
60
+ const associations = []
61
+ const assocTypes = [
62
+ { key: 'belongsTo', type: 'belongs_to' },
63
+ { key: 'hasMany', type: 'has_many' },
64
+ { key: 'hasOne', type: 'has_one' },
65
+ { key: 'habtm', type: 'has_and_belongs_to_many' },
66
+ ]
67
+ for (const { key, type } of assocTypes) {
68
+ const re = new RegExp(MODEL_PATTERNS[key].source, 'gm')
69
+ while ((m = re.exec(content))) {
70
+ const entry = { type, name: m[1], options: m[2] || null }
71
+ // Check for through
72
+ if (entry.options) {
73
+ const throughMatch = entry.options.match(MODEL_PATTERNS.through)
74
+ if (throughMatch) entry.through = throughMatch[1]
75
+ // Check for counter_cache
76
+ const ccMatch = entry.options.match(MODEL_PATTERNS.counterCache)
77
+ if (ccMatch) entry.counter_cache = true
78
+ // Check for polymorphic
79
+ if (MODEL_PATTERNS.polymorphic.test(entry.options))
80
+ entry.polymorphic = true
81
+ // Check for strict_loading
82
+ if (MODEL_PATTERNS.strictLoadingAssoc.test(entry.options))
83
+ entry.strict_loading = true
84
+ }
85
+ associations.push(entry)
86
+ }
87
+ }
88
+
89
+ // Validations
90
+ const validations = []
91
+ const custom_validators = []
92
+ const validatesRe = new RegExp(MODEL_PATTERNS.validates.source, 'gm')
93
+ while ((m = validatesRe.exec(content))) {
94
+ const attrs = m[1].split(/,\s*:?/).map((a) => a.trim().replace(/^:/, ''))
95
+ validations.push({ attributes: attrs, rules: m[2] || '' })
96
+ }
97
+ const validateRe = new RegExp(MODEL_PATTERNS.validate.source, 'gm')
98
+ while ((m = validateRe.exec(content))) {
99
+ custom_validators.push(m[1])
100
+ }
101
+
102
+ // Scopes — names array (backward-compat) + scope_queries dict with bodies
103
+ const scopes = []
104
+ const scope_queries = {}
105
+ // Extended pattern: capture the body inside { } after ->
106
+ const scopeBodyRe =
107
+ /^\s*scope\s+:(\w+),\s*->\s*(?:\([^)]*\)\s*)?\{\s*([^}]+)\}/gm
108
+ const scopeSimpleRe = new RegExp(MODEL_PATTERNS.scope.source, 'gm')
109
+ const scopeNamesFound = new Set()
110
+ while ((m = scopeBodyRe.exec(content))) {
111
+ scopes.push(m[1])
112
+ scope_queries[m[1]] = m[2].trim().replace(/\s+/g, ' ')
113
+ scopeNamesFound.add(m[1])
114
+ }
115
+ // Fall back to name-only for scopes we couldn't extract a body from
116
+ while ((m = scopeSimpleRe.exec(content))) {
117
+ if (!scopeNamesFound.has(m[1])) scopes.push(m[1])
118
+ }
119
+
120
+ // Enums — values is always array of key names; value_map has int mapping
121
+ const enums = {}
122
+ // Modern hash syntax: enum :status, { key: 0, ... } (Rails 7+)
123
+ const enumModernHashRe = /^\s*enum\s+:(\w+),\s*\{([^}]+)\}/gm
124
+ while ((m = enumModernHashRe.exec(content))) {
125
+ const name = m[1]
126
+ const valStr = m[2]
127
+ const value_map = {}
128
+ const keys = []
129
+ const pairRe = /(\w+):\s*(\d+)/g
130
+ let pm
131
+ while ((pm = pairRe.exec(valStr))) {
132
+ value_map[pm[1]] = parseInt(pm[2], 10)
133
+ keys.push(pm[1])
134
+ }
135
+ if (keys.length > 0) {
136
+ enums[name] = { values: keys, value_map, syntax: 'hash' }
137
+ } else {
138
+ const symKeys =
139
+ valStr.match(/\w+/g)?.filter((v) => !/^\d+$/.test(v)) || []
140
+ enums[name] = { values: symKeys, syntax: 'hash' }
141
+ }
142
+ // Check for validate: true after the closing brace
143
+ const afterEnum = content.slice(
144
+ m.index + m[0].length,
145
+ m.index + m[0].length + 50,
146
+ )
147
+ if (/validate:\s*true/.test(m[0] + afterEnum)) {
148
+ enums[name].validate = true
149
+ }
150
+ }
151
+ // Legacy hash syntax: enum status: { draft: 0, ... } (Rails 4-6)
152
+ const enumLegacyHashRe = /^\s*enum\s+(\w+):\s*\{([^}]+)\}/gm
153
+ while ((m = enumLegacyHashRe.exec(content))) {
154
+ const name = m[1]
155
+ if (enums[name]) continue
156
+ const valStr = m[2]
157
+ const value_map = {}
158
+ const keys = []
159
+ const pairRe = /(\w+):\s*(\d+)/g
160
+ let pm
161
+ while ((pm = pairRe.exec(valStr))) {
162
+ value_map[pm[1]] = parseInt(pm[2], 10)
163
+ keys.push(pm[1])
164
+ }
165
+ if (keys.length > 0) {
166
+ enums[name] = { values: keys, value_map, syntax: 'legacy' }
167
+ } else {
168
+ const symKeys =
169
+ valStr.match(/\w+/g)?.filter((v) => !/^\d+$/.test(v)) || []
170
+ enums[name] = { values: symKeys, syntax: 'legacy' }
171
+ }
172
+ // Check for validate: true after the closing brace
173
+ const afterEnum = content.slice(
174
+ m.index + m[0].length,
175
+ m.index + m[0].length + 50,
176
+ )
177
+ if (/validate:\s*true/.test(m[0] + afterEnum)) {
178
+ enums[name].validate = true
179
+ }
180
+ }
181
+ // Array syntax: enum :role, [ :a, :b ] — only add if not already captured
182
+ const enumArrayPatterns = [
183
+ { re: MODEL_PATTERNS.enumPositionalArray, syntax: 'positional_array' },
184
+ { re: MODEL_PATTERNS.enumLegacyArray, syntax: 'legacy_array' },
185
+ ]
186
+ for (const { re, syntax } of enumArrayPatterns) {
187
+ const gre = new RegExp(re.source, 'gm')
188
+ while ((m = gre.exec(content))) {
189
+ const name = m[1]
190
+ if (enums[name]) continue // already captured from hash syntax
191
+ const values = (m[2].match(/\w+/g) || []).filter((v) => !/^\d+$/.test(v))
192
+ enums[name] = { values, syntax }
193
+ }
194
+ }
195
+
196
+ // Callbacks
197
+ const callbacks = []
198
+ const cbRe = new RegExp(MODEL_PATTERNS.callbackType.source, 'gm')
199
+ while ((m = cbRe.exec(content))) {
200
+ callbacks.push({ type: m[1], method: m[2], options: m[3] || null })
201
+ }
202
+
203
+ // Delegations
204
+ const delegations = []
205
+ const delRe = new RegExp(MODEL_PATTERNS.delegate.source, 'gm')
206
+ while ((m = delRe.exec(content))) {
207
+ delegations.push({ methods: m[1].trim(), to: m[2] })
208
+ }
209
+
210
+ // Encrypts
211
+ const encrypts = []
212
+ const encRe = new RegExp(MODEL_PATTERNS.encrypts.source, 'gm')
213
+ while ((m = encRe.exec(content))) {
214
+ const attrs = m[1].match(/:(\w+)/g)
215
+ if (attrs) encrypts.push(...attrs.map((a) => a.slice(1)))
216
+ }
217
+
218
+ // Normalizes
219
+ const normalizes = []
220
+ const normRe = new RegExp(MODEL_PATTERNS.normalizes.source, 'gm')
221
+ while ((m = normRe.exec(content))) {
222
+ const fullDecl = m[1]
223
+ const attrs = fullDecl.match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
224
+ const withMatch = fullDecl.match(
225
+ /with:\s*->\s*(?:\([^)]*\)\s*)?\{([^}]+)\}/,
226
+ )
227
+ const normExpression = withMatch ? withMatch[1].trim() : null
228
+ for (const attr of attrs) {
229
+ normalizes.push({ attribute: attr, expression: normExpression })
230
+ }
231
+ }
232
+
233
+ // Token generators
234
+ const token_generators = []
235
+ const tokenRe = new RegExp(MODEL_PATTERNS.generatesTokenFor.source, 'gm')
236
+ while ((m = tokenRe.exec(content))) {
237
+ token_generators.push(m[1])
238
+ }
239
+
240
+ // Secure password
241
+ const has_secure_password = MODEL_PATTERNS.hasSecurePassword.test(content)
242
+
243
+ // Attachments
244
+ const attachments = []
245
+ const attachPatterns = [
246
+ { re: MODEL_PATTERNS.hasOneAttached, type: 'has_one_attached' },
247
+ { re: MODEL_PATTERNS.hasManyAttached, type: 'has_many_attached' },
248
+ ]
249
+ for (const { re, type } of attachPatterns) {
250
+ const gre = new RegExp(re.source, 'gm')
251
+ while ((m = gre.exec(content))) {
252
+ attachments.push({ type, name: m[1] })
253
+ }
254
+ }
255
+
256
+ // Rich text
257
+ const rich_text = []
258
+ const rtRe = new RegExp(MODEL_PATTERNS.hasRichText.source, 'gm')
259
+ while ((m = rtRe.exec(content))) {
260
+ rich_text.push(m[1])
261
+ }
262
+
263
+ // Store accessors
264
+ const store_accessors = {}
265
+ const storeRe = new RegExp(MODEL_PATTERNS.store.source, 'gm')
266
+ while ((m = storeRe.exec(content))) {
267
+ store_accessors[m[1]] = m[2].match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
268
+ }
269
+ const saRe = new RegExp(MODEL_PATTERNS.storeAccessor.source, 'gm')
270
+ while ((m = saRe.exec(content))) {
271
+ const storeName = m[1]
272
+ const accessors = m[2].match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
273
+ store_accessors[storeName] = [
274
+ ...(store_accessors[storeName] || []),
275
+ ...accessors,
276
+ ]
277
+ }
278
+
279
+ // Table name override
280
+ const tableMatch = content.match(MODEL_PATTERNS.tableName)
281
+ const table_name = tableMatch ? tableMatch[1] : null
282
+
283
+ // Abstract class
284
+ const abstract = MODEL_PATTERNS.abstractClass.test(content)
285
+
286
+ // Default scope
287
+ const default_scope = MODEL_PATTERNS.defaultScope.test(content)
288
+
289
+ // Broadcasts
290
+ const broadcasts =
291
+ MODEL_PATTERNS.broadcastsTo.test(content) ||
292
+ MODEL_PATTERNS.broadcasts.test(content)
293
+
294
+ // Strict loading
295
+ const strict_loading = MODEL_PATTERNS.strictLoading.test(content)
296
+
297
+ // Turbo 8 morphing
298
+ const turboRefreshesMatch = content.match(MODEL_PATTERNS.turboRefreshes)
299
+ const turbo_refreshes_with = turboRefreshesMatch
300
+ ? turboRefreshesMatch[1]
301
+ : null
302
+
303
+ // Devise modules
304
+ let devise_modules = []
305
+ const deviseMatch = content.match(MODEL_PATTERNS.devise)
306
+ if (deviseMatch) {
307
+ // Devise declaration can span multiple lines
308
+ let deviseStr = deviseMatch[1]
309
+ // Continue capturing if line ends with comma
310
+ const deviseStartIdx = content.indexOf(deviseMatch[0])
311
+ const afterMatch = content.slice(deviseStartIdx + deviseMatch[0].length)
312
+ const continuationLines = afterMatch.split('\n')
313
+ for (const line of continuationLines) {
314
+ const trimmed = line.trim()
315
+ if (trimmed.length === 0) continue
316
+ if (/^:/.test(trimmed) || /^,/.test(trimmed) || /^\w+.*:/.test(trimmed)) {
317
+ deviseStr += ' ' + trimmed
318
+ } else {
319
+ break
320
+ }
321
+ }
322
+ devise_modules = (deviseStr.match(/:(\w+)/g) || []).map((s) => s.slice(1))
323
+ }
324
+
325
+ // Searchable
326
+ let searchable = null
327
+ if (MODEL_PATTERNS.searchkick.test(content)) {
328
+ searchable = { gem: 'searchkick', scopes: [] }
329
+ } else if (MODEL_PATTERNS.pgSearchModel.test(content)) {
330
+ const pgScopes = []
331
+ const pgRe = new RegExp(MODEL_PATTERNS.pgSearchScope.source, 'gm')
332
+ while ((m = pgRe.exec(content))) {
333
+ pgScopes.push(m[1])
334
+ }
335
+ searchable = { gem: 'pg_search', scopes: pgScopes }
336
+ }
337
+
338
+ // Friendly ID
339
+ let friendly_id = null
340
+ if (MODEL_PATTERNS.extendFriendlyId.test(content)) {
341
+ const fidMatch = content.match(MODEL_PATTERNS.friendlyId)
342
+ friendly_id = { attribute: fidMatch ? fidMatch[1] : null }
343
+ }
344
+
345
+ // Soft delete
346
+ let soft_delete = null
347
+ if (MODEL_PATTERNS.discardModel.test(content)) {
348
+ soft_delete = { strategy: 'discard' }
349
+ } else if (MODEL_PATTERNS.paranoid.test(content)) {
350
+ soft_delete = { strategy: 'paranoid' }
351
+ }
352
+
353
+ // State machine
354
+ let state_machine = null
355
+ if (
356
+ MODEL_PATTERNS.includeAASM.test(content) ||
357
+ MODEL_PATTERNS.aasm.test(content)
358
+ ) {
359
+ state_machine = { gem: 'aasm', detected: true }
360
+ } else if (MODEL_PATTERNS.stateMachine.test(content)) {
361
+ state_machine = { gem: 'state_machines', detected: true }
362
+ }
363
+
364
+ // Paper trail
365
+ const paper_trail = MODEL_PATTERNS.hasPaperTrail.test(content)
366
+
367
+ // Audited
368
+ const audited = MODEL_PATTERNS.audited.test(content)
369
+
370
+ // STI base detection (has subclasses inheriting from this, detected elsewhere)
371
+ const sti_base = false
372
+
373
+ // Public instance method names (before first private/protected marker) with line ranges
374
+ const public_methods = []
375
+ const method_line_ranges = {}
376
+ {
377
+ const methodLines = content.split('\n')
378
+ let inPrivate = false
379
+ let currentMethodName = null
380
+ let currentMethodStart = null
381
+ let methodDepth = 0
382
+ for (let i = 0; i < methodLines.length; i++) {
383
+ const line = methodLines[i]
384
+ const lineNumber = i + 1
385
+
386
+ if (/^\s*(private|protected)\s*$/.test(line)) {
387
+ if (currentMethodName && !inPrivate) {
388
+ method_line_ranges[currentMethodName] = {
389
+ start: currentMethodStart,
390
+ end: lineNumber - 1,
391
+ }
392
+ }
393
+ inPrivate = true
394
+ currentMethodName = null
395
+ methodDepth = 0
396
+ continue
397
+ }
398
+
399
+ const mm = line.match(/^\s*def\s+(\w+[?!=]?)/)
400
+ if (mm) {
401
+ // Close previous method
402
+ if (currentMethodName && !inPrivate) {
403
+ method_line_ranges[currentMethodName] = {
404
+ start: currentMethodStart,
405
+ end: lineNumber - 1,
406
+ }
407
+ }
408
+
409
+ if (!inPrivate && mm[1] !== 'initialize') {
410
+ public_methods.push(mm[1])
411
+ currentMethodName = mm[1]
412
+ currentMethodStart = lineNumber
413
+ methodDepth = 1
414
+ } else {
415
+ currentMethodName = null
416
+ }
417
+ continue
418
+ }
419
+
420
+ if (currentMethodName && !inPrivate) {
421
+ if (
422
+ /\bdo\b|\bif\b(?!.*\bthen\b.*\bend\b)|\bcase\b|\bbegin\b/.test(
423
+ line,
424
+ ) &&
425
+ !/\bend\b/.test(line)
426
+ ) {
427
+ methodDepth++
428
+ }
429
+ if (/^\s*end\b/.test(line)) {
430
+ methodDepth--
431
+ if (methodDepth <= 0) {
432
+ method_line_ranges[currentMethodName] = {
433
+ start: currentMethodStart,
434
+ end: lineNumber,
435
+ }
436
+ currentMethodName = null
437
+ methodDepth = 0
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ // Close final method
444
+ if (currentMethodName && !inPrivate) {
445
+ method_line_ranges[currentMethodName] = {
446
+ start: currentMethodStart,
447
+ end: methodLines.length,
448
+ }
449
+ }
450
+ }
451
+
452
+ return {
453
+ class: detectedClass,
454
+ file: filePath,
455
+ type: isConcern ? 'concern' : 'model',
456
+ superclass,
457
+ abstract,
458
+ sti_base,
459
+ concerns,
460
+ extends: extends_,
461
+ associations,
462
+ validations,
463
+ custom_validators,
464
+ scopes,
465
+ scope_queries,
466
+ enums,
467
+ callbacks,
468
+ delegations,
469
+ encrypts,
470
+ normalizes,
471
+ token_generators,
472
+ has_secure_password,
473
+ attachments,
474
+ rich_text,
475
+ store_accessors,
476
+ table_name,
477
+ default_scope,
478
+ broadcasts,
479
+ strict_loading,
480
+ turbo_refreshes_with,
481
+ devise_modules,
482
+ searchable,
483
+ friendly_id,
484
+ soft_delete,
485
+ state_machine,
486
+ paper_trail,
487
+ audited,
488
+ public_methods,
489
+ method_line_ranges,
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Extract all models from a manifest.
495
+ * @param {import('../providers/interface.js').FileProvider} provider
496
+ * @param {Array<{path: string}>} modelEntries
497
+ * @returns {Array<object>}
498
+ */
499
+ export function extractModels(provider, modelEntries) {
500
+ const results = []
501
+ for (const entry of modelEntries) {
502
+ const model = extractModel(provider, entry.path)
503
+ if (model) results.push(model)
504
+ }
505
+ return results
506
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Realtime Extractor (#14)
3
+ * Extracts Action Cable channels, Turbo Streams, and WebSocket config.
4
+ */
5
+
6
+ import { REALTIME_PATTERNS } from '../core/patterns.js'
7
+
8
+ /**
9
+ * Extract realtime information.
10
+ * @param {import('../providers/interface.js').FileProvider} provider
11
+ * @param {Array<{path: string, category: string}>} entries
12
+ * @param {{gems?: object}} gemInfo
13
+ * @returns {object}
14
+ */
15
+ export function extractRealtime(provider, entries, gemInfo = {}) {
16
+ const gems = gemInfo.gems || {}
17
+ const result = {
18
+ adapter: {},
19
+ channels: [],
20
+ turbo_stream_from_usage: 0,
21
+ connection_auth: null,
22
+ anycable: !!gems.anycable || !!gems['anycable-rails'],
23
+ }
24
+
25
+ // Cable config
26
+ const cableYml = provider.readFile('config/cable.yml')
27
+ if (cableYml) {
28
+ const sections = cableYml.split(/\n(?=\w)/)
29
+ for (const section of sections) {
30
+ const envMatch = section.match(/^(\w+):/)
31
+ if (envMatch) {
32
+ const adapterMatch = section.match(REALTIME_PATTERNS.cableAdapter)
33
+ if (adapterMatch) {
34
+ result.adapter[envMatch[1]] = adapterMatch[1]
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ // Connection auth
41
+ const connContent = provider.readFile(
42
+ 'app/channels/application_cable/connection.rb',
43
+ )
44
+ if (connContent) {
45
+ if (REALTIME_PATTERNS.findVerifiedUser.test(connContent)) {
46
+ result.connection_auth = 'find_verified_user'
47
+ } else if (REALTIME_PATTERNS.rejectUnauthorized.test(connContent)) {
48
+ result.connection_auth = 'reject_unauthorized_connection'
49
+ }
50
+ }
51
+
52
+ // Channels
53
+ const channelEntries = entries.filter(
54
+ (e) => e.path.startsWith('app/channels/') && e.path.endsWith('_channel.rb'),
55
+ )
56
+ for (const entry of channelEntries) {
57
+ const content = provider.readFile(entry.path)
58
+ if (!content) continue
59
+
60
+ const classMatch = content.match(REALTIME_PATTERNS.channelClass)
61
+ if (!classMatch || classMatch[1] === 'ApplicationCable') continue
62
+
63
+ const channel = {
64
+ class: classMatch[1],
65
+ file: entry.path,
66
+ streams_from: [],
67
+ streams_for: [],
68
+ authenticated: false,
69
+ }
70
+
71
+ const fromRe = new RegExp(REALTIME_PATTERNS.streamFrom.source, 'g')
72
+ let m
73
+ while ((m = fromRe.exec(content))) {
74
+ channel.streams_from.push(m[1])
75
+ }
76
+
77
+ const forRe = new RegExp(REALTIME_PATTERNS.streamFor.source, 'g')
78
+ while ((m = forRe.exec(content))) {
79
+ channel.streams_for.push(m[1].trim())
80
+ }
81
+
82
+ // Simple auth detection
83
+ if (content.includes('current_user') || content.includes('find_verified')) {
84
+ channel.authenticated = true
85
+ }
86
+
87
+ result.channels.push(channel)
88
+ }
89
+
90
+ // Turbo stream from usage in views
91
+ const viewEntries = entries.filter((e) => e.path.startsWith('app/views/'))
92
+ for (const entry of viewEntries) {
93
+ const content = provider.readFile(entry.path)
94
+ if (!content) continue
95
+ const tsRe = new RegExp(REALTIME_PATTERNS.turboStreamFrom.source, 'g')
96
+ while (tsRe.exec(content)) {
97
+ result.turbo_stream_from_usage++
98
+ }
99
+ }
100
+
101
+ return result
102
+ }