@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,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
+ }