@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,394 @@
1
+ /**
2
+ * Layer 3: Structural Scanner
3
+ *
4
+ * Classifies all project files by path into the 56-category taxonomy.
5
+ * Zero file content reads — pure path-based classification using
6
+ * provider.glob() and provider.listDir().
7
+ *
8
+ * @module scanner
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} ManifestEntry
13
+ * @property {string} path - Relative file path
14
+ * @property {number} category - Category number (1-56)
15
+ * @property {string} categoryName - Human-readable category name
16
+ * @property {string} type - File type (ruby, js, erb, yml, etc.)
17
+ * @property {string|null} [specCategory] - Sub-category for test files (category 19 only)
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} Manifest
22
+ * @property {ManifestEntry[]} entries - All classified files
23
+ * @property {Object<string, ManifestEntry[]>} byCategory - Entries grouped by category name
24
+ * @property {Object} stats - File counts per category
25
+ * @property {string[]} unclassified - Files that didn't match any rule
26
+ */
27
+
28
+ /** Category name constants */
29
+ const CATEGORIES = {
30
+ 1: 'models',
31
+ 2: 'controllers',
32
+ 3: 'routes',
33
+ 4: 'schema',
34
+ 5: 'components',
35
+ 6: 'stimulus',
36
+ 7: 'views',
37
+ 8: 'authentication',
38
+ 9: 'authorization',
39
+ 10: 'jobs',
40
+ 11: 'email',
41
+ 12: 'storage',
42
+ 13: 'caching',
43
+ 14: 'realtime',
44
+ 15: 'api',
45
+ 16: 'gemfile',
46
+ 17: 'config',
47
+ 18: 'security',
48
+ 19: 'testing',
49
+ 20: 'code_quality',
50
+ 21: 'deployment',
51
+ 22: 'search',
52
+ 23: 'payments',
53
+ 24: 'multi_tenancy',
54
+ 25: 'admin',
55
+ 26: 'design_patterns',
56
+ 27: 'state_machines',
57
+ 28: 'i18n',
58
+ 29: 'pdf',
59
+ 30: 'csv',
60
+ 31: 'webhooks',
61
+ 32: 'scheduled_tasks',
62
+ 33: 'middleware',
63
+ 34: 'engines',
64
+ 35: 'credentials',
65
+ 36: 'http_client',
66
+ 37: 'performance',
67
+ 38: 'database_tooling',
68
+ 39: 'rich_text',
69
+ 40: 'notifications',
70
+ 41: 'feature_flags',
71
+ 42: 'audit',
72
+ 43: 'soft_delete',
73
+ 44: 'pagination',
74
+ 45: 'friendly_urls',
75
+ 46: 'tagging',
76
+ 47: 'seo',
77
+ 48: 'geolocation',
78
+ 49: 'sms_push',
79
+ 50: 'activity_tracking',
80
+ 51: 'data_import_export',
81
+ 52: 'event_sourcing',
82
+ 53: 'dry_rb',
83
+ 54: 'markdown',
84
+ 55: 'rate_limiting',
85
+ 56: 'graphql',
86
+ }
87
+
88
+ /**
89
+ * Classification rules. Each rule maps a path pattern to a category.
90
+ * Order matters — first match wins for ambiguous paths.
91
+ *
92
+ * The rules are ordered by specificity:
93
+ * 1. Singleton files (routes.rb, schema.rb, Gemfile) — exact path matches
94
+ * 2. Auth-specific controllers (sessions, registrations, passwords) — before
95
+ * general controllers so they land in category 8 instead of category 2
96
+ * 3. Specialized app/ subdirectories (policies, serializers, services) —
97
+ * before general models/controllers to capture design-pattern files
98
+ * 4. Core Tier 1 categories (models, controllers, views, jobs, etc.)
99
+ * 5. Config and infrastructure files
100
+ * 6. Catch-all patterns (lib/, migrations)
101
+ *
102
+ * @type {Array<{test: function(string): boolean, category: number}>}
103
+ */
104
+ const RULES = [
105
+ // --- Singleton files: exact path matches first ---
106
+ { test: (p) => /^config\/routes(\.rb|\/.*\.rb)$/.test(p), category: 3 },
107
+ { test: (p) => /^db\/(schema\.rb|structure\.sql)$/.test(p), category: 4 },
108
+ { test: (p) => p === 'Gemfile' || p === 'Gemfile.lock', category: 16 },
109
+
110
+ // --- Auth-specific files: must precede general controllers/models ---
111
+ // Devise initializer is config but functionally auth
112
+ { test: (p) => /^config\/initializers\/devise\.rb$/.test(p), category: 8 },
113
+ // Rails 8 native auth uses Session/Current models
114
+ { test: (p) => /^app\/models\/(session|current)\.rb$/.test(p), category: 8 },
115
+ // Auth controllers: sessions, registrations, passwords, confirmations
116
+ {
117
+ test: (p) => /^app\/controllers\/.*sessions_controller\.rb$/.test(p),
118
+ category: 8,
119
+ },
120
+ {
121
+ test: (p) => /^app\/controllers\/.*registrations_controller\.rb$/.test(p),
122
+ category: 8,
123
+ },
124
+ {
125
+ test: (p) => /^app\/controllers\/.*passwords_controller\.rb$/.test(p),
126
+ category: 8,
127
+ },
128
+ {
129
+ test: (p) => /^app\/controllers\/.*confirmations_controller\.rb$/.test(p),
130
+ category: 8,
131
+ },
132
+
133
+ // --- Authorization: policies and ability files ---
134
+ { test: (p) => /^app\/policies\/.*\.rb$/.test(p), category: 9 },
135
+ { test: (p) => /^app\/models\/ability\.rb$/.test(p), category: 9 },
136
+
137
+ // --- API serializers/blueprints: before general models ---
138
+ { test: (p) => /^app\/serializers\/.*\.rb$/.test(p), category: 15 },
139
+ { test: (p) => /^app\/blueprints\/.*\.rb$/.test(p), category: 15 },
140
+
141
+ // --- GraphQL: own directory under app/ ---
142
+ { test: (p) => /^app\/graphql\/.*\.rb$/.test(p), category: 56 },
143
+
144
+ // --- Design pattern directories: before general models ---
145
+ { test: (p) => /^app\/services\/.*\.rb$/.test(p), category: 26 },
146
+ { test: (p) => /^app\/forms\/.*\.rb$/.test(p), category: 26 },
147
+ { test: (p) => /^app\/queries\/.*\.rb$/.test(p), category: 26 },
148
+ { test: (p) => /^app\/decorators\/.*\.rb$/.test(p), category: 26 },
149
+ { test: (p) => /^app\/presenters\/.*\.rb$/.test(p), category: 26 },
150
+ { test: (p) => /^app\/interactors\/.*\.rb$/.test(p), category: 26 },
151
+
152
+ // --- Admin namespace ---
153
+ { test: (p) => /^app\/admin\/.*\.rb$/.test(p), category: 25 },
154
+
155
+ // --- Workers: Sidekiq native workers (before general models) ---
156
+ { test: (p) => /^app\/workers\/.*\.rb$/.test(p), category: 10 },
157
+ { test: (p) => /^app\/sidekiq\/.*\.rb$/.test(p), category: 10 },
158
+
159
+ // --- Uploaders: CarrierWave / Shrine (before general models) ---
160
+ { test: (p) => /^app\/uploaders\/.*\.rb$/.test(p), category: 12 },
161
+
162
+ // --- Validators: Custom validators (before general models) ---
163
+ { test: (p) => /^app\/validators\/.*\.rb$/.test(p), category: 26 },
164
+
165
+ // --- Notifiers: Action Notifier (future, before general models) ---
166
+ { test: (p) => /^app\/notifiers\/.*\.rb$/.test(p), category: 40 },
167
+
168
+ // --- Core Tier 1: broad app/ directory matches ---
169
+ { test: (p) => /^app\/models\/.*\.rb$/.test(p), category: 1 },
170
+ { test: (p) => /^app\/controllers\/.*\.rb$/.test(p), category: 2 },
171
+ { test: (p) => /^app\/components\/.*\.(rb|html\.\w+)$/.test(p), category: 5 },
172
+ {
173
+ test: (p) => /^app\/javascript\/controllers\/.*\.js$/.test(p),
174
+ category: 6,
175
+ },
176
+ { test: (p) => /^app\/views\/.*/.test(p), category: 7 },
177
+ // --- Helpers: View helpers ---
178
+ { test: (p) => /^app\/helpers\/.*\.rb$/.test(p), category: 7 },
179
+ { test: (p) => /^app\/jobs\/.*\.rb$/.test(p), category: 10 },
180
+ { test: (p) => /^app\/mailers\/.*\.rb$/.test(p), category: 11 },
181
+ { test: (p) => /^app\/channels\/.*\.rb$/.test(p), category: 14 },
182
+ { test: (p) => /^app\/mailboxes\/.*\.rb$/.test(p), category: 11 },
183
+
184
+ // --- Infrastructure & Config ---
185
+ { test: (p) => /^config\/storage\.yml$/.test(p), category: 12 },
186
+ { test: (p) => /^config\/application\.rb$/.test(p), category: 17 },
187
+ { test: (p) => /^config\/environments\/.*\.rb$/.test(p), category: 17 },
188
+ { test: (p) => /^config\/database\.yml$/.test(p), category: 17 },
189
+ { test: (p) => /^config\/cable\.yml$/.test(p), category: 14 },
190
+ { test: (p) => /^config\/initializers\/.*\.rb$/.test(p), category: 17 },
191
+
192
+ // Security-specific initializers (after general initializers to override)
193
+ {
194
+ test: (p) => /^config\/initializers\/content_security_policy\.rb$/.test(p),
195
+ category: 18,
196
+ },
197
+ { test: (p) => /^config\/initializers\/cors\.rb$/.test(p), category: 18 },
198
+
199
+ // --- Testing ---
200
+ { test: (p) => /^spec\/.*\.rb$/.test(p), category: 19 },
201
+ { test: (p) => /^test\/.*\.rb$/.test(p), category: 19 },
202
+ { test: (p) => /^\.rspec$/.test(p), category: 19 },
203
+
204
+ // --- Code quality & tooling ---
205
+ { test: (p) => /^\.rubocop(\.yml|_todo\.yml)$/.test(p), category: 20 },
206
+ { test: (p) => /^\.eslintrc/.test(p), category: 20 },
207
+
208
+ // --- Deployment ---
209
+ {
210
+ test: (p) => /^(Dockerfile|docker-compose\.yml|\.dockerignore)$/.test(p),
211
+ category: 21,
212
+ },
213
+ { test: (p) => /^config\/deploy\.yml$/.test(p), category: 21 },
214
+ { test: (p) => /^\.kamal\/.*/.test(p), category: 21 },
215
+ { test: (p) => /^config\/deploy\/.*\.rb$/.test(p), category: 21 },
216
+ { test: (p) => /^Procfile/.test(p), category: 21 },
217
+ { test: (p) => /^config\/puma\.rb$/.test(p), category: 21 },
218
+
219
+ // --- i18n, credentials, middleware, engines, notifications ---
220
+ { test: (p) => /^config\/locales\/.*\.yml$/.test(p), category: 28 },
221
+ { test: (p) => /^config\/credentials/.test(p), category: 35 },
222
+ { test: (p) => /^config\/master\.key$/.test(p), category: 35 },
223
+ { test: (p) => /^\.env/.test(p), category: 35 },
224
+ { test: (p) => /^app\/middleware\/.*\.rb$/.test(p), category: 33 },
225
+ { test: (p) => /^engines\/.*/.test(p), category: 34 },
226
+ { test: (p) => /^lib\/engines\/.*/.test(p), category: 34 },
227
+ { test: (p) => /^app\/notifications\/.*\.rb$/.test(p), category: 40 },
228
+
229
+ // --- Catch-all: lib and migrations ---
230
+ { test: (p) => /^lib\/.*\.rb$/.test(p), category: 17 },
231
+ { test: (p) => /^db\/migrate\/.*\.rb$/.test(p), category: 4 },
232
+ { test: (p) => /^db\/seeds\.rb$/.test(p), category: 17 },
233
+ ]
234
+
235
+ /**
236
+ * Detect file type from extension.
237
+ * @param {string} path
238
+ * @returns {string}
239
+ */
240
+ function detectType(path) {
241
+ if (path.endsWith('.rb')) return 'ruby'
242
+ if (path.endsWith('.js')) return 'javascript'
243
+ if (path.endsWith('.ts')) return 'typescript'
244
+ if (path.endsWith('.html.erb')) return 'erb'
245
+ if (path.endsWith('.html.haml')) return 'haml'
246
+ if (path.endsWith('.html.slim')) return 'slim'
247
+ if (path.endsWith('.jbuilder')) return 'jbuilder'
248
+ if (path.endsWith('.yml') || path.endsWith('.yaml')) return 'yaml'
249
+ if (path.endsWith('.json.erb')) return 'json_erb'
250
+ if (path.endsWith('.json')) return 'json'
251
+ if (path.endsWith('.sql')) return 'sql'
252
+ if (path.endsWith('.css')) return 'css'
253
+ if (path.endsWith('.scss')) return 'scss'
254
+ return 'other'
255
+ }
256
+
257
+ /**
258
+ * Classify a single file path into a category.
259
+ * @param {string} path - Relative file path
260
+ * @returns {ManifestEntry|null}
261
+ */
262
+ function classifyFile(path) {
263
+ for (const rule of RULES) {
264
+ if (rule.test(path)) {
265
+ const entry = {
266
+ path,
267
+ category: rule.category,
268
+ categoryName: CATEGORIES[rule.category],
269
+ type: detectType(path),
270
+ }
271
+ if (entry.category === 19) {
272
+ entry.specCategory = classifySpecFile(path)
273
+ }
274
+ if (entry.category === 7 && path.startsWith('app/views/pwa/')) {
275
+ entry.pwaFile = true
276
+ }
277
+ if (
278
+ entry.category === 10 &&
279
+ (path.startsWith('app/workers/') || path.startsWith('app/sidekiq/'))
280
+ ) {
281
+ entry.workerType = 'sidekiq_native'
282
+ }
283
+ return entry
284
+ }
285
+ }
286
+ return null
287
+ }
288
+
289
+ /**
290
+ * Sub-classify a spec/test file by its directory.
291
+ * @param {string} path
292
+ * @returns {string|null}
293
+ */
294
+ function classifySpecFile(path) {
295
+ if (path.startsWith('spec/models/')) return 'model_specs'
296
+ if (path.startsWith('spec/requests/')) return 'request_specs'
297
+ if (path.startsWith('spec/controllers/')) return 'controller_specs'
298
+ if (path.startsWith('spec/services/')) return 'service_specs'
299
+ if (path.startsWith('spec/jobs/')) return 'job_specs'
300
+ if (path.startsWith('spec/mailers/')) return 'mailer_specs'
301
+ if (path.startsWith('spec/policies/')) return 'policy_specs'
302
+ if (path.startsWith('spec/components/')) return 'component_specs'
303
+ if (path.startsWith('spec/forms/')) return 'form_specs'
304
+ if (path.startsWith('spec/factories/')) return 'factories'
305
+ if (path.startsWith('spec/support/')) return 'support'
306
+ if (path.startsWith('spec/shared_examples/')) return 'shared_examples'
307
+ if (path.startsWith('spec/shared_contexts/')) return 'shared_contexts'
308
+ if (path.startsWith('test/models/')) return 'model_tests'
309
+ if (path.startsWith('test/controllers/')) return 'controller_tests'
310
+ if (path.startsWith('test/integration/')) return 'integration_tests'
311
+ if (path.startsWith('test/factories/')) return 'factories'
312
+ return null
313
+ }
314
+
315
+ /**
316
+ * Scan the project structure and classify all files.
317
+ * Zero file content reads — uses only glob and listDir.
318
+ *
319
+ * @param {import('../providers/interface.js').FileProvider} provider
320
+ * @returns {Manifest}
321
+ */
322
+ export function scanStructure(provider) {
323
+ const entries = []
324
+ const unclassified = []
325
+ const byCategory = {}
326
+ const stats = {}
327
+
328
+ // Initialize byCategory
329
+ for (const [num, name] of Object.entries(CATEGORIES)) {
330
+ byCategory[name] = []
331
+ stats[name] = 0
332
+ }
333
+
334
+ // Get all relevant files
335
+ const allFiles = [
336
+ ...provider.glob('app/**/*.rb'),
337
+ ...provider.glob('app/**/*.js'),
338
+ ...provider.glob('app/**/*.ts'),
339
+ ...provider.glob('app/**/*.html.erb'),
340
+ ...provider.glob('app/**/*.json.erb'),
341
+ ...provider.glob('app/**/*.html.haml'),
342
+ ...provider.glob('app/**/*.html.slim'),
343
+ ...provider.glob('app/**/*.jbuilder'),
344
+ ...provider.glob('config/**/*.rb'),
345
+ ...provider.glob('config/**/*.yml'),
346
+ ...provider.glob('db/**/*.rb'),
347
+ ...provider.glob('db/**/*.sql'),
348
+ ...provider.glob('lib/**/*.rb'),
349
+ ...provider.glob('spec/**/*.rb'),
350
+ ...provider.glob('test/**/*.rb'),
351
+ ...provider.glob('engines/**/*'),
352
+ ]
353
+
354
+ // Add specific files
355
+ const specificFiles = [
356
+ 'Gemfile',
357
+ 'Gemfile.lock',
358
+ 'Dockerfile',
359
+ 'docker-compose.yml',
360
+ '.dockerignore',
361
+ 'Procfile',
362
+ '.rspec',
363
+ '.rubocop.yml',
364
+ '.rubocop_todo.yml',
365
+ '.env',
366
+ '.env.development',
367
+ '.env.production',
368
+ ]
369
+
370
+ for (const file of specificFiles) {
371
+ if (provider.fileExists(file)) {
372
+ allFiles.push(file)
373
+ }
374
+ }
375
+
376
+ // Deduplicate
377
+ const uniqueFiles = [...new Set(allFiles)]
378
+
379
+ // Classify each file
380
+ for (const filePath of uniqueFiles) {
381
+ const entry = classifyFile(filePath)
382
+ if (entry) {
383
+ entries.push(entry)
384
+ byCategory[entry.categoryName].push(entry)
385
+ stats[entry.categoryName]++
386
+ } else {
387
+ unclassified.push(filePath)
388
+ }
389
+ }
390
+
391
+ return { entries, byCategory, stats, unclassified }
392
+ }
393
+
394
+ export { classifyFile, classifySpecFile, CATEGORIES }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Layer 2: Version Detector
3
+ *
4
+ * Detects Rails version, Ruby version, and key framework versions from
5
+ * Gemfile, Gemfile.lock, config/application.rb, and related config files.
6
+ * Uses FileProvider for all reads.
7
+ *
8
+ * @module version-detector
9
+ */
10
+
11
+ import { GEMFILE_PATTERNS, CONFIG_PATTERNS } from './patterns.js'
12
+
13
+ /**
14
+ * @typedef {Object} VersionInfo
15
+ * @property {string|null} rails - Rails version (e.g., "7.1.3")
16
+ * @property {string|null} ruby - Ruby version (e.g., "3.2.2")
17
+ * @property {string|null} loadDefaults - config.load_defaults value
18
+ * @property {Object} framework - Detected framework stack
19
+ * @property {string|null} framework.assetPipeline - "sprockets" | "propshaft" | null
20
+ * @property {string|null} framework.jsBundling - "webpacker" | "importmap" | "esbuild" | "rollup" | "webpack" | null
21
+ * @property {string|null} framework.cssBundling - "tailwind" | "bootstrap" | "sass" | "postcss" | null
22
+ * @property {string|null} framework.auth - "devise" | "native" | null
23
+ * @property {string|null} framework.jobAdapter - "sidekiq" | "solid_queue" | "good_job" | "delayed_job" | "async" | null
24
+ * @property {string|null} framework.cacheStore - "redis" | "solid_cache" | "memcached" | "memory" | null
25
+ * @property {string|null} framework.cableAdapter - "redis" | "solid_cable" | "async" | null
26
+ * @property {string|null} framework.testFramework - "rspec" | "minitest" | null
27
+ * @property {string|null} framework.deploy - "kamal" | "capistrano" | "heroku" | "docker" | null
28
+ * @property {boolean} framework.hotwire - Whether Hotwire (Turbo + Stimulus) is present
29
+ * @property {boolean} framework.apiOnly - Whether app is API-only
30
+ * @property {Object} gems - Key gem versions keyed by name
31
+ * @property {string[]} warnings - Detection warnings
32
+ */
33
+
34
+ /**
35
+ * Detect versions and framework stack from project files.
36
+ *
37
+ * @param {import('../providers/interface.js').FileProvider} provider - File access provider
38
+ * @returns {VersionInfo}
39
+ */
40
+ export function detectVersions(provider) {
41
+ const warnings = []
42
+ const gems = {}
43
+
44
+ // Read key files
45
+ const gemfile = provider.readFile('Gemfile') || ''
46
+ const gemfileLock = provider.readFile('Gemfile.lock') || ''
47
+ const appConfig = provider.readFile('config/application.rb') || ''
48
+
49
+ // Extract Rails version
50
+ const rails = extractRailsVersion(gemfile, gemfileLock)
51
+ if (!rails) warnings.push('Could not determine Rails version')
52
+
53
+ // Extract Ruby version
54
+ const ruby = extractRubyVersion(gemfile, gemfileLock, provider)
55
+ if (!ruby) warnings.push('Could not determine Ruby version')
56
+
57
+ // Extract load_defaults
58
+ const loadDefaults = extractLoadDefaults(appConfig)
59
+
60
+ // Parse all gems from Gemfile
61
+ parseGems(gemfile, gems)
62
+
63
+ // Parse precise versions from Gemfile.lock
64
+ parseLockfileVersions(gemfileLock, gems)
65
+
66
+ // Detect framework stack
67
+ const framework = detectFramework(gemfile, gems, appConfig, provider)
68
+
69
+ return { rails, ruby, loadDefaults, framework, gems, warnings }
70
+ }
71
+
72
+ /**
73
+ * Extract Rails version from Gemfile and Gemfile.lock.
74
+ * @param {string} gemfile
75
+ * @param {string} gemfileLock
76
+ * @returns {string|null}
77
+ */
78
+ function extractRailsVersion(gemfile, gemfileLock) {
79
+ // Try Gemfile.lock first (most precise)
80
+ const lockMatch = gemfileLock.match(
81
+ /^\s+rails\s+\((\d+\.\d+\.\d+(?:\.\w+)?)\)/m,
82
+ )
83
+ if (lockMatch) return lockMatch[1]
84
+
85
+ // Try Gemfile
86
+ const gemMatch = gemfile.match(
87
+ /gem\s+['"]rails['"],\s*['"]~?\s*>?\s*=?\s*(\d+\.\d+(?:\.\d+)?)['"]/,
88
+ )
89
+ if (gemMatch) return gemMatch[1]
90
+
91
+ return null
92
+ }
93
+
94
+ /**
95
+ * Extract Ruby version from Gemfile, Gemfile.lock, or .ruby-version.
96
+ * @param {string} gemfile
97
+ * @param {string} gemfileLock
98
+ * @param {import('../providers/interface.js').FileProvider} provider
99
+ * @returns {string|null}
100
+ */
101
+ function extractRubyVersion(gemfile, gemfileLock, provider) {
102
+ // Gemfile.lock RUBY VERSION section
103
+ const lockMatch = gemfileLock.match(/RUBY VERSION\s+ruby\s+(\d+\.\d+\.\d+)/)
104
+ if (lockMatch) return lockMatch[1]
105
+
106
+ // Gemfile ruby declaration
107
+ const gemMatch = gemfile.match(GEMFILE_PATTERNS.ruby)
108
+ if (gemMatch) return gemMatch[1]
109
+
110
+ // .ruby-version file
111
+ const rubyVersion = provider.readFile('.ruby-version')
112
+ if (rubyVersion) {
113
+ const ver = rubyVersion.trim().match(/^(\d+\.\d+\.\d+)/)
114
+ if (ver) return ver[1]
115
+ }
116
+
117
+ return null
118
+ }
119
+
120
+ /**
121
+ * Extract config.load_defaults from application.rb.
122
+ * @param {string} appConfig
123
+ * @returns {string|null}
124
+ */
125
+ function extractLoadDefaults(appConfig) {
126
+ const m = appConfig.match(CONFIG_PATTERNS.loadDefaults)
127
+ return m ? m[1] : null
128
+ }
129
+
130
+ /**
131
+ * Parse gem names from Gemfile.
132
+ * @param {string} gemfile
133
+ * @param {Object} gems
134
+ */
135
+ function parseGems(gemfile, gems) {
136
+ const lines = gemfile.split('\n')
137
+ for (const line of lines) {
138
+ const m = line.match(GEMFILE_PATTERNS.gem)
139
+ if (m) {
140
+ const name = m[1]
141
+ const version = m[2] || null
142
+ gems[name] = { declared: version }
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Parse precise versions from Gemfile.lock.
149
+ * @param {string} gemfileLock
150
+ * @param {Object} gems
151
+ */
152
+ function parseLockfileVersions(gemfileLock, gems) {
153
+ const lines = gemfileLock.split('\n')
154
+ let inSpecs = false
155
+
156
+ for (const line of lines) {
157
+ if (line.trim() === 'specs:') {
158
+ inSpecs = true
159
+ continue
160
+ }
161
+ if (inSpecs && /^\S/.test(line)) {
162
+ inSpecs = false
163
+ continue
164
+ }
165
+ if (!inSpecs) continue
166
+
167
+ const m = line.match(/^\s{4}(\S+)\s+\((\d+\.\d+(?:\.\d+(?:\.\w+)?)?)\)/)
168
+ if (m) {
169
+ const name = m[1]
170
+ const version = m[2]
171
+ if (gems[name]) {
172
+ gems[name].locked = version
173
+ } else {
174
+ gems[name] = { locked: version }
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Detect framework stack from gem presence and config.
182
+ * @param {string} gemfile
183
+ * @param {Object} gems
184
+ * @param {string} appConfig
185
+ * @param {import('../providers/interface.js').FileProvider} provider
186
+ * @returns {Object}
187
+ */
188
+ function detectFramework(gemfile, gems, appConfig, provider) {
189
+ const hasGem = (name) => name in gems
190
+
191
+ // Asset pipeline
192
+ let assetPipeline = null
193
+ if (hasGem('propshaft')) assetPipeline = 'propshaft'
194
+ else if (hasGem('sprockets') || hasGem('sprockets-rails'))
195
+ assetPipeline = 'sprockets'
196
+
197
+ // JS bundling
198
+ let jsBundling = null
199
+ if (hasGem('webpacker')) jsBundling = 'webpacker'
200
+ else if (hasGem('importmap-rails')) jsBundling = 'importmap'
201
+ else if (hasGem('jsbundling-rails')) {
202
+ // Check package.json for specific bundler
203
+ const pkgJson = provider.readFile('package.json')
204
+ if (pkgJson) {
205
+ if (pkgJson.includes('"esbuild"')) jsBundling = 'esbuild'
206
+ else if (pkgJson.includes('"rollup"')) jsBundling = 'rollup'
207
+ else if (pkgJson.includes('"webpack"')) jsBundling = 'webpack'
208
+ else jsBundling = 'jsbundling'
209
+ } else {
210
+ jsBundling = 'jsbundling'
211
+ }
212
+ }
213
+
214
+ // CSS
215
+ let cssBundling = null
216
+ if (hasGem('tailwindcss-rails')) cssBundling = 'tailwind'
217
+ else if (hasGem('cssbundling-rails')) cssBundling = 'cssbundling'
218
+ else if (hasGem('bootstrap')) cssBundling = 'bootstrap'
219
+ else if (hasGem('sassc-rails') || hasGem('sass-rails')) cssBundling = 'sass'
220
+
221
+ // Auth
222
+ let auth = null
223
+ if (hasGem('devise')) auth = 'devise'
224
+ else if (
225
+ provider.fileExists('app/models/session.rb') ||
226
+ provider.fileExists('app/models/current.rb')
227
+ ) {
228
+ auth = 'native'
229
+ }
230
+
231
+ // Job adapter
232
+ let jobAdapter = null
233
+ if (hasGem('solid_queue')) jobAdapter = 'solid_queue'
234
+ else if (hasGem('sidekiq')) jobAdapter = 'sidekiq'
235
+ else if (hasGem('good_job')) jobAdapter = 'good_job'
236
+ else if (hasGem('delayed_job')) jobAdapter = 'delayed_job'
237
+ else jobAdapter = 'async'
238
+
239
+ // Cache store
240
+ let cacheStore = null
241
+ if (hasGem('solid_cache')) cacheStore = 'solid_cache'
242
+ else if (hasGem('redis')) cacheStore = 'redis'
243
+ // Also check config
244
+ const prodConfig =
245
+ provider.readFile('config/environments/production.rb') || ''
246
+ const cacheStoreMatch = prodConfig.match(/config\.cache_store\s*=\s*:(\w+)/)
247
+ if (cacheStoreMatch) cacheStore = cacheStoreMatch[1]
248
+
249
+ // Cable adapter
250
+ let cableAdapter = null
251
+ if (hasGem('solid_cable')) cableAdapter = 'solid_cable'
252
+ else {
253
+ const cableYml = provider.readFile('config/cable.yml') || ''
254
+ const adapterMatch = cableYml.match(/production:\s*\n\s*adapter:\s*(\w+)/)
255
+ if (adapterMatch) cableAdapter = adapterMatch[1]
256
+ }
257
+
258
+ // Test framework
259
+ let testFramework = null
260
+ if (hasGem('rspec-rails') || hasGem('rspec')) testFramework = 'rspec'
261
+ else if (hasGem('minitest') || provider.fileExists('test'))
262
+ testFramework = 'minitest'
263
+
264
+ // Deployment
265
+ let deploy = null
266
+ if (
267
+ hasGem('kamal') ||
268
+ provider.fileExists('config/deploy.yml') ||
269
+ provider.fileExists('.kamal')
270
+ )
271
+ deploy = 'kamal'
272
+ else if (hasGem('capistrano')) deploy = 'capistrano'
273
+ else if (provider.fileExists('Procfile')) deploy = 'heroku'
274
+ else if (provider.fileExists('Dockerfile')) deploy = 'docker'
275
+
276
+ // Hotwire
277
+ const hotwire = hasGem('turbo-rails') || hasGem('hotwire-rails')
278
+
279
+ // API only
280
+ const apiOnly = CONFIG_PATTERNS.apiOnly.test(appConfig)
281
+
282
+ return {
283
+ assetPipeline,
284
+ jsBundling,
285
+ cssBundling,
286
+ auth,
287
+ jobAdapter,
288
+ cacheStore,
289
+ cableAdapter,
290
+ testFramework,
291
+ deploy,
292
+ hotwire,
293
+ apiOnly,
294
+ }
295
+ }