@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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caching Extractor (#13)
|
|
3
|
+
* Extracts cache store config, fragment caching, HTTP caching usage.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CACHING_PATTERNS } from '../core/patterns.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract caching information.
|
|
10
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
11
|
+
* @param {Array<{path: string, category: string}>} entries
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
export function extractCaching(provider, entries) {
|
|
15
|
+
const result = {
|
|
16
|
+
store: {},
|
|
17
|
+
fragment_caching: { usage_count: 0, russian_doll_detected: false },
|
|
18
|
+
low_level_caching: { rails_cache_fetch_count: 0 },
|
|
19
|
+
http_caching: { stale_usage: 0, fresh_when_usage: 0, expires_in_usage: 0 },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Cache store per environment
|
|
23
|
+
for (const env of ['production', 'development', 'test']) {
|
|
24
|
+
const content = provider.readFile(`config/environments/${env}.rb`)
|
|
25
|
+
if (content) {
|
|
26
|
+
const storeMatch = content.match(CACHING_PATTERNS.cacheStore)
|
|
27
|
+
if (storeMatch) {
|
|
28
|
+
result.store[env] = storeMatch[1]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Scan views for fragment caching
|
|
34
|
+
const viewEntries = entries.filter(
|
|
35
|
+
(e) =>
|
|
36
|
+
e.path.startsWith('app/views/') || e.path.startsWith('app/components/'),
|
|
37
|
+
)
|
|
38
|
+
for (const entry of viewEntries) {
|
|
39
|
+
const content = provider.readFile(entry.path)
|
|
40
|
+
if (!content) continue
|
|
41
|
+
|
|
42
|
+
const fragRe = new RegExp(CACHING_PATTERNS.fragmentCache.source, 'g')
|
|
43
|
+
let m
|
|
44
|
+
while ((m = fragRe.exec(content))) {
|
|
45
|
+
result.fragment_caching.usage_count++
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Russian doll detection
|
|
49
|
+
const rdRe = new RegExp(CACHING_PATTERNS.russianDoll.source, 'g')
|
|
50
|
+
if (rdRe.test(content)) {
|
|
51
|
+
result.fragment_caching.russian_doll_detected = true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Scan Ruby files for Rails.cache usage
|
|
56
|
+
const rbEntries = entries.filter((e) => e.path.endsWith('.rb'))
|
|
57
|
+
for (const entry of rbEntries) {
|
|
58
|
+
const content = provider.readFile(entry.path)
|
|
59
|
+
if (!content) continue
|
|
60
|
+
|
|
61
|
+
const fetchRe = new RegExp(CACHING_PATTERNS.railsCacheFetch.source, 'g')
|
|
62
|
+
while (fetchRe.exec(content)) {
|
|
63
|
+
result.low_level_caching.rails_cache_fetch_count++
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// HTTP caching
|
|
67
|
+
const staleRe = new RegExp(CACHING_PATTERNS.stale.source, 'g')
|
|
68
|
+
while (staleRe.exec(content)) {
|
|
69
|
+
result.http_caching.stale_usage++
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const freshRe = new RegExp(CACHING_PATTERNS.freshWhen.source, 'g')
|
|
73
|
+
while (freshRe.exec(content)) {
|
|
74
|
+
result.http_caching.fresh_when_usage++
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const expiresRe = new RegExp(CACHING_PATTERNS.expiresIn.source, 'g')
|
|
78
|
+
while (expiresRe.exec(content)) {
|
|
79
|
+
result.http_caching.expires_in_usage++
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component Extractor (#5)
|
|
3
|
+
* Extracts ViewComponent metadata from Ruby class files and sidecar templates.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { COMPONENT_PATTERNS } from '../core/patterns.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Determine component tier from class name / path.
|
|
10
|
+
* @param {string} className
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function detectTier(className) {
|
|
14
|
+
const lower = className.toLowerCase()
|
|
15
|
+
if (/^ui::/.test(lower) || /ui_component/.test(lower)) return 'ui'
|
|
16
|
+
if (/^layout/.test(lower) || /layout_component/.test(lower)) return 'layout'
|
|
17
|
+
if (/^page/.test(lower) || /page_component/.test(lower)) return 'page'
|
|
18
|
+
return 'feature'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse initialize params from the param string.
|
|
23
|
+
* @param {string} paramStr
|
|
24
|
+
* @returns {Array<{name: string, type: string, default: string|null, required: boolean}>}
|
|
25
|
+
*/
|
|
26
|
+
function parseInitializeParams(paramStr) {
|
|
27
|
+
const params = []
|
|
28
|
+
// Split by comma, handling nested defaults like [] and {}
|
|
29
|
+
const parts = paramStr.split(/,(?![^{[]*[}\]])/)
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
const trimmed = part.trim()
|
|
32
|
+
if (!trimmed) continue
|
|
33
|
+
|
|
34
|
+
// Keyword arg: name: default or name:
|
|
35
|
+
const kwMatch = trimmed.match(/^(\w+):\s*(.+)?$/)
|
|
36
|
+
if (kwMatch) {
|
|
37
|
+
const name = kwMatch[1]
|
|
38
|
+
const defaultVal = kwMatch[2]?.trim() || null
|
|
39
|
+
params.push({
|
|
40
|
+
name,
|
|
41
|
+
type: 'keyword',
|
|
42
|
+
default: defaultVal,
|
|
43
|
+
required: defaultVal === null,
|
|
44
|
+
})
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Positional arg
|
|
49
|
+
const posMatch = trimmed.match(/^(\w+)$/)
|
|
50
|
+
if (posMatch) {
|
|
51
|
+
params.push({
|
|
52
|
+
name: posMatch[1],
|
|
53
|
+
type: 'positional',
|
|
54
|
+
default: null,
|
|
55
|
+
required: true,
|
|
56
|
+
})
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Positional with default: name = value
|
|
61
|
+
const posDefMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/)
|
|
62
|
+
if (posDefMatch) {
|
|
63
|
+
params.push({
|
|
64
|
+
name: posDefMatch[1],
|
|
65
|
+
type: 'positional',
|
|
66
|
+
default: posDefMatch[2].trim(),
|
|
67
|
+
required: false,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return params
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract component information from a single file.
|
|
76
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
77
|
+
* @param {string} filePath
|
|
78
|
+
* @returns {object|null}
|
|
79
|
+
*/
|
|
80
|
+
export function extractComponent(provider, filePath) {
|
|
81
|
+
const content = provider.readFile(filePath)
|
|
82
|
+
if (!content) return null
|
|
83
|
+
|
|
84
|
+
const classMatch = content.match(COMPONENT_PATTERNS.classDeclaration)
|
|
85
|
+
if (!classMatch) return null
|
|
86
|
+
|
|
87
|
+
const className = classMatch[1]
|
|
88
|
+
const superclass = classMatch[2]
|
|
89
|
+
|
|
90
|
+
const result = {
|
|
91
|
+
class: className,
|
|
92
|
+
file: filePath,
|
|
93
|
+
superclass,
|
|
94
|
+
tier: detectTier(className),
|
|
95
|
+
initialize_params: [],
|
|
96
|
+
slots: { renders_one: [], renders_many: [] },
|
|
97
|
+
collection_parameter: null,
|
|
98
|
+
stimulus_controllers: [],
|
|
99
|
+
turbo_frames: [],
|
|
100
|
+
child_components: [],
|
|
101
|
+
uses_partials: false,
|
|
102
|
+
sidecar_template: null,
|
|
103
|
+
preview: null,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Initialize params
|
|
107
|
+
const initMatch = content.match(COMPONENT_PATTERNS.initialize)
|
|
108
|
+
if (initMatch) {
|
|
109
|
+
result.initialize_params = parseInitializeParams(initMatch[1])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Slots - renders_one
|
|
113
|
+
const rendersOneRe = new RegExp(COMPONENT_PATTERNS.rendersOne.source, 'gm')
|
|
114
|
+
let m
|
|
115
|
+
while ((m = rendersOneRe.exec(content))) {
|
|
116
|
+
result.slots.renders_one.push(m[1])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Slots - renders_many
|
|
120
|
+
const rendersManyRe = new RegExp(COMPONENT_PATTERNS.rendersMany.source, 'gm')
|
|
121
|
+
while ((m = rendersManyRe.exec(content))) {
|
|
122
|
+
result.slots.renders_many.push(m[1])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Collection parameter
|
|
126
|
+
const collMatch = content.match(COMPONENT_PATTERNS.collectionParam)
|
|
127
|
+
if (collMatch) {
|
|
128
|
+
result.collection_parameter = collMatch[1]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Sidecar template detection
|
|
132
|
+
const baseName = filePath.replace(/\.rb$/, '')
|
|
133
|
+
const templateCandidates = [
|
|
134
|
+
baseName + '.html.erb',
|
|
135
|
+
baseName + '.html.haml',
|
|
136
|
+
baseName + '.html.slim',
|
|
137
|
+
]
|
|
138
|
+
for (const candidate of templateCandidates) {
|
|
139
|
+
const templateContent = provider.readFile(candidate)
|
|
140
|
+
if (templateContent) {
|
|
141
|
+
result.sidecar_template = candidate
|
|
142
|
+
analyzeTemplate(templateContent, result)
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Also check sidecar directory style: component_name/component_name.html.erb
|
|
148
|
+
if (!result.sidecar_template) {
|
|
149
|
+
const parts = filePath.split('/')
|
|
150
|
+
const fileName = parts[parts.length - 1].replace(/\.rb$/, '')
|
|
151
|
+
const dirPath = filePath.replace(/\.rb$/, '')
|
|
152
|
+
const sidecarCandidates = [
|
|
153
|
+
dirPath + '/' + fileName + '.html.erb',
|
|
154
|
+
dirPath + '/' + fileName + '.html.haml',
|
|
155
|
+
]
|
|
156
|
+
for (const candidate of sidecarCandidates) {
|
|
157
|
+
const templateContent = provider.readFile(candidate)
|
|
158
|
+
if (templateContent) {
|
|
159
|
+
result.sidecar_template = candidate
|
|
160
|
+
analyzeTemplate(templateContent, result)
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Analyze a sidecar template for Stimulus, Turbo, child components.
|
|
171
|
+
* @param {string} content
|
|
172
|
+
* @param {object} result
|
|
173
|
+
*/
|
|
174
|
+
function analyzeTemplate(content, result) {
|
|
175
|
+
// Stimulus controllers
|
|
176
|
+
const ctrlRe = new RegExp(COMPONENT_PATTERNS.stimulusController.source, 'g')
|
|
177
|
+
let m
|
|
178
|
+
while ((m = ctrlRe.exec(content))) {
|
|
179
|
+
const controllers = m[1].split(/\s+/)
|
|
180
|
+
for (const c of controllers) {
|
|
181
|
+
if (!result.stimulus_controllers.includes(c)) {
|
|
182
|
+
result.stimulus_controllers.push(c)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Turbo frames
|
|
188
|
+
const frameRe = new RegExp(COMPONENT_PATTERNS.turboFrame.source, 'g')
|
|
189
|
+
while ((m = frameRe.exec(content))) {
|
|
190
|
+
result.turbo_frames.push(m[1])
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Child components
|
|
194
|
+
const compRe = new RegExp(COMPONENT_PATTERNS.componentRender.source, 'g')
|
|
195
|
+
while ((m = compRe.exec(content))) {
|
|
196
|
+
if (!result.child_components.includes(m[1])) {
|
|
197
|
+
result.child_components.push(m[1])
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Partial usage
|
|
202
|
+
const partialRe = new RegExp(COMPONENT_PATTERNS.partialRender.source, 'g')
|
|
203
|
+
if (partialRe.test(content)) {
|
|
204
|
+
result.uses_partials = true
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Extract all components from scanned entries.
|
|
210
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
211
|
+
* @param {Array<{path: string}>} entries - entries with category 'component'
|
|
212
|
+
* @returns {Array<object>}
|
|
213
|
+
*/
|
|
214
|
+
export function extractComponents(provider, entries) {
|
|
215
|
+
const components = []
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
const comp = extractComponent(provider, entry.path)
|
|
218
|
+
if (comp) components.push(comp)
|
|
219
|
+
}
|
|
220
|
+
return components
|
|
221
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Extractor (#17)
|
|
3
|
+
* Extracts Rails application configuration from config files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CONFIG_PATTERNS } from '../core/patterns.js'
|
|
7
|
+
import { parseYaml } from '../utils/yaml-parser.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract config information.
|
|
11
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
export function extractConfig(provider) {
|
|
15
|
+
const result = {
|
|
16
|
+
load_defaults: null,
|
|
17
|
+
api_only: false,
|
|
18
|
+
time_zone: null,
|
|
19
|
+
queue_adapter: null,
|
|
20
|
+
database: {},
|
|
21
|
+
environments: {},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// config/application.rb
|
|
25
|
+
const appContent = provider.readFile('config/application.rb')
|
|
26
|
+
if (appContent) {
|
|
27
|
+
const ldMatch = appContent.match(CONFIG_PATTERNS.loadDefaults)
|
|
28
|
+
if (ldMatch) result.load_defaults = ldMatch[1]
|
|
29
|
+
|
|
30
|
+
if (CONFIG_PATTERNS.apiOnly.test(appContent)) result.api_only = true
|
|
31
|
+
|
|
32
|
+
const tzMatch = appContent.match(CONFIG_PATTERNS.timeZone)
|
|
33
|
+
if (tzMatch) result.time_zone = tzMatch[1]
|
|
34
|
+
|
|
35
|
+
const qaMatch = appContent.match(CONFIG_PATTERNS.queueAdapter)
|
|
36
|
+
if (qaMatch) result.queue_adapter = qaMatch[1]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// config/database.yml
|
|
40
|
+
const dbContent = provider.readFile('config/database.yml')
|
|
41
|
+
if (dbContent) {
|
|
42
|
+
const parsed = parseYaml(dbContent)
|
|
43
|
+
// Extract production adapter
|
|
44
|
+
if (parsed.production) {
|
|
45
|
+
result.database.adapter = parsed.production.adapter || null
|
|
46
|
+
result.database.pool = parsed.production.pool || null
|
|
47
|
+
} else if (parsed.default) {
|
|
48
|
+
result.database.adapter = parsed.default.adapter || null
|
|
49
|
+
result.database.pool = parsed.default.pool || null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Multi-DB detection: check for primary/secondary or multiple named DBs under production
|
|
53
|
+
const prodSection = parsed.production || {}
|
|
54
|
+
const prodKeys = Object.keys(prodSection)
|
|
55
|
+
const subDbs = prodKeys.filter(
|
|
56
|
+
(k) => typeof prodSection[k] === 'object' && prodSection[k] !== null,
|
|
57
|
+
)
|
|
58
|
+
if (subDbs.length > 1) {
|
|
59
|
+
result.database.multi_db = true
|
|
60
|
+
result.database.databases = subDbs
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// config/environments/*.rb
|
|
65
|
+
for (const env of ['production', 'development', 'test']) {
|
|
66
|
+
const content = provider.readFile(`config/environments/${env}.rb`)
|
|
67
|
+
if (!content) continue
|
|
68
|
+
|
|
69
|
+
const envConfig = {}
|
|
70
|
+
const csMatch = content.match(CONFIG_PATTERNS.cacheStore)
|
|
71
|
+
if (csMatch) envConfig.cache_store = csMatch[1]
|
|
72
|
+
|
|
73
|
+
if (CONFIG_PATTERNS.forceSSL.test(content)) envConfig.force_ssl = true
|
|
74
|
+
|
|
75
|
+
if (Object.keys(envConfig).length > 0) {
|
|
76
|
+
result.environments[env] = envConfig
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controller Extractor (#2)
|
|
3
|
+
* Extracts all controller patterns from Ruby controller files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CONTROLLER_PATTERNS } from '../core/patterns.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract all controller information from a single controller file.
|
|
10
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
11
|
+
* @param {string} filePath
|
|
12
|
+
* @returns {object|null}
|
|
13
|
+
*/
|
|
14
|
+
export function extractController(provider, filePath) {
|
|
15
|
+
const content = provider.readFile(filePath)
|
|
16
|
+
if (!content) return null
|
|
17
|
+
|
|
18
|
+
// Class declaration
|
|
19
|
+
const classMatch = content.match(CONTROLLER_PATTERNS.classDeclaration)
|
|
20
|
+
const className = classMatch ? classMatch[1] : null
|
|
21
|
+
const superclass = classMatch ? classMatch[2] : null
|
|
22
|
+
|
|
23
|
+
// Derive namespace from class name
|
|
24
|
+
let namespace = null
|
|
25
|
+
if (className && className.includes('::')) {
|
|
26
|
+
const parts = className.split('::')
|
|
27
|
+
parts.pop() // Remove controller name
|
|
28
|
+
namespace = parts.join('/').toLowerCase().replace(/::/g, '/')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Concerns
|
|
32
|
+
const concerns = []
|
|
33
|
+
const includeRe = new RegExp(CONTROLLER_PATTERNS.include.source, 'gm')
|
|
34
|
+
let m
|
|
35
|
+
while ((m = includeRe.exec(content))) {
|
|
36
|
+
const mod = m[1]
|
|
37
|
+
if (mod !== 'ActionController::Live') {
|
|
38
|
+
concerns.push(mod)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Filters — tag authorization guards
|
|
43
|
+
const filters = []
|
|
44
|
+
const filterRe = new RegExp(CONTROLLER_PATTERNS.filterType.source, 'gm')
|
|
45
|
+
while ((m = filterRe.exec(content))) {
|
|
46
|
+
const filterMethod = m[2]
|
|
47
|
+
const isAuthzGuard = /^require_\w+!$/.test(filterMethod)
|
|
48
|
+
filters.push({
|
|
49
|
+
type: m[1],
|
|
50
|
+
method: filterMethod,
|
|
51
|
+
...(isAuthzGuard ? { authorization_guard: true } : {}),
|
|
52
|
+
options: m[3] || null,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Actions (public methods before private/protected) with line ranges
|
|
57
|
+
const actions = []
|
|
58
|
+
const action_line_ranges = {}
|
|
59
|
+
const lines = content.split('\n')
|
|
60
|
+
let inPublic = true
|
|
61
|
+
let currentActionName = null
|
|
62
|
+
let currentActionStart = null
|
|
63
|
+
let methodDepth = 0
|
|
64
|
+
const visRe = /^\s*(private|protected)\s*$/
|
|
65
|
+
const methodRe = /^\s*def\s+(\w+)/
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i]
|
|
68
|
+
const lineNumber = i + 1
|
|
69
|
+
|
|
70
|
+
if (visRe.test(line)) {
|
|
71
|
+
// Close current action if open
|
|
72
|
+
if (currentActionName && inPublic) {
|
|
73
|
+
action_line_ranges[currentActionName] = {
|
|
74
|
+
start: currentActionStart,
|
|
75
|
+
end: lineNumber - 1,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
inPublic = false
|
|
79
|
+
currentActionName = null
|
|
80
|
+
methodDepth = 0
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mm = line.match(methodRe)
|
|
85
|
+
if (mm) {
|
|
86
|
+
// Close previous action
|
|
87
|
+
if (currentActionName && inPublic) {
|
|
88
|
+
action_line_ranges[currentActionName] = {
|
|
89
|
+
start: currentActionStart,
|
|
90
|
+
end: lineNumber - 1,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (inPublic) {
|
|
95
|
+
actions.push(mm[1])
|
|
96
|
+
currentActionName = mm[1]
|
|
97
|
+
currentActionStart = lineNumber
|
|
98
|
+
methodDepth = 1
|
|
99
|
+
} else {
|
|
100
|
+
currentActionName = null
|
|
101
|
+
}
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (currentActionName && inPublic) {
|
|
106
|
+
if (
|
|
107
|
+
/\bdo\b|\bif\b(?!.*\bthen\b.*\bend\b)|\bcase\b|\bbegin\b/.test(line) &&
|
|
108
|
+
!/\bend\b/.test(line)
|
|
109
|
+
) {
|
|
110
|
+
methodDepth++
|
|
111
|
+
}
|
|
112
|
+
if (/^\s*end\b/.test(line)) {
|
|
113
|
+
methodDepth--
|
|
114
|
+
if (methodDepth <= 0) {
|
|
115
|
+
action_line_ranges[currentActionName] = {
|
|
116
|
+
start: currentActionStart,
|
|
117
|
+
end: lineNumber,
|
|
118
|
+
}
|
|
119
|
+
currentActionName = null
|
|
120
|
+
methodDepth = 0
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Close final action
|
|
127
|
+
if (currentActionName && inPublic) {
|
|
128
|
+
action_line_ranges[currentActionName] = {
|
|
129
|
+
start: currentActionStart,
|
|
130
|
+
end: lines.length,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Strong params
|
|
135
|
+
let strong_params = null
|
|
136
|
+
const spMatch = content.match(CONTROLLER_PATTERNS.paramsRequire)
|
|
137
|
+
if (spMatch) {
|
|
138
|
+
const methodMatch = content.match(CONTROLLER_PATTERNS.strongParamsMethod)
|
|
139
|
+
strong_params = {
|
|
140
|
+
method: methodMatch ? methodMatch[1] : null,
|
|
141
|
+
model: spMatch[1],
|
|
142
|
+
permitted: spMatch[2].split(',').map((p) => p.trim()),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Rescue handlers
|
|
147
|
+
const rescue_handlers = []
|
|
148
|
+
const rescueRe = new RegExp(CONTROLLER_PATTERNS.rescueFrom.source, 'gm')
|
|
149
|
+
while ((m = rescueRe.exec(content))) {
|
|
150
|
+
rescue_handlers.push({
|
|
151
|
+
exception: m[1],
|
|
152
|
+
handler: m[2] || null,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Layout
|
|
157
|
+
const layoutMatch = content.match(CONTROLLER_PATTERNS.layout)
|
|
158
|
+
const layout = layoutMatch ? layoutMatch[1] : null
|
|
159
|
+
|
|
160
|
+
// API controller detection
|
|
161
|
+
const api_controller =
|
|
162
|
+
CONTROLLER_PATTERNS.skipForgeryProtection.test(content) ||
|
|
163
|
+
/protect_from_forgery\s+with:\s*:null_session/.test(content) ||
|
|
164
|
+
(superclass && /Api|API/.test(superclass)) ||
|
|
165
|
+
(className && /Api::/.test(className))
|
|
166
|
+
|
|
167
|
+
// Streaming
|
|
168
|
+
const streaming = CONTROLLER_PATTERNS.actionControllerLive.test(content)
|
|
169
|
+
|
|
170
|
+
// Rails 8: rate_limit declarations
|
|
171
|
+
const rate_limits = []
|
|
172
|
+
const rateLimitRe =
|
|
173
|
+
/rate_limit\s+to:\s*(\d+),\s*within:\s*([^,\n]+?)(?:,\s*only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?$/gm
|
|
174
|
+
let rl
|
|
175
|
+
while ((rl = rateLimitRe.exec(content))) {
|
|
176
|
+
rate_limits.push({
|
|
177
|
+
to: parseInt(rl[1], 10),
|
|
178
|
+
within: rl[2].trim(),
|
|
179
|
+
only: rl[3] || rl[4] || rl[5] || null,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Rails 8: allow_unauthenticated_access
|
|
184
|
+
const unauthedMatch = content.match(
|
|
185
|
+
/allow_unauthenticated_access(?:\s+only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?/,
|
|
186
|
+
)
|
|
187
|
+
const allow_unauthenticated_access = unauthedMatch
|
|
188
|
+
? {
|
|
189
|
+
only: (unauthedMatch[1] || unauthedMatch[2] || unauthedMatch[3] || '')
|
|
190
|
+
.replace(/[:%\s]/g, ' ')
|
|
191
|
+
.trim()
|
|
192
|
+
.split(/\s+/)
|
|
193
|
+
.filter(Boolean),
|
|
194
|
+
}
|
|
195
|
+
: null
|
|
196
|
+
|
|
197
|
+
// Action key logic summaries — connected flow chain per action
|
|
198
|
+
const action_summaries = {}
|
|
199
|
+
for (const action of actions) {
|
|
200
|
+
const actionLines = content.split('\n')
|
|
201
|
+
let inAction = false
|
|
202
|
+
let depth = 0
|
|
203
|
+
const keyCalls = []
|
|
204
|
+
for (const line of actionLines) {
|
|
205
|
+
if (new RegExp(`^\\s*def\\s+${action}\\b`).test(line)) {
|
|
206
|
+
inAction = true
|
|
207
|
+
depth = 0
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (!inAction) continue
|
|
211
|
+
if (/^\s*def\s+\w+/.test(line) && depth === 0) break
|
|
212
|
+
if (/\bdo\b|\bif\b|\bcase\b|\bbegin\b|\bblock\b/.test(line)) depth++
|
|
213
|
+
if (/^\s*end\b/.test(line)) {
|
|
214
|
+
if (depth === 0) break
|
|
215
|
+
depth--
|
|
216
|
+
}
|
|
217
|
+
const trimmed = line.trim()
|
|
218
|
+
// Capture all significant model calls, session helpers, and outcome calls
|
|
219
|
+
if (
|
|
220
|
+
/^(redirect_to|render\s|head\s|respond_to|@\w+\s*=\s*\w+[\.\w]+|User\.[a-z]|Session\.[a-z]|\w+\.(authenticate|find|create)|start_new_session|terminate_session|format\.)/.test(
|
|
221
|
+
trimmed,
|
|
222
|
+
)
|
|
223
|
+
) {
|
|
224
|
+
// Collapse complex expressions to a short label
|
|
225
|
+
const label = trimmed
|
|
226
|
+
.replace(/\s+/g, ' ')
|
|
227
|
+
.replace(
|
|
228
|
+
/(redirect_to\s+)(root_path|after_authentication_url|new_session_path)\b.*/,
|
|
229
|
+
'$1$2',
|
|
230
|
+
)
|
|
231
|
+
.slice(0, 80)
|
|
232
|
+
keyCalls.push(label)
|
|
233
|
+
if (keyCalls.length >= 4) break
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (keyCalls.length > 0) action_summaries[action] = keyCalls.join(' → ')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
class: className,
|
|
241
|
+
file: filePath,
|
|
242
|
+
superclass,
|
|
243
|
+
namespace,
|
|
244
|
+
concerns,
|
|
245
|
+
filters,
|
|
246
|
+
actions,
|
|
247
|
+
action_line_ranges,
|
|
248
|
+
action_summaries:
|
|
249
|
+
Object.keys(action_summaries).length > 0 ? action_summaries : null,
|
|
250
|
+
strong_params,
|
|
251
|
+
rescue_handlers,
|
|
252
|
+
layout,
|
|
253
|
+
api_controller: !!api_controller,
|
|
254
|
+
streaming,
|
|
255
|
+
rate_limits: rate_limits.length > 0 ? rate_limits : null,
|
|
256
|
+
allow_unauthenticated_access,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Extract all controllers from a manifest.
|
|
262
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
263
|
+
* @param {Array<{path: string}>} controllerEntries
|
|
264
|
+
* @returns {Array<object>}
|
|
265
|
+
*/
|
|
266
|
+
export function extractControllers(provider, controllerEntries) {
|
|
267
|
+
const results = []
|
|
268
|
+
for (const entry of controllerEntries) {
|
|
269
|
+
const ctrl = extractController(provider, entry.path)
|
|
270
|
+
if (ctrl) results.push(ctrl)
|
|
271
|
+
}
|
|
272
|
+
return results
|
|
273
|
+
}
|