@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,251 @@
1
+ /**
2
+ * Routes Extractor (#3)
3
+ * Parses config/routes.rb with namespace/scope stack tracking.
4
+ */
5
+
6
+ import { ROUTE_PATTERNS } from '../core/patterns.js'
7
+
8
+ /**
9
+ * Extract route information from routes file(s).
10
+ * @param {import('../providers/interface.js').FileProvider} provider
11
+ * @returns {object}
12
+ */
13
+ export function extractRoutes(provider) {
14
+ const result = {
15
+ root: null,
16
+ resources: [],
17
+ standalone_routes: [],
18
+ mounted_engines: [],
19
+ concerns: [],
20
+ drawn_files: [],
21
+ nested_relationships: [],
22
+ }
23
+
24
+ const content = provider.readFile('config/routes.rb')
25
+ if (!content) return result
26
+
27
+ parseRouteContent(content, result, provider, [])
28
+ return result
29
+ }
30
+
31
+ /**
32
+ * @param {string} content
33
+ * @param {object} result
34
+ * @param {import('../providers/interface.js').FileProvider} provider
35
+ * @param {string[]} namespaceStack
36
+ */
37
+ function parseRouteContent(content, result, provider, namespaceStack) {
38
+ const lines = content.split('\n')
39
+ const blockStack = [] // tracks do..end nesting for resources/member/collection
40
+ const resourceStack = []
41
+ let inMember = false
42
+ let inCollection = false
43
+
44
+ for (let i = 0; i < lines.length; i++) {
45
+ const line = lines[i]
46
+ const trimmed = line.trim()
47
+
48
+ // Skip comments and blanks
49
+ if (!trimmed || trimmed.startsWith('#')) continue
50
+
51
+ // Root
52
+ const rootMatch = trimmed.match(ROUTE_PATTERNS.root)
53
+ if (rootMatch) {
54
+ result.root = { controller: rootMatch[1], action: rootMatch[2] || null }
55
+ continue
56
+ }
57
+
58
+ // Draw (route splitting)
59
+ const drawMatch = trimmed.match(ROUTE_PATTERNS.draw)
60
+ if (drawMatch) {
61
+ const drawFile = drawMatch[1]
62
+ result.drawn_files.push(drawFile)
63
+ const drawContent = provider.readFile('config/routes/' + drawFile + '.rb')
64
+ if (drawContent) {
65
+ parseRouteContent(drawContent, result, provider, [...namespaceStack])
66
+ }
67
+ continue
68
+ }
69
+
70
+ // Mount
71
+ const mountMatch = trimmed.match(ROUTE_PATTERNS.mount)
72
+ if (mountMatch) {
73
+ result.mounted_engines.push({
74
+ engine: mountMatch[1],
75
+ path: mountMatch[2],
76
+ })
77
+ continue
78
+ }
79
+
80
+ // Concern definition
81
+ const concernMatch = trimmed.match(ROUTE_PATTERNS.concern)
82
+ if (concernMatch) {
83
+ result.concerns.push(concernMatch[1])
84
+ blockStack.push('concern')
85
+ continue
86
+ }
87
+
88
+ // Namespace
89
+ const nsMatch = trimmed.match(ROUTE_PATTERNS.namespace)
90
+ if (nsMatch) {
91
+ namespaceStack.push(nsMatch[1])
92
+ blockStack.push('namespace')
93
+ continue
94
+ }
95
+
96
+ // Scope
97
+ const scopeMatch = trimmed.match(ROUTE_PATTERNS.scope)
98
+ if (scopeMatch) {
99
+ const scopeName = scopeMatch[1] || scopeMatch[2] || ''
100
+ namespaceStack.push(scopeName)
101
+ blockStack.push('scope')
102
+ continue
103
+ }
104
+
105
+ // Resource (singular) - check before resources since resources? matches both
106
+ const resourceMatch = trimmed.match(ROUTE_PATTERNS.resource)
107
+ if (resourceMatch && /^\s*resource\s/.test(trimmed)) {
108
+ const name = resourceMatch[1]
109
+ const options = resourceMatch[2] || ''
110
+ const ns = namespaceStack.length > 0 ? namespaceStack.join('/') : null
111
+
112
+ let actions = ['show', 'new', 'create', 'edit', 'update', 'destroy']
113
+ const onlyMatch = options.match(ROUTE_PATTERNS.only)
114
+ if (onlyMatch) {
115
+ actions = onlyMatch[1].match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
116
+ }
117
+
118
+ const entry = {
119
+ name,
120
+ namespace: ns,
121
+ controller: ns ? `${ns}/${name}` : name,
122
+ actions,
123
+ singular: true,
124
+ }
125
+
126
+ if (trimmed.includes('do')) {
127
+ blockStack.push('resource')
128
+ resourceStack.push(entry)
129
+ }
130
+
131
+ result.resources.push(entry)
132
+ continue
133
+ }
134
+
135
+ // Resources (plural)
136
+ const resourcesMatch = trimmed.match(ROUTE_PATTERNS.resources)
137
+ if (resourcesMatch) {
138
+ const name = resourcesMatch[1]
139
+ const options = resourcesMatch[2] || ''
140
+ const ns = namespaceStack.length > 0 ? namespaceStack.join('/') : null
141
+
142
+ // Determine actions
143
+ let actions = [
144
+ 'index',
145
+ 'show',
146
+ 'new',
147
+ 'create',
148
+ 'edit',
149
+ 'update',
150
+ 'destroy',
151
+ ]
152
+ const onlyMatch = options.match(ROUTE_PATTERNS.only)
153
+ if (onlyMatch) {
154
+ actions = onlyMatch[1].match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
155
+ }
156
+ const exceptMatch = options.match(ROUTE_PATTERNS.except)
157
+ if (exceptMatch) {
158
+ const except =
159
+ exceptMatch[1].match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
160
+ actions = actions.filter((a) => !except.includes(a))
161
+ }
162
+
163
+ const entry = {
164
+ name,
165
+ namespace: ns,
166
+ controller: ns ? `${ns}/${name}` : name,
167
+ actions,
168
+ member_routes: [],
169
+ collection_routes: [],
170
+ nested: [],
171
+ }
172
+
173
+ // Track nesting relationship
174
+ const parentResource = resourceStack[resourceStack.length - 1] || null
175
+ if (parentResource) {
176
+ result.nested_relationships.push({
177
+ parent: parentResource.name,
178
+ child: name,
179
+ parent_controller: parentResource.controller,
180
+ child_controller: ns ? `${ns}/${name}` : name,
181
+ })
182
+ entry.parent_resource = parentResource.name
183
+ if (parentResource.nested) {
184
+ parentResource.nested.push(name)
185
+ }
186
+ }
187
+
188
+ if (trimmed.includes('do')) {
189
+ blockStack.push('resources')
190
+ resourceStack.push(entry)
191
+ }
192
+
193
+ result.resources.push(entry)
194
+ continue
195
+ }
196
+
197
+ // Member block
198
+ if (ROUTE_PATTERNS.member.test(trimmed)) {
199
+ inMember = true
200
+ blockStack.push('member')
201
+ continue
202
+ }
203
+
204
+ // Collection block
205
+ if (ROUTE_PATTERNS.collection.test(trimmed)) {
206
+ inCollection = true
207
+ blockStack.push('collection')
208
+ continue
209
+ }
210
+
211
+ // HTTP verb routes
212
+ const verbMatch = trimmed.match(ROUTE_PATTERNS.httpVerb)
213
+ if (verbMatch) {
214
+ const path = verbMatch[1]
215
+ const controller = verbMatch[2] || null
216
+ const action = verbMatch[3] || null
217
+ const method =
218
+ trimmed
219
+ .match(/^\s*(get|post|put|patch|delete)\s/)?.[1]
220
+ ?.toUpperCase() || 'GET'
221
+
222
+ if (inMember && resourceStack.length > 0) {
223
+ // Extract action name from path
224
+ const currentResource = resourceStack[resourceStack.length - 1]
225
+ const memberAction = path.replace(/^\//, '').split('/')[0]
226
+ currentResource.member_routes.push(memberAction)
227
+ } else if (inCollection && resourceStack.length > 0) {
228
+ const currentResource = resourceStack[resourceStack.length - 1]
229
+ const collAction = path.replace(/^\//, '').split('/')[0]
230
+ currentResource.collection_routes.push(collAction)
231
+ } else {
232
+ result.standalone_routes.push({ method, path, controller, action })
233
+ }
234
+ continue
235
+ }
236
+
237
+ // End
238
+ if (/^\s*end\b/.test(trimmed)) {
239
+ const popped = blockStack.pop()
240
+ if (popped === 'namespace' || popped === 'scope') {
241
+ namespaceStack.pop()
242
+ } else if (popped === 'member') {
243
+ inMember = false
244
+ } else if (popped === 'collection') {
245
+ inCollection = false
246
+ } else if (popped === 'resources' || popped === 'resource') {
247
+ resourceStack.pop()
248
+ }
249
+ }
250
+ }
251
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Schema Extractor (#4)
3
+ * Parses db/schema.rb for table definitions, columns, indexes, foreign keys.
4
+ */
5
+
6
+ import { SCHEMA_PATTERNS } from '../core/patterns.js'
7
+
8
+ /**
9
+ * Extract schema information from db/schema.rb.
10
+ * @param {import('../providers/interface.js').FileProvider} provider
11
+ * @returns {object}
12
+ */
13
+ export function extractSchema(provider) {
14
+ const result = {
15
+ version: null,
16
+ extensions: [],
17
+ enums: {},
18
+ tables: [],
19
+ foreign_keys: [],
20
+ }
21
+
22
+ const content = provider.readFile('db/schema.rb')
23
+ if (!content) return result
24
+
25
+ // Schema version
26
+ const versionMatch =
27
+ content.match(SCHEMA_PATTERNS.schemaVersion) ||
28
+ content.match(SCHEMA_PATTERNS.schemaVersionAlt)
29
+ if (versionMatch) {
30
+ result.version = versionMatch[1]
31
+ }
32
+
33
+ // Extensions
34
+ const extRe = new RegExp(SCHEMA_PATTERNS.enableExtension.source, 'gm')
35
+ let m
36
+ while ((m = extRe.exec(content))) {
37
+ result.extensions.push(m[1])
38
+ }
39
+
40
+ // Enums (PostgreSQL)
41
+ const enumRe = new RegExp(SCHEMA_PATTERNS.createEnum.source, 'gm')
42
+ while ((m = enumRe.exec(content))) {
43
+ const values =
44
+ m[2].match(/['"](\w+)['"]/g)?.map((v) => v.replace(/['"]/g, '')) || []
45
+ result.enums[m[1]] = values
46
+ }
47
+
48
+ // Foreign keys (outside table blocks)
49
+ const fkRe = new RegExp(SCHEMA_PATTERNS.foreignKey.source, 'gm')
50
+ while ((m = fkRe.exec(content))) {
51
+ result.foreign_keys.push({
52
+ from_table: m[1],
53
+ to_table: m[2],
54
+ options: m[3] || null,
55
+ })
56
+ }
57
+
58
+ // Parse tables
59
+ const lines = content.split('\n')
60
+ let currentTable = null
61
+
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i]
64
+ const trimmed = line.trim()
65
+
66
+ // Create table
67
+ const tableMatch = trimmed.match(SCHEMA_PATTERNS.createTable)
68
+ if (tableMatch) {
69
+ const options = tableMatch[2] || ''
70
+ let pkType = 'bigint'
71
+ let pkAuto = true
72
+
73
+ if (SCHEMA_PATTERNS.idFalse.test(options)) {
74
+ pkType = null
75
+ pkAuto = false
76
+ } else if (SCHEMA_PATTERNS.idUuid.test(options)) {
77
+ pkType = 'uuid'
78
+ } else {
79
+ const idTypeMatch = options.match(SCHEMA_PATTERNS.idType)
80
+ if (idTypeMatch) pkType = idTypeMatch[1]
81
+ }
82
+
83
+ const commentMatch = options.match(SCHEMA_PATTERNS.comment)
84
+
85
+ // Composite primary key detection
86
+ const compositePkMatch = options.match(
87
+ SCHEMA_PATTERNS.compositePrimaryKey,
88
+ )
89
+ if (compositePkMatch) {
90
+ const columns =
91
+ compositePkMatch[1]
92
+ .match(/['":]\w+/g)
93
+ ?.map((c) => c.replace(/['":]/, '')) || []
94
+ currentTable = {
95
+ name: tableMatch[1],
96
+ primary_key: { type: 'composite', columns },
97
+ columns: [],
98
+ indexes: [],
99
+ comment: commentMatch ? commentMatch[1] : null,
100
+ }
101
+ } else {
102
+ currentTable = {
103
+ name: tableMatch[1],
104
+ primary_key: pkType ? { type: pkType, auto: pkAuto } : null,
105
+ columns: [],
106
+ indexes: [],
107
+ comment: commentMatch ? commentMatch[1] : null,
108
+ }
109
+ }
110
+ result.tables.push(currentTable)
111
+ continue
112
+ }
113
+
114
+ if (!currentTable) continue
115
+
116
+ // End of table block
117
+ if (/^\s*end\b/.test(trimmed) && currentTable) {
118
+ currentTable = null
119
+ continue
120
+ }
121
+
122
+ // References/belongs_to
123
+ const refMatch = trimmed.match(SCHEMA_PATTERNS.references)
124
+ if (refMatch) {
125
+ currentTable.columns.push({
126
+ name: refMatch[1] + '_id',
127
+ type: 'references',
128
+ ref_name: refMatch[1],
129
+ constraints: refMatch[2] || null,
130
+ })
131
+ continue
132
+ }
133
+
134
+ // Timestamps
135
+ if (SCHEMA_PATTERNS.timestamps.test(trimmed)) {
136
+ currentTable.columns.push({
137
+ name: 'created_at',
138
+ type: 'datetime',
139
+ constraints: 'null: false',
140
+ })
141
+ currentTable.columns.push({
142
+ name: 'updated_at',
143
+ type: 'datetime',
144
+ constraints: 'null: false',
145
+ })
146
+ continue
147
+ }
148
+
149
+ // Index
150
+ const indexMatch = trimmed.match(SCHEMA_PATTERNS.index)
151
+ if (indexMatch) {
152
+ const columns = indexMatch[1]
153
+ ? indexMatch[1]
154
+ .match(/['"](\w+)['"]/g)
155
+ ?.map((c) => c.replace(/['"]/g, '')) || []
156
+ : [indexMatch[2]]
157
+ const opts = indexMatch[3] || ''
158
+ currentTable.indexes.push({
159
+ columns,
160
+ unique: /unique:\s*true/.test(opts),
161
+ name: opts.match(/name:\s*['"]([^'"]+)['"]/)?.[1] || null,
162
+ })
163
+ continue
164
+ }
165
+
166
+ // Regular column
167
+ const colMatch = trimmed.match(SCHEMA_PATTERNS.column)
168
+ if (colMatch) {
169
+ currentTable.columns.push({
170
+ name: colMatch[2],
171
+ type: colMatch[1],
172
+ constraints: colMatch[3] || null,
173
+ })
174
+ }
175
+ }
176
+
177
+ return result
178
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Stimulus Extractor (#6)
3
+ * Extracts Stimulus controller metadata from JavaScript files.
4
+ */
5
+
6
+ import { STIMULUS_PATTERNS } from '../core/patterns.js'
7
+
8
+ /**
9
+ * Derive the Stimulus identifier from the file path.
10
+ * app/javascript/controllers/dropdown_controller.js → "dropdown"
11
+ * app/javascript/controllers/users/filter_controller.js → "users--filter"
12
+ * @param {string} filePath
13
+ * @returns {string}
14
+ */
15
+ function deriveIdentifier(filePath) {
16
+ const controllersIdx = filePath.indexOf('controllers/')
17
+ if (controllersIdx === -1) return filePath
18
+ const rest = filePath.slice(controllersIdx + 'controllers/'.length)
19
+ return rest
20
+ .replace(/_controller\.\w+$/, '')
21
+ .replace(/\//g, '--')
22
+ .replace(/_/g, '-')
23
+ }
24
+
25
+ const LIFECYCLE_METHODS = new Set(['connect', 'disconnect', 'initialize'])
26
+
27
+ /**
28
+ * Extract Stimulus controller information from a single JS file.
29
+ * @param {import('../providers/interface.js').FileProvider} provider
30
+ * @param {string} filePath
31
+ * @returns {object|null}
32
+ */
33
+ export function extractStimulusController(provider, filePath) {
34
+ const content = provider.readFile(filePath)
35
+ if (!content) return null
36
+
37
+ const classMatch = content.match(STIMULUS_PATTERNS.classDeclaration)
38
+ if (!classMatch) return null
39
+
40
+ const result = {
41
+ identifier: deriveIdentifier(filePath),
42
+ file: filePath,
43
+ targets: [],
44
+ values: {},
45
+ classes: [],
46
+ outlets: [],
47
+ actions: [],
48
+ imports: [],
49
+ }
50
+
51
+ // Targets
52
+ const targetsMatch = content.match(STIMULUS_PATTERNS.targets)
53
+ if (targetsMatch) {
54
+ result.targets =
55
+ targetsMatch[1]
56
+ .match(/['"](\w+)['"]/g)
57
+ ?.map((t) => t.replace(/['"]/g, '')) || []
58
+ }
59
+
60
+ // Values - extract the full block handling nested braces
61
+ const valuesStart = content.match(/static\s+values\s*=\s*\{/)
62
+ if (valuesStart) {
63
+ const startIdx = valuesStart.index + valuesStart[0].length
64
+ let depth = 1
65
+ let endIdx = startIdx
66
+ for (let ci = startIdx; ci < content.length && depth > 0; ci++) {
67
+ if (content[ci] === '{') depth++
68
+ else if (content[ci] === '}') depth--
69
+ if (depth === 0) endIdx = ci
70
+ }
71
+ const valStr = content.slice(startIdx, endIdx)
72
+ // Complex form: key: { type: Type, default: val }
73
+ const complexRe =
74
+ /(\w+):\s*\{\s*type:\s*(\w+)(?:,\s*default:\s*([^,}]+))?\s*\}/g
75
+ let vm
76
+ const processed = new Set()
77
+ while ((vm = complexRe.exec(valStr))) {
78
+ processed.add(vm[1])
79
+ result.values[vm[1]] = {
80
+ type: vm[2],
81
+ default: vm[3]?.trim() || null,
82
+ }
83
+ }
84
+ // Simple form: key: Type
85
+ const simpleRe = /(\w+):\s*(\w+)/g
86
+ while ((vm = simpleRe.exec(valStr))) {
87
+ if (!processed.has(vm[1]) && vm[1] !== 'type' && vm[1] !== 'default') {
88
+ result.values[vm[1]] = { type: vm[2], default: null }
89
+ }
90
+ }
91
+ }
92
+
93
+ // Classes
94
+ const classesMatch = content.match(STIMULUS_PATTERNS.classes)
95
+ if (classesMatch) {
96
+ result.classes =
97
+ classesMatch[1]
98
+ .match(/['"](\w[\w-]*)['"]/g)
99
+ ?.map((c) => c.replace(/['"]/g, '')) || []
100
+ }
101
+
102
+ // Outlets
103
+ const outletsMatch = content.match(STIMULUS_PATTERNS.outlets)
104
+ if (outletsMatch) {
105
+ result.outlets =
106
+ outletsMatch[1]
107
+ .match(/['"](\w[\w-]*)['"]/g)
108
+ ?.map((o) => o.replace(/['"]/g, '')) || []
109
+ }
110
+
111
+ // Actions (methods)
112
+ const actionRe = new RegExp(STIMULUS_PATTERNS.actionMethod.source, 'gm')
113
+ let am
114
+ while ((am = actionRe.exec(content))) {
115
+ const name = am[1]
116
+ if (
117
+ !LIFECYCLE_METHODS.has(name) &&
118
+ !name.endsWith('TargetConnected') &&
119
+ !name.endsWith('TargetDisconnected') &&
120
+ !name.endsWith('ValueChanged')
121
+ ) {
122
+ result.actions.push(name)
123
+ }
124
+ }
125
+
126
+ // Imports
127
+ const importRe = new RegExp(STIMULUS_PATTERNS.imports.source, 'g')
128
+ let im
129
+ while ((im = importRe.exec(content))) {
130
+ result.imports.push(im[3])
131
+ }
132
+
133
+ return result
134
+ }
135
+
136
+ /**
137
+ * Extract all Stimulus controllers from scanned entries.
138
+ * @param {import('../providers/interface.js').FileProvider} provider
139
+ * @param {Array<{path: string}>} entries
140
+ * @returns {Array<object>}
141
+ */
142
+ export function extractStimulusControllers(provider, entries) {
143
+ const controllers = []
144
+ for (const entry of entries) {
145
+ const ctrl = extractStimulusController(provider, entry.path)
146
+ if (ctrl) controllers.push(ctrl)
147
+ }
148
+ return controllers
149
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Storage Extractor (#12)
3
+ * Extracts Active Storage configuration, attachments, and variants.
4
+ */
5
+
6
+ import { STORAGE_PATTERNS } from '../core/patterns.js'
7
+
8
+ /**
9
+ * Extract storage 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 extractStorage(provider, entries, gemInfo = {}) {
16
+ const gems = gemInfo.gems || {}
17
+ const result = {
18
+ services: {},
19
+ attachments: [],
20
+ direct_uploads: false,
21
+ image_processing: null,
22
+ variants_detected: 0,
23
+ }
24
+
25
+ // Storage services from config/storage.yml
26
+ const storageYml = provider.readFile('config/storage.yml')
27
+ if (storageYml) {
28
+ const serviceRe = new RegExp(STORAGE_PATTERNS.storageService.source, 'g')
29
+ let m
30
+ while ((m = serviceRe.exec(storageYml))) {
31
+ result.services[m[1]] = { service: m[2] }
32
+ }
33
+
34
+ // Mirror service
35
+ if (STORAGE_PATTERNS.mirrorService.test(storageYml)) {
36
+ result.services.mirror = { service: 'Mirror' }
37
+ }
38
+
39
+ // Direct uploads
40
+ if (STORAGE_PATTERNS.directUpload.test(storageYml)) {
41
+ result.direct_uploads = true
42
+ }
43
+ }
44
+
45
+ // Attachments from model files
46
+ const modelEntries = entries.filter((e) => e.category === 'model')
47
+ for (const entry of modelEntries) {
48
+ const content = provider.readFile(entry.path)
49
+ if (!content) continue
50
+
51
+ const className = entry.path
52
+ .split('/')
53
+ .pop()
54
+ .replace('.rb', '')
55
+ .split('_')
56
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
57
+ .join('')
58
+
59
+ const oneRe = new RegExp(STORAGE_PATTERNS.hasOneAttached.source, 'gm')
60
+ let m
61
+ while ((m = oneRe.exec(content))) {
62
+ result.attachments.push({
63
+ model: className,
64
+ name: m[1],
65
+ type: 'has_one_attached',
66
+ })
67
+ }
68
+
69
+ const manyRe = new RegExp(STORAGE_PATTERNS.hasManyAttached.source, 'gm')
70
+ while ((m = manyRe.exec(content))) {
71
+ result.attachments.push({
72
+ model: className,
73
+ name: m[1],
74
+ type: 'has_many_attached',
75
+ })
76
+ }
77
+
78
+ // Variants
79
+ const varRe = new RegExp(STORAGE_PATTERNS.variant.source, 'g')
80
+ while (varRe.exec(content)) {
81
+ result.variants_detected++
82
+ }
83
+ }
84
+
85
+ // Image processing
86
+ if (gems.image_processing) {
87
+ result.image_processing = {
88
+ gem: 'image_processing',
89
+ backend: 'mini_magick',
90
+ }
91
+ // Check for vips backend
92
+ const envContent = provider.readFile('config/application.rb') || ''
93
+ const vipsMatch = envContent.match(STORAGE_PATTERNS.variantProcessor)
94
+ if (vipsMatch) {
95
+ result.image_processing.backend = vipsMatch[1]
96
+ }
97
+ }
98
+
99
+ return result
100
+ }