@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.
- package/LICENSE +15 -0
- package/README.md +210 -0
- package/bin/railsinsight.js +128 -0
- package/package.json +62 -0
- package/src/core/blast-radius.js +496 -0
- package/src/core/constants.js +39 -0
- package/src/core/context-loader.js +227 -0
- package/src/core/drift-detector.js +168 -0
- package/src/core/formatter.js +197 -0
- package/src/core/graph.js +510 -0
- package/src/core/indexer.js +595 -0
- package/src/core/patterns/api.js +27 -0
- package/src/core/patterns/auth.js +25 -0
- package/src/core/patterns/authorization.js +24 -0
- package/src/core/patterns/caching.js +19 -0
- package/src/core/patterns/component.js +18 -0
- package/src/core/patterns/config.js +15 -0
- package/src/core/patterns/controller.js +42 -0
- package/src/core/patterns/email.js +20 -0
- package/src/core/patterns/factory.js +31 -0
- package/src/core/patterns/gemfile.js +9 -0
- package/src/core/patterns/helper.js +10 -0
- package/src/core/patterns/job.js +12 -0
- package/src/core/patterns/model.js +123 -0
- package/src/core/patterns/realtime.js +17 -0
- package/src/core/patterns/route.js +27 -0
- package/src/core/patterns/schema.js +25 -0
- package/src/core/patterns/stimulus.js +13 -0
- package/src/core/patterns/storage.js +16 -0
- package/src/core/patterns/uploader.js +16 -0
- package/src/core/patterns/view.js +20 -0
- package/src/core/patterns/worker.js +12 -0
- package/src/core/patterns.js +27 -0
- package/src/core/scanner.js +394 -0
- package/src/core/version-detector.js +295 -0
- package/src/extractors/api.js +284 -0
- package/src/extractors/auth.js +853 -0
- package/src/extractors/authorization.js +785 -0
- package/src/extractors/caching.js +84 -0
- package/src/extractors/component.js +221 -0
- package/src/extractors/config.js +81 -0
- package/src/extractors/controller.js +273 -0
- package/src/extractors/coverage-snapshot.js +296 -0
- package/src/extractors/email.js +123 -0
- package/src/extractors/factory-registry.js +225 -0
- package/src/extractors/gemfile.js +440 -0
- package/src/extractors/helper.js +55 -0
- package/src/extractors/jobs.js +122 -0
- package/src/extractors/model.js +506 -0
- package/src/extractors/realtime.js +102 -0
- package/src/extractors/routes.js +251 -0
- package/src/extractors/schema.js +178 -0
- package/src/extractors/stimulus.js +149 -0
- package/src/extractors/storage.js +100 -0
- package/src/extractors/test-conventions.js +340 -0
- package/src/extractors/tier2.js +417 -0
- package/src/extractors/tier3.js +84 -0
- package/src/extractors/uploader.js +138 -0
- package/src/extractors/views.js +131 -0
- package/src/extractors/worker.js +62 -0
- package/src/git/diff-parser.js +132 -0
- package/src/providers/interface.js +12 -0
- package/src/providers/local-fs.js +318 -0
- package/src/server.js +71 -0
- package/src/tools/blast-radius-tools.js +129 -0
- package/src/tools/free-tools.js +44 -0
- package/src/tools/handlers/get-controller.js +93 -0
- package/src/tools/handlers/get-coverage-gaps.js +100 -0
- package/src/tools/handlers/get-deep-analysis.js +294 -0
- package/src/tools/handlers/get-domain-clusters.js +113 -0
- package/src/tools/handlers/get-factory-registry.js +43 -0
- package/src/tools/handlers/get-full-index.js +28 -0
- package/src/tools/handlers/get-model.js +108 -0
- package/src/tools/handlers/get-overview.js +153 -0
- package/src/tools/handlers/get-routes.js +18 -0
- package/src/tools/handlers/get-schema.js +40 -0
- package/src/tools/handlers/get-subgraph.js +82 -0
- package/src/tools/handlers/get-test-conventions.js +18 -0
- package/src/tools/handlers/get-well-tested-examples.js +51 -0
- package/src/tools/handlers/helpers.js +115 -0
- package/src/tools/handlers/index-project.js +36 -0
- package/src/tools/handlers/search-patterns.js +104 -0
- package/src/tools/index.js +34 -0
- package/src/tools/pro-tools.js +13 -0
- package/src/utils/file-reader.js +20 -0
- package/src/utils/inflector.js +223 -0
- package/src/utils/ruby-parser.js +115 -0
- package/src/utils/spec-style-detector.js +26 -0
- package/src/utils/token-counter.js +46 -0
- 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
|
+
}
|