@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,417 @@
1
+ /**
2
+ * Tier 2 Extractor (#18-40)
3
+ * Lightweight extraction for secondary categories, primarily
4
+ * using Gemfile detection and file existence checks.
5
+ */
6
+
7
+ import { detectSpecStyle } from '../utils/spec-style-detector.js'
8
+
9
+ /**
10
+ * Extract Tier 2 information across categories #18-40.
11
+ * @param {import('../providers/interface.js').FileProvider} provider
12
+ * @param {Array<{path: string, category: string}>} entries
13
+ * @param {{gems?: object}} gemInfo
14
+ * @returns {object}
15
+ */
16
+ export function extractTier2(provider, entries, gemInfo = {}) {
17
+ const gems = gemInfo.gems || {}
18
+
19
+ return {
20
+ security: extractSecurity(provider, gems),
21
+ testing: extractTesting(provider, entries, gems),
22
+ code_quality: extractCodeQuality(provider, gems),
23
+ deployment: extractDeployment(provider, gems),
24
+ search: extractSearch(entries, gems),
25
+ payments: extractPayments(gems),
26
+ multi_tenancy: extractMultiTenancy(entries, gems),
27
+ admin: extractAdmin(entries, gems),
28
+ design_patterns: extractDesignPatterns(entries),
29
+ state_machines: extractStateMachines(gems),
30
+ i18n: extractI18n(provider, entries),
31
+ pdf: extractPdf(gems),
32
+ csv: extractCsv(gems),
33
+ webhooks: extractWebhooks(entries),
34
+ scheduled_tasks: extractScheduledTasks(provider, gems),
35
+ middleware: extractMiddleware(entries),
36
+ engines: extractEngines(entries),
37
+ credentials: extractCredentials(provider, gems),
38
+ http_clients: extractHttpClients(gems),
39
+ performance: extractPerformance(gems),
40
+ database_tooling: extractDatabaseTooling(gems),
41
+ rich_text: extractRichText(gems),
42
+ notifications: extractNotifications(entries, gems),
43
+ }
44
+ }
45
+
46
+ /** #18 Security */
47
+ function extractSecurity(provider, gems) {
48
+ const result = {
49
+ csp: false,
50
+ cors: false,
51
+ force_ssl: false,
52
+ filter_parameters: false,
53
+ credentials_encrypted: false,
54
+ brakeman: !!gems.brakeman,
55
+ bundler_audit: !!gems['bundler-audit'],
56
+ }
57
+
58
+ result.csp =
59
+ provider.readFile('config/initializers/content_security_policy.rb') !== null
60
+ result.cors = provider.readFile('config/initializers/cors.rb') !== null
61
+
62
+ const prodContent = provider.readFile('config/environments/production.rb')
63
+ if (prodContent && /config\.force_ssl\s*=\s*true/.test(prodContent)) {
64
+ result.force_ssl = true
65
+ }
66
+
67
+ const filterContent = provider.readFile(
68
+ 'config/initializers/filter_parameter_logging.rb',
69
+ )
70
+ if (filterContent) result.filter_parameters = true
71
+
72
+ if (provider.readFile('config/credentials.yml.enc') !== null) {
73
+ result.credentials_encrypted = true
74
+ }
75
+
76
+ return result
77
+ }
78
+
79
+ /** #19 Testing */
80
+ function extractTesting(provider, entries, gems) {
81
+ const result = {
82
+ framework: null,
83
+ factories: !!gems.factory_bot_rails,
84
+ system_tests: !!gems.capybara,
85
+ coverage: !!gems.simplecov,
86
+ mocking: [],
87
+ parallel: !!gems.parallel_tests,
88
+ faker: !!gems.faker,
89
+ spec_style: detectSpecStyle(entries),
90
+ factories_dir: detectFactoriesDir(provider),
91
+ fixtures_dir: detectFixturesDir(provider),
92
+ }
93
+
94
+ if (gems['rspec-rails']) {
95
+ result.framework = 'rspec'
96
+ } else if (entries.some((e) => e.path.startsWith('test/'))) {
97
+ result.framework = 'minitest'
98
+ }
99
+
100
+ if (gems.webmock) result.mocking.push('webmock')
101
+ if (gems.vcr) result.mocking.push('vcr')
102
+
103
+ return result
104
+ }
105
+
106
+ /**
107
+ * Detect the factories directory.
108
+ * @param {import('../providers/interface.js').FileProvider} provider
109
+ * @returns {string|null}
110
+ */
111
+ function detectFactoriesDir(provider) {
112
+ if (provider.fileExists('spec/factories')) return 'spec/factories'
113
+ if (provider.fileExists('test/factories')) return 'test/factories'
114
+ return null
115
+ }
116
+
117
+ /**
118
+ * Detect the fixtures directory.
119
+ * @param {import('../providers/interface.js').FileProvider} provider
120
+ * @returns {string|null}
121
+ */
122
+ function detectFixturesDir(provider) {
123
+ if (provider.fileExists('spec/fixtures')) return 'spec/fixtures'
124
+ if (provider.fileExists('test/fixtures')) return 'test/fixtures'
125
+ return null
126
+ }
127
+
128
+ /** #20 Code Quality */
129
+ function extractCodeQuality(provider, gems) {
130
+ const result = {
131
+ rubocop: false,
132
+ rubocop_preset: null,
133
+ erb_lint: !!gems.erb_lint,
134
+ eslint: false,
135
+ brakeman: !!gems.brakeman,
136
+ }
137
+
138
+ if (provider.readFile('.rubocop.yml') !== null) {
139
+ result.rubocop = true
140
+ if (gems['rubocop-rails-omakase']) result.rubocop_preset = 'omakase'
141
+ else if (gems.standard) result.rubocop_preset = 'standard'
142
+ }
143
+
144
+ // ESLint detection
145
+ const eslintFiles = [
146
+ '.eslintrc.js',
147
+ '.eslintrc.json',
148
+ '.eslintrc.yml',
149
+ 'eslint.config.js',
150
+ 'eslint.config.mjs',
151
+ ]
152
+ for (const f of eslintFiles) {
153
+ if (provider.readFile(f) !== null) {
154
+ result.eslint = true
155
+ break
156
+ }
157
+ }
158
+
159
+ return result
160
+ }
161
+
162
+ /** #21 Deployment */
163
+ function extractDeployment(provider, gems) {
164
+ const result = {
165
+ kamal: false,
166
+ capistrano: false,
167
+ heroku: false,
168
+ docker: false,
169
+ ci: [],
170
+ }
171
+
172
+ if (provider.readFile('config/deploy.yml') !== null) result.kamal = true
173
+ if (provider.readFile('Capfile') !== null) result.capistrano = true
174
+ if (provider.readFile('Procfile') !== null) result.heroku = true
175
+ if (provider.readFile('Dockerfile') !== null) result.docker = true
176
+
177
+ // CI detection
178
+ if (
179
+ provider.readFile('.github/workflows') !== null ||
180
+ provider.readFile('.github') !== null
181
+ ) {
182
+ // Check if any workflow files exist via entries
183
+ result.ci.push('github_actions')
184
+ }
185
+ if (provider.readFile('.circleci/config.yml') !== null)
186
+ result.ci.push('circleci')
187
+ if (provider.readFile('.gitlab-ci.yml') !== null) result.ci.push('gitlab')
188
+
189
+ return result
190
+ }
191
+
192
+ /** #22 Search */
193
+ function extractSearch(entries, gems) {
194
+ const result = {
195
+ engine: null,
196
+ }
197
+
198
+ if (gems.searchkick) result.engine = 'searchkick'
199
+ else if (gems.pg_search) result.engine = 'pg_search'
200
+ else if (gems['meilisearch-rails']) result.engine = 'meilisearch'
201
+ else if (gems.chewy) result.engine = 'chewy'
202
+ else if (gems['elasticsearch-rails']) result.engine = 'elasticsearch'
203
+
204
+ return result
205
+ }
206
+
207
+ /** #23 Payments */
208
+ function extractPayments(gems) {
209
+ const result = { provider: null }
210
+ if (gems.pay) result.provider = 'pay'
211
+ else if (gems.stripe) result.provider = 'stripe'
212
+ return result
213
+ }
214
+
215
+ /** #24 Multi-tenancy */
216
+ function extractMultiTenancy(entries, gems) {
217
+ const result = { strategy: null }
218
+ if (gems.acts_as_tenant) result.strategy = 'acts_as_tenant'
219
+ else if (gems.apartment) result.strategy = 'apartment'
220
+ return result
221
+ }
222
+
223
+ /** #25 Admin */
224
+ function extractAdmin(entries, gems) {
225
+ const result = { framework: null }
226
+ if (gems.activeadmin) result.framework = 'activeadmin'
227
+ else if (gems.administrate) result.framework = 'administrate'
228
+ else if (gems.avo) result.framework = 'avo'
229
+ else if (gems.rails_admin) result.framework = 'rails_admin'
230
+ else if (entries.some((e) => e.path.startsWith('app/controllers/admin/'))) {
231
+ result.framework = 'custom'
232
+ }
233
+ return result
234
+ }
235
+
236
+ /** #26 Design Patterns */
237
+ function extractDesignPatterns(entries) {
238
+ const patterns = {}
239
+ const dirs = {
240
+ services: 'app/services/',
241
+ forms: 'app/forms/',
242
+ queries: 'app/queries/',
243
+ decorators: 'app/decorators/',
244
+ presenters: 'app/presenters/',
245
+ interactors: 'app/interactors/',
246
+ validators: 'app/validators/',
247
+ notifiers: 'app/notifiers/',
248
+ }
249
+ for (const [name, dir] of Object.entries(dirs)) {
250
+ const count = entries.filter((e) => e.path.startsWith(dir)).length
251
+ if (count > 0) patterns[name] = count
252
+ }
253
+ return patterns
254
+ }
255
+
256
+ /** #27 State Machines */
257
+ function extractStateMachines(gems) {
258
+ const result = { library: null }
259
+ if (gems.aasm) result.library = 'aasm'
260
+ else if (gems.statesman) result.library = 'statesman'
261
+ else if (gems['state_machines-activerecord'])
262
+ result.library = 'state_machines'
263
+ return result
264
+ }
265
+
266
+ /** #28 I18n */
267
+ function extractI18n(provider, entries) {
268
+ const result = {
269
+ default_locale: null,
270
+ locales: [],
271
+ }
272
+
273
+ const appContent = provider.readFile('config/application.rb')
274
+ if (appContent) {
275
+ const dlMatch = appContent.match(
276
+ /config\.i18n\.default_locale\s*=\s*:(\w+)/,
277
+ )
278
+ if (dlMatch) result.default_locale = dlMatch[1]
279
+ }
280
+
281
+ const localeEntries = entries.filter(
282
+ (e) => e.path.startsWith('config/locales/') && e.path.endsWith('.yml'),
283
+ )
284
+ const localeSet = new Set()
285
+ for (const entry of localeEntries) {
286
+ // Extract locale from filename: en.yml, devise.en.yml, etc.
287
+ const match = entry.path.match(/\.?(\w{2}(?:-\w{2})?)\.yml$/)
288
+ if (match) localeSet.add(match[1])
289
+ }
290
+ result.locales = [...localeSet].sort()
291
+
292
+ return result
293
+ }
294
+
295
+ /** #29 PDF */
296
+ function extractPdf(gems) {
297
+ const result = { library: null }
298
+ if (gems.wicked_pdf) result.library = 'wicked_pdf'
299
+ else if (gems.prawn) result.library = 'prawn'
300
+ else if (gems.grover) result.library = 'grover'
301
+ return result
302
+ }
303
+
304
+ /** #30 CSV / Spreadsheet */
305
+ function extractCsv(gems) {
306
+ const result = { library: null }
307
+ if (gems.caxlsx || gems.axlsx_rails) result.library = 'caxlsx'
308
+ else if (gems.roo) result.library = 'roo'
309
+ return result
310
+ }
311
+
312
+ /** #31 Webhooks */
313
+ function extractWebhooks(entries) {
314
+ const controllers = entries.filter(
315
+ (e) => e.path.includes('webhook') && e.category === 'controller',
316
+ )
317
+ return { detected: controllers.length > 0, controllers: controllers.length }
318
+ }
319
+
320
+ /** #32 Scheduled Tasks */
321
+ function extractScheduledTasks(provider, gems) {
322
+ const result = { scheduler: null }
323
+ if (gems.whenever) result.scheduler = 'whenever'
324
+ else if (gems['sidekiq-cron']) result.scheduler = 'sidekiq-cron'
325
+ else if (gems['sidekiq-scheduler']) result.scheduler = 'sidekiq-scheduler'
326
+
327
+ if (provider.readFile('config/recurring.yml') !== null) {
328
+ result.scheduler = result.scheduler || 'solid_queue'
329
+ result.recurring_jobs = true
330
+ }
331
+
332
+ return result
333
+ }
334
+
335
+ /** #33 Middleware */
336
+ function extractMiddleware(entries) {
337
+ const custom = entries.filter((e) => e.path.startsWith('app/middleware/'))
338
+ return { custom_count: custom.length }
339
+ }
340
+
341
+ /** #34 Engines */
342
+ function extractEngines(entries) {
343
+ const engineEntries = entries.filter(
344
+ (e) => e.path.startsWith('engines/') || e.path.startsWith('lib/engines/'),
345
+ )
346
+ return { count: engineEntries.length }
347
+ }
348
+
349
+ /** #35 Credentials */
350
+ function extractCredentials(provider, gems) {
351
+ const result = {
352
+ encrypted: false,
353
+ per_environment: false,
354
+ dotenv: !!gems['dotenv-rails'],
355
+ legacy_secrets: false,
356
+ }
357
+
358
+ if (provider.readFile('config/credentials.yml.enc') !== null) {
359
+ result.encrypted = true
360
+ }
361
+ if (provider.readFile('config/credentials/production.yml.enc') !== null) {
362
+ result.per_environment = true
363
+ }
364
+ if (provider.readFile('config/secrets.yml') !== null) {
365
+ result.legacy_secrets = true
366
+ }
367
+
368
+ return result
369
+ }
370
+
371
+ /** #36 HTTP Clients */
372
+ function extractHttpClients(gems) {
373
+ const clients = []
374
+ if (gems.faraday) clients.push('faraday')
375
+ if (gems.httparty) clients.push('httparty')
376
+ return { clients }
377
+ }
378
+
379
+ /** #37 Performance */
380
+ function extractPerformance(gems) {
381
+ const tools = []
382
+ if (gems.bullet) tools.push('bullet')
383
+ if (gems['rack-mini-profiler']) tools.push('rack-mini-profiler')
384
+ if (gems.pghero) tools.push('pghero')
385
+ if (gems.prosopite) tools.push('prosopite')
386
+ return { tools }
387
+ }
388
+
389
+ /** #38 Database Tooling */
390
+ function extractDatabaseTooling(gems) {
391
+ const tools = []
392
+ if (gems.annotate) tools.push('annotate')
393
+ if (gems.strong_migrations) tools.push('strong_migrations')
394
+ if (gems.database_cleaner) tools.push('database_cleaner')
395
+ if (gems.active_record_doctor) tools.push('active_record_doctor')
396
+ return { tools }
397
+ }
398
+
399
+ /** #39 Rich Text */
400
+ function extractRichText(gems) {
401
+ const result = { action_text: false, markdown: null }
402
+ if (gems.actiontext || gems['actiontext']) result.action_text = true
403
+ if (gems.redcarpet) result.markdown = 'redcarpet'
404
+ else if (gems.kramdown) result.markdown = 'kramdown'
405
+ else if (gems.commonmarker) result.markdown = 'commonmarker'
406
+ return result
407
+ }
408
+
409
+ /** #40 Notifications */
410
+ function extractNotifications(entries, gems) {
411
+ const result = { framework: null }
412
+ if (gems.noticed) result.framework = 'noticed'
413
+ else if (entries.some((e) => e.path.startsWith('app/notifications/'))) {
414
+ result.framework = 'custom'
415
+ }
416
+ return result
417
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tier 3 Extractor (#41-56)
3
+ * Detection-only extraction for tertiary categories,
4
+ * primarily Gemfile-based gem presence checks.
5
+ */
6
+
7
+ /**
8
+ * Detect the first matching gem from the given names.
9
+ * @param {object} gems - Gem lookup object
10
+ * @param {...string} names - Gem names to check in priority order
11
+ * @returns {{gem: string}|null}
12
+ */
13
+ function detectGem(gems, ...names) {
14
+ for (const n of names) {
15
+ if (gems[n]) return { gem: n }
16
+ }
17
+ return null
18
+ }
19
+
20
+ /**
21
+ * Detect all matching gems from the given names.
22
+ * @param {object} gems - Gem lookup object
23
+ * @param {...string} names - Gem names to check
24
+ * @returns {{gems: string[]}|null}
25
+ */
26
+ function detectGems(gems, ...names) {
27
+ const found = names.filter((n) => gems[n])
28
+ return found.length > 0 ? { gems: found } : null
29
+ }
30
+
31
+ /**
32
+ * Extract Tier 3 detection across categories #41-56.
33
+ * @param {import('../providers/interface.js').FileProvider} provider
34
+ * @param {Array<{path: string, category: string}>} entries
35
+ * @param {{gems?: object}} gemInfo
36
+ * @returns {object}
37
+ */
38
+ export function extractTier3(provider, entries, gemInfo = {}) {
39
+ const gems = gemInfo.gems || {}
40
+
41
+ return {
42
+ feature_flags: detectGem(gems, 'flipper', 'unleash'),
43
+ audit: detectGem(gems, 'paper_trail', 'audited', 'logidze'),
44
+ soft_delete: detectGem(gems, 'discard', 'paranoia'),
45
+ pagination: detectGem(gems, 'pagy', 'kaminari', 'will_paginate'),
46
+ friendly_urls: detectGem(gems, 'friendly_id'),
47
+ tagging: detectGem(gems, 'acts-as-taggable-on'),
48
+ seo: detectGems(gems, 'meta-tags', 'sitemap_generator'),
49
+ geolocation: detectGem(gems, 'geocoder', 'rgeo'),
50
+ sms_push: detectGems(gems, 'twilio-ruby', 'web-push'),
51
+ activity_tracking: detectGem(gems, 'public_activity'),
52
+ data_import_export: extractDataImportExport(entries),
53
+ event_sourcing: detectGem(gems, 'rails_event_store', 'sequent'),
54
+ dry_rb: detectGems(
55
+ gems,
56
+ 'dry-validation',
57
+ 'dry-monads',
58
+ 'dry-types',
59
+ 'dry-struct',
60
+ ),
61
+ markdown: detectGem(gems, 'redcarpet', 'kramdown', 'commonmarker'),
62
+ rate_limiting: detectGem(gems, 'rack-attack'),
63
+ graphql: extractGraphql(entries, gems),
64
+ }
65
+ }
66
+
67
+ /** #51 */
68
+ function extractDataImportExport(entries) {
69
+ const detected = entries.some(
70
+ (e) =>
71
+ /import|export/i.test(e.path) &&
72
+ (e.path.startsWith('app/services/') || e.path.startsWith('app/jobs/')),
73
+ )
74
+ return { detected }
75
+ }
76
+
77
+ /** #56 */
78
+ function extractGraphql(entries, gems) {
79
+ if (!gems.graphql) return null
80
+ const schema = entries.some(
81
+ (e) => e.path.startsWith('app/graphql/') && e.path.includes('schema'),
82
+ )
83
+ return { gem: 'graphql', schema }
84
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Uploader Extractor (#12 — Storage sub-type)
3
+ * Extracts CarrierWave / Shrine uploader metadata: class name, storage type,
4
+ * allowed extensions/content types, versions, and size constraints.
5
+ */
6
+
7
+ import { UPLOADER_PATTERNS } from '../core/patterns.js'
8
+
9
+ /**
10
+ * Extract uploader information from a single uploader file.
11
+ * @param {import('../providers/interface.js').FileProvider} provider
12
+ * @param {string} filePath
13
+ * @returns {object|null}
14
+ */
15
+ export function extractUploader(provider, filePath) {
16
+ const content = provider.readFile(filePath)
17
+ if (!content) return null
18
+
19
+ // Try CarrierWave first
20
+ const cwMatch = content.match(UPLOADER_PATTERNS.carrierWaveClass)
21
+ if (cwMatch) {
22
+ return extractCarrierWaveUploader(content, filePath, cwMatch[1])
23
+ }
24
+
25
+ // Try Shrine
26
+ const shrineMatch = content.match(UPLOADER_PATTERNS.shrineClass)
27
+ if (shrineMatch) {
28
+ return extractShrineUploader(content, filePath, shrineMatch[1])
29
+ }
30
+
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Extract CarrierWave uploader metadata.
36
+ * @param {string} content
37
+ * @param {string} filePath
38
+ * @param {string} className
39
+ * @returns {object}
40
+ */
41
+ function extractCarrierWaveUploader(content, filePath, className) {
42
+ const result = {
43
+ class: className,
44
+ file: filePath,
45
+ type: 'carrierwave',
46
+ storage: 'file',
47
+ extensions: [],
48
+ content_types: [],
49
+ versions: [],
50
+ store_dir: null,
51
+ }
52
+
53
+ // Storage type
54
+ const storageMatch = content.match(UPLOADER_PATTERNS.storageType)
55
+ if (storageMatch) {
56
+ result.storage = storageMatch[1]
57
+ }
58
+
59
+ // Extension allowlist
60
+ const extMatch = content.match(UPLOADER_PATTERNS.extensionAllowlist)
61
+ if (extMatch) {
62
+ result.extensions = extMatch[1].trim().split(/\s+/)
63
+ }
64
+
65
+ // Content type allowlist
66
+ const ctMatch = content.match(UPLOADER_PATTERNS.contentTypeAllowlist)
67
+ if (ctMatch) {
68
+ result.content_types = ctMatch[1].trim().split(/\s+/)
69
+ }
70
+
71
+ // Versions
72
+ const versionRe = new RegExp(UPLOADER_PATTERNS.versionBlock.source, 'g')
73
+ let m
74
+ while ((m = versionRe.exec(content))) {
75
+ result.versions.push(m[1])
76
+ }
77
+
78
+ // Store dir
79
+ const dirMatch = content.match(UPLOADER_PATTERNS.storeDir)
80
+ if (dirMatch) {
81
+ result.store_dir = dirMatch[1]
82
+ }
83
+
84
+ return result
85
+ }
86
+
87
+ /**
88
+ * Extract Shrine uploader metadata.
89
+ * @param {string} content
90
+ * @param {string} filePath
91
+ * @param {string} className
92
+ * @returns {object}
93
+ */
94
+ function extractShrineUploader(content, filePath, className) {
95
+ const result = {
96
+ class: className,
97
+ file: filePath,
98
+ type: 'shrine',
99
+ plugins: [],
100
+ }
101
+
102
+ const pluginRe = new RegExp(UPLOADER_PATTERNS.shrinePlugin.source, 'g')
103
+ let m
104
+ while ((m = pluginRe.exec(content))) {
105
+ result.plugins.push(m[1])
106
+ }
107
+
108
+ return result
109
+ }
110
+
111
+ /**
112
+ * Scan models for CarrierWave mount_uploader declarations.
113
+ * @param {import('../providers/interface.js').FileProvider} provider
114
+ * @param {Object<string, {file?: string}>} modelExtractions
115
+ * @returns {Array<{model: string, attribute: string, uploader: string}>}
116
+ */
117
+ export function detectMountedUploaders(provider, modelExtractions) {
118
+ const mounted = []
119
+ if (!modelExtractions) return mounted
120
+
121
+ for (const [modelName, model] of Object.entries(modelExtractions)) {
122
+ if (!model.file) continue
123
+ const content = provider.readFile(model.file)
124
+ if (!content) continue
125
+
126
+ const mountRe = new RegExp(UPLOADER_PATTERNS.mountUploader.source, 'g')
127
+ let m
128
+ while ((m = mountRe.exec(content))) {
129
+ mounted.push({
130
+ model: modelName,
131
+ attribute: m[1],
132
+ uploader: m[2],
133
+ })
134
+ }
135
+ }
136
+
137
+ return mounted
138
+ }