@reinteractive/rails-insight 1.0.11 → 1.0.12
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/package.json +2 -2
- package/src/core/blast-radius.js +1 -1
- package/src/core/indexer.js +5 -2
- package/src/core/patterns/authorization.js +1 -1
- package/src/core/patterns/gemfile.js +1 -1
- package/src/core/patterns/model.js +1 -0
- package/src/core/patterns/realtime.js +1 -1
- package/src/core/version-detector.js +4 -2
- package/src/extractors/auth.js +33 -1
- package/src/extractors/authorization.js +20 -1
- package/src/extractors/caching.js +3 -1
- package/src/extractors/config.js +4 -2
- package/src/extractors/model.js +31 -19
- package/src/extractors/realtime.js +5 -10
- package/src/extractors/routes.js +23 -5
- package/src/extractors/storage.js +34 -4
- package/src/extractors/views.js +8 -9
- package/src/tools/handlers/get-deep-analysis.js +13 -2
- package/src/tools/handlers/get-overview.js +7 -6
- package/src/tools/handlers/get-subgraph.js +97 -32
- package/src/tools/handlers/index-project.js +1 -0
- package/src/tools/handlers/search-patterns.js +5 -1
- package/src/utils/ruby-parser.js +13 -0
- package/src/utils/spec-style-detector.js +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reinteractive/rails-insight",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Rails-aware codebase indexer — MCP server for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,4 +59,4 @@
|
|
|
59
59
|
"engines": {
|
|
60
60
|
"node": ">=18.0.0"
|
|
61
61
|
}
|
|
62
|
-
}
|
|
62
|
+
}
|
package/src/core/blast-radius.js
CHANGED
|
@@ -483,7 +483,7 @@ function formatControllerSummary(name, controller) {
|
|
|
483
483
|
const actionCount = (controller.actions || []).length
|
|
484
484
|
if (actionCount > 0) parts.push(`${actionCount} actions`)
|
|
485
485
|
const filters = controller.before_actions || controller.filters || []
|
|
486
|
-
if (filters.length > 0) parts.push(filters.map((f) => f.name || f).join(', '))
|
|
486
|
+
if (filters.length > 0) parts.push(filters.map((f) => f.method || f.name || JSON.stringify(f)).join(', '))
|
|
487
487
|
return parts.join(' — ')
|
|
488
488
|
}
|
|
489
489
|
|
package/src/core/indexer.js
CHANGED
|
@@ -232,7 +232,8 @@ export async function buildIndex(provider, options = {}) {
|
|
|
232
232
|
extractionErrors,
|
|
233
233
|
)
|
|
234
234
|
if (ctrl) {
|
|
235
|
-
|
|
235
|
+
// Use the controller's own fully-qualified class name to avoid namespace collisions
|
|
236
|
+
const name = ctrl.class || pathToClassName(entry.path)
|
|
236
237
|
extractions.controllers[name] = ctrl
|
|
237
238
|
}
|
|
238
239
|
} else if (entry.categoryName === 'components') {
|
|
@@ -581,7 +582,9 @@ function computeStatistics(manifest, extractions, relationships) {
|
|
|
581
582
|
const entries = manifest.entries || []
|
|
582
583
|
return {
|
|
583
584
|
total_files: entries.length,
|
|
584
|
-
models: Object.
|
|
585
|
+
models: Object.values(extractions.models || {}).filter(
|
|
586
|
+
(m) => m.type !== 'concern' && !m.abstract,
|
|
587
|
+
).length,
|
|
585
588
|
controllers: Object.keys(extractions.controllers || {}).length,
|
|
586
589
|
components: Object.keys(extractions.components || {}).length,
|
|
587
590
|
relationships: relationships.length,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export const AUTHORIZATION_PATTERNS = {
|
|
5
5
|
// Pundit
|
|
6
6
|
policyClass: /class\s+(\w+)Policy\s*<\s*(\w+)/,
|
|
7
|
-
policyMethod: /def\s+(
|
|
7
|
+
policyMethod: /def\s+(\w+)\?/g,
|
|
8
8
|
policyScopeClass: /class\s+Scope\s*<\s*(?:ApplicationPolicy::)?Scope/,
|
|
9
9
|
policyScopeResolve: /def\s+resolve/,
|
|
10
10
|
authorize: /authorize\s+(@?\w+)(?:,\s*:(\w+)\?)?/g,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Regex patterns for Gemfile extraction.
|
|
3
3
|
*/
|
|
4
4
|
export const GEMFILE_PATTERNS = {
|
|
5
|
-
gem: /^\s*gem\s+['"]([^'"]+)['"](?:,\s*['"]([^'"]+)['"])?(?:,\s*(
|
|
5
|
+
gem: /^\s*gem\s+['"]([^'"]+)['"](?:,\s*['"]([^'"]+)['"])?(?:,\s*([^#]+?))?(?:\s*#.*)?$/m,
|
|
6
6
|
group: /^\s*group\s+(.+)\s+do/m,
|
|
7
7
|
source: /^\s*source\s+['"]([^'"]+)['"]/m,
|
|
8
8
|
ruby: /^\s*ruby\s+['"]([^'"]+)['"]/m,
|
|
@@ -21,6 +21,7 @@ export const MODEL_PATTERNS = {
|
|
|
21
21
|
// === VALIDATIONS ===
|
|
22
22
|
validates: /^\s*validates?\s+:?(\w+(?:,\s*:\w+)*)(?:,\s*(.+))?$/m,
|
|
23
23
|
validate: /^\s*validate\s+:(\w+)/m,
|
|
24
|
+
validatesWithValidator: /^\s*validates_with\s+(\S+)(?:,\s*(.+))?$/m,
|
|
24
25
|
|
|
25
26
|
// === SCOPES ===
|
|
26
27
|
scope: /^\s*scope\s+:(\w+),\s*(?:->|lambda|proc)/m,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export const REALTIME_PATTERNS = {
|
|
5
5
|
channelClass: /class\s+(\w+Channel)\s*<\s*(\w+)/,
|
|
6
6
|
subscribed: /def\s+subscribed/,
|
|
7
|
-
streamFrom: /stream_from\s+['"]
|
|
7
|
+
streamFrom: /stream_from\s+(?:['"]([^'"]+)['"]|(\w+(?:\.\w+)*))/g,
|
|
8
8
|
streamFor: /stream_for\s+(.+)/g,
|
|
9
9
|
turboStreamFrom: /turbo_stream_from\s+(.+)/g,
|
|
10
10
|
connectionConnect: /def\s+connect/,
|
|
@@ -110,7 +110,8 @@ function extractRubyVersion(gemfile, gemfileLock, provider) {
|
|
|
110
110
|
// .ruby-version file
|
|
111
111
|
const rubyVersion = provider.readFile('.ruby-version')
|
|
112
112
|
if (rubyVersion) {
|
|
113
|
-
const
|
|
113
|
+
const cleaned = rubyVersion.trim().replace(/^ruby-/, '')
|
|
114
|
+
const ver = cleaned.match(/^(\d+\.\d+\.\d+)/)
|
|
114
115
|
if (ver) return ver[1]
|
|
115
116
|
}
|
|
116
117
|
|
|
@@ -196,7 +197,8 @@ function detectFramework(gemfile, gems, appConfig, provider) {
|
|
|
196
197
|
|
|
197
198
|
// JS bundling
|
|
198
199
|
let jsBundling = null
|
|
199
|
-
if (hasGem('
|
|
200
|
+
if (hasGem('vite_rails') || hasGem('vite_ruby')) jsBundling = 'vite'
|
|
201
|
+
else if (hasGem('webpacker')) jsBundling = 'webpacker'
|
|
200
202
|
else if (hasGem('importmap-rails')) jsBundling = 'importmap'
|
|
201
203
|
else if (hasGem('jsbundling-rails')) {
|
|
202
204
|
// Check package.json for specific bundler
|
package/src/extractors/auth.js
CHANGED
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { AUTH_PATTERNS } from '../core/patterns.js'
|
|
8
|
+
import { stripRubyComments } from '../utils/ruby-parser.js'
|
|
9
|
+
|
|
10
|
+
const REDACTED_DEVISE_KEYS = new Set([
|
|
11
|
+
'secret_key',
|
|
12
|
+
'pepper',
|
|
13
|
+
'secret_key_base',
|
|
14
|
+
'signing_salt',
|
|
15
|
+
'digest',
|
|
16
|
+
])
|
|
8
17
|
|
|
9
18
|
// -------------------------------------------------------
|
|
10
19
|
// Helpers for reading native Rails 8 auth details
|
|
@@ -269,6 +278,22 @@ function scanForApiAuthPatterns(provider, entries) {
|
|
|
269
278
|
const c = provider.readFile(entry.path)
|
|
270
279
|
if (c) contents.push(c)
|
|
271
280
|
}
|
|
281
|
+
// Also scan lib/ files for custom JWT implementations
|
|
282
|
+
const libEntries = entries.filter(
|
|
283
|
+
(e) => e.path.startsWith('lib/') && e.path.endsWith('.rb'),
|
|
284
|
+
)
|
|
285
|
+
for (const entry of libEntries) {
|
|
286
|
+
const c = provider.readFile(entry.path)
|
|
287
|
+
if (c) contents.push(c)
|
|
288
|
+
}
|
|
289
|
+
// If no lib entries in the index, try a glob
|
|
290
|
+
if (libEntries.length === 0) {
|
|
291
|
+
const libFiles = provider.glob?.('lib/**/*.rb') || []
|
|
292
|
+
for (const path of libFiles) {
|
|
293
|
+
const c = provider.readFile(path)
|
|
294
|
+
if (c) contents.push(c)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
272
297
|
const gemfileContent = provider.readFile('Gemfile') || ''
|
|
273
298
|
const allContent = [...contents, gemfileContent].join('\n')
|
|
274
299
|
|
|
@@ -381,13 +406,20 @@ export function extractAuth(
|
|
|
381
406
|
// Parse Devise initializer config
|
|
382
407
|
const deviseConfig = provider.readFile('config/initializers/devise.rb')
|
|
383
408
|
if (deviseConfig) {
|
|
409
|
+
const activeConfig = stripRubyComments(deviseConfig)
|
|
384
410
|
const configRe = new RegExp(AUTH_PATTERNS.deviseConfig.source, 'g')
|
|
385
411
|
let m
|
|
386
|
-
while ((m = configRe.exec(
|
|
412
|
+
while ((m = configRe.exec(activeConfig))) {
|
|
387
413
|
const key = m[1]
|
|
388
414
|
const val = m[2].trim()
|
|
389
415
|
result.devise.config[key] = val
|
|
390
416
|
}
|
|
417
|
+
// Redact sensitive keys
|
|
418
|
+
for (const key of Object.keys(result.devise.config)) {
|
|
419
|
+
if (REDACTED_DEVISE_KEYS.has(key)) {
|
|
420
|
+
result.devise.config[key] = '[REDACTED]'
|
|
421
|
+
}
|
|
422
|
+
}
|
|
391
423
|
}
|
|
392
424
|
|
|
393
425
|
// Parse models for devise declarations
|
|
@@ -575,7 +575,26 @@ export function extractAuthorization(
|
|
|
575
575
|
// CanCanCan
|
|
576
576
|
if (hasCanCan) {
|
|
577
577
|
if (!result.strategy) result.strategy = 'cancancan'
|
|
578
|
-
|
|
578
|
+
let abilityContent = provider.readFile('app/models/ability.rb')
|
|
579
|
+
// Fallback: scan model files for CanCan::Ability
|
|
580
|
+
if (!abilityContent || !AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent)) {
|
|
581
|
+
const modelEntries = entries.filter(
|
|
582
|
+
(e) =>
|
|
583
|
+
(e.category === 'model' || e.categoryName === 'models') &&
|
|
584
|
+
e.path.endsWith('.rb'),
|
|
585
|
+
)
|
|
586
|
+
for (const entry of modelEntries) {
|
|
587
|
+
const c = provider.readFile(entry.path)
|
|
588
|
+
if (
|
|
589
|
+
c &&
|
|
590
|
+
AUTHORIZATION_PATTERNS.abilityClass.test(c) &&
|
|
591
|
+
AUTHORIZATION_PATTERNS.includeCanCan.test(c)
|
|
592
|
+
) {
|
|
593
|
+
abilityContent = c
|
|
594
|
+
break
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
579
598
|
if (
|
|
580
599
|
abilityContent &&
|
|
581
600
|
AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { CACHING_PATTERNS } from '../core/patterns.js'
|
|
7
|
+
import { stripRubyComments } from '../utils/ruby-parser.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Extract caching information.
|
|
@@ -23,7 +24,8 @@ export function extractCaching(provider, entries) {
|
|
|
23
24
|
for (const env of ['production', 'development', 'test']) {
|
|
24
25
|
const content = provider.readFile(`config/environments/${env}.rb`)
|
|
25
26
|
if (content) {
|
|
26
|
-
const
|
|
27
|
+
const activeContent = stripRubyComments(content)
|
|
28
|
+
const storeMatch = activeContent.match(CACHING_PATTERNS.cacheStore)
|
|
27
29
|
if (storeMatch) {
|
|
28
30
|
result.store[env] = storeMatch[1]
|
|
29
31
|
}
|
package/src/extractors/config.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { CONFIG_PATTERNS } from '../core/patterns.js'
|
|
7
7
|
import { parseYaml } from '../utils/yaml-parser.js'
|
|
8
|
+
import { stripRubyComments } from '../utils/ruby-parser.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Extract config information.
|
|
@@ -66,11 +67,12 @@ export function extractConfig(provider) {
|
|
|
66
67
|
const content = provider.readFile(`config/environments/${env}.rb`)
|
|
67
68
|
if (!content) continue
|
|
68
69
|
|
|
70
|
+
const activeContent = stripRubyComments(content)
|
|
69
71
|
const envConfig = {}
|
|
70
|
-
const csMatch =
|
|
72
|
+
const csMatch = activeContent.match(CONFIG_PATTERNS.cacheStore)
|
|
71
73
|
if (csMatch) envConfig.cache_store = csMatch[1]
|
|
72
74
|
|
|
73
|
-
if (CONFIG_PATTERNS.forceSSL.test(
|
|
75
|
+
if (CONFIG_PATTERNS.forceSSL.test(activeContent)) envConfig.force_ssl = true
|
|
74
76
|
|
|
75
77
|
if (Object.keys(envConfig).length > 0) {
|
|
76
78
|
result.environments[env] = envConfig
|
package/src/extractors/model.js
CHANGED
|
@@ -175,6 +175,10 @@ export function extractModel(provider, filePath, className) {
|
|
|
175
175
|
while ((m = validateRe.exec(content))) {
|
|
176
176
|
custom_validators.push(m[1])
|
|
177
177
|
}
|
|
178
|
+
const vwRe = new RegExp(MODEL_PATTERNS.validatesWithValidator.source, 'gm')
|
|
179
|
+
while ((m = vwRe.exec(content))) {
|
|
180
|
+
custom_validators.push(`validates_with:${m[1]}`)
|
|
181
|
+
}
|
|
178
182
|
|
|
179
183
|
// Scopes — names array (backward-compat) + scope_queries dict with bodies
|
|
180
184
|
const scopes = []
|
|
@@ -269,11 +273,17 @@ export function extractModel(provider, filePath, className) {
|
|
|
269
273
|
}
|
|
270
274
|
}
|
|
271
275
|
|
|
272
|
-
// Callbacks
|
|
276
|
+
// Callbacks — strip inline comments before matching; skip block-only callbacks
|
|
277
|
+
const cbLines = content
|
|
278
|
+
.split('\n')
|
|
279
|
+
.map((l) => l.replace(/#[^{].*$/, '').trimEnd())
|
|
280
|
+
.join('\n')
|
|
273
281
|
const callbacks = []
|
|
274
282
|
const cbRe = new RegExp(MODEL_PATTERNS.callbackType.source, 'gm')
|
|
275
|
-
while ((m = cbRe.exec(
|
|
276
|
-
|
|
283
|
+
while ((m = cbRe.exec(cbLines))) {
|
|
284
|
+
const method = m[2]
|
|
285
|
+
if (method === 'do' || method === '{') continue
|
|
286
|
+
callbacks.push({ type: m[1], method, options: m[3] || null })
|
|
277
287
|
}
|
|
278
288
|
|
|
279
289
|
// Delegations
|
|
@@ -376,26 +386,28 @@ export function extractModel(provider, filePath, className) {
|
|
|
376
386
|
? turboRefreshesMatch[1]
|
|
377
387
|
: null
|
|
378
388
|
|
|
379
|
-
// Devise modules
|
|
389
|
+
// Devise modules — use matchAll to handle multiple devise() calls
|
|
380
390
|
let devise_modules = []
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
391
|
+
const deviseGlobalRe = /^\s*devise\s+(.+)/gm
|
|
392
|
+
let deviseMatch
|
|
393
|
+
while ((deviseMatch = deviseGlobalRe.exec(content))) {
|
|
384
394
|
let deviseStr = deviseMatch[1]
|
|
385
|
-
//
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
395
|
+
// Only continue if line ends with comma (argument list continues)
|
|
396
|
+
const afterMatch = content.slice(deviseMatch.index + deviseMatch[0].length)
|
|
397
|
+
if (deviseMatch[0].trimEnd().endsWith(',')) {
|
|
398
|
+
const continuationLines = afterMatch.split('\n')
|
|
399
|
+
for (const line of continuationLines) {
|
|
400
|
+
const trimmed = line.trim()
|
|
401
|
+
if (trimmed.length === 0) continue
|
|
402
|
+
if (/^:/.test(trimmed) || /^,\s*:/.test(trimmed)) {
|
|
403
|
+
deviseStr += ' ' + trimmed
|
|
404
|
+
} else {
|
|
405
|
+
break
|
|
406
|
+
}
|
|
396
407
|
}
|
|
397
408
|
}
|
|
398
|
-
|
|
409
|
+
const modules = (deviseStr.match(/:(\w+)/g) || []).map((s) => s.slice(1))
|
|
410
|
+
devise_modules.push(...modules)
|
|
399
411
|
}
|
|
400
412
|
|
|
401
413
|
// Searchable
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { REALTIME_PATTERNS } from '../core/patterns.js'
|
|
7
|
+
import { parseYaml } from '../utils/yaml-parser.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Extract realtime information.
|
|
@@ -25,15 +26,9 @@ export function extractRealtime(provider, entries, gemInfo = {}) {
|
|
|
25
26
|
// Cable config
|
|
26
27
|
const cableYml = provider.readFile('config/cable.yml')
|
|
27
28
|
if (cableYml) {
|
|
28
|
-
const
|
|
29
|
-
for (const
|
|
30
|
-
|
|
31
|
-
if (envMatch) {
|
|
32
|
-
const adapterMatch = section.match(REALTIME_PATTERNS.cableAdapter)
|
|
33
|
-
if (adapterMatch) {
|
|
34
|
-
result.adapter[envMatch[1]] = adapterMatch[1]
|
|
35
|
-
}
|
|
36
|
-
}
|
|
29
|
+
const parsed = parseYaml(cableYml)
|
|
30
|
+
for (const [env, cfg] of Object.entries(parsed || {})) {
|
|
31
|
+
if (cfg && cfg.adapter) result.adapter[env] = cfg.adapter
|
|
37
32
|
}
|
|
38
33
|
}
|
|
39
34
|
|
|
@@ -71,7 +66,7 @@ export function extractRealtime(provider, entries, gemInfo = {}) {
|
|
|
71
66
|
const fromRe = new RegExp(REALTIME_PATTERNS.streamFrom.source, 'g')
|
|
72
67
|
let m
|
|
73
68
|
while ((m = fromRe.exec(content))) {
|
|
74
|
-
channel.streams_from.push(m[1])
|
|
69
|
+
channel.streams_from.push(m[1] || m[2])
|
|
75
70
|
}
|
|
76
71
|
|
|
77
72
|
const forRe = new RegExp(REALTIME_PATTERNS.streamFor.source, 'g')
|
package/src/extractors/routes.js
CHANGED
|
@@ -19,6 +19,7 @@ export function extractRoutes(provider) {
|
|
|
19
19
|
concerns: [],
|
|
20
20
|
drawn_files: [],
|
|
21
21
|
nested_relationships: [],
|
|
22
|
+
devise_routes: [],
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const content = provider.readFile('config/routes.rb')
|
|
@@ -55,18 +56,35 @@ function parseRouteContent(content, result, provider, namespaceStack) {
|
|
|
55
56
|
continue
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
// Draw (route splitting)
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// Draw (route splitting) — handles both `draw :name` and `draw_routes :name`
|
|
60
|
+
const drawRawMatch = trimmed.match(
|
|
61
|
+
/^\s*(?:draw_routes|draw)\s*\(?:?(\w+)\)?/,
|
|
62
|
+
)
|
|
63
|
+
if (drawRawMatch) {
|
|
64
|
+
const drawFile = drawRawMatch[1]
|
|
62
65
|
result.drawn_files.push(drawFile)
|
|
63
|
-
|
|
66
|
+
// Try config/routes/<name>.rb and config/routes/<name>_routes.rb
|
|
67
|
+
const drawContent =
|
|
68
|
+
provider.readFile(`config/routes/${drawFile}.rb`) ||
|
|
69
|
+
provider.readFile(`config/routes/${drawFile}_routes.rb`)
|
|
64
70
|
if (drawContent) {
|
|
65
71
|
parseRouteContent(drawContent, result, provider, [...namespaceStack])
|
|
66
72
|
}
|
|
67
73
|
continue
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
// devise_for
|
|
77
|
+
const deviseForMatch = trimmed.match(
|
|
78
|
+
/^\s*devise_for\s+:(\w+)(?:,\s*(.+))?/,
|
|
79
|
+
)
|
|
80
|
+
if (deviseForMatch) {
|
|
81
|
+
result.devise_routes.push({
|
|
82
|
+
model: deviseForMatch[1],
|
|
83
|
+
options: deviseForMatch[2] || null,
|
|
84
|
+
})
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
// Mount
|
|
71
89
|
const mountMatch = trimmed.match(ROUTE_PATTERNS.mount)
|
|
72
90
|
if (mountMatch) {
|
|
@@ -25,25 +25,31 @@ export function extractStorage(provider, entries, gemInfo = {}) {
|
|
|
25
25
|
// Storage services from config/storage.yml
|
|
26
26
|
const storageYml = provider.readFile('config/storage.yml')
|
|
27
27
|
if (storageYml) {
|
|
28
|
+
const activeYml = storageYml
|
|
29
|
+
.split('\n')
|
|
30
|
+
.filter((l) => !l.trim().startsWith('#'))
|
|
31
|
+
.join('\n')
|
|
28
32
|
const serviceRe = new RegExp(STORAGE_PATTERNS.storageService.source, 'g')
|
|
29
33
|
let m
|
|
30
|
-
while ((m = serviceRe.exec(
|
|
34
|
+
while ((m = serviceRe.exec(activeYml))) {
|
|
31
35
|
result.services[m[1]] = { service: m[2] }
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
// Mirror service
|
|
35
|
-
if (STORAGE_PATTERNS.mirrorService.test(
|
|
39
|
+
if (STORAGE_PATTERNS.mirrorService.test(activeYml)) {
|
|
36
40
|
result.services.mirror = { service: 'Mirror' }
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
// Direct uploads
|
|
40
|
-
if (STORAGE_PATTERNS.directUpload.test(
|
|
44
|
+
if (STORAGE_PATTERNS.directUpload.test(activeYml)) {
|
|
41
45
|
result.direct_uploads = true
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
// Attachments from model files
|
|
46
|
-
const modelEntries = entries.filter(
|
|
50
|
+
const modelEntries = entries.filter(
|
|
51
|
+
(e) => e.category === 'model' || e.category === 1 || e.categoryName === 'models',
|
|
52
|
+
)
|
|
47
53
|
for (const entry of modelEntries) {
|
|
48
54
|
const content = provider.readFile(entry.path)
|
|
49
55
|
if (!content) continue
|
|
@@ -82,6 +88,30 @@ export function extractStorage(provider, entries, gemInfo = {}) {
|
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
// Paperclip attachments
|
|
92
|
+
if (gems.paperclip) {
|
|
93
|
+
for (const entry of modelEntries) {
|
|
94
|
+
const content = provider.readFile(entry.path)
|
|
95
|
+
if (!content) continue
|
|
96
|
+
const className = entry.path
|
|
97
|
+
.split('/')
|
|
98
|
+
.pop()
|
|
99
|
+
.replace('.rb', '')
|
|
100
|
+
.split('_')
|
|
101
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
102
|
+
.join('')
|
|
103
|
+
const pcRe = /^\s*has_attached_file\s+:(\w+)/gm
|
|
104
|
+
let m
|
|
105
|
+
while ((m = pcRe.exec(content))) {
|
|
106
|
+
result.attachments.push({
|
|
107
|
+
model: className,
|
|
108
|
+
name: m[1],
|
|
109
|
+
type: 'has_attached_file',
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
85
115
|
// Image processing
|
|
86
116
|
if (gems.image_processing) {
|
|
87
117
|
result.image_processing = {
|
package/src/extractors/views.js
CHANGED
|
@@ -11,17 +11,16 @@ import { VIEW_PATTERNS } from '../core/patterns.js'
|
|
|
11
11
|
* @returns {string}
|
|
12
12
|
*/
|
|
13
13
|
function detectEngine(entries) {
|
|
14
|
-
|
|
15
|
-
haml = 0,
|
|
16
|
-
slim = 0
|
|
14
|
+
const counts = { erb: 0, haml: 0, slim: 0 }
|
|
17
15
|
for (const e of entries) {
|
|
18
|
-
if (e.path.endsWith('.erb')) erb++
|
|
19
|
-
else if (e.path.endsWith('.haml')) haml++
|
|
20
|
-
else if (e.path.endsWith('.slim')) slim++
|
|
16
|
+
if (e.path.endsWith('.erb')) counts.erb++
|
|
17
|
+
else if (e.path.endsWith('.haml')) counts.haml++
|
|
18
|
+
else if (e.path.endsWith('.slim')) counts.slim++
|
|
21
19
|
}
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
return
|
|
20
|
+
const found = Object.entries(counts).filter(([, c]) => c > 0)
|
|
21
|
+
if (found.length === 0) return 'erb'
|
|
22
|
+
if (found.length === 1) return found[0][0]
|
|
23
|
+
return found.map(([engine, count]) => `${engine}(${count})`).join(', ')
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
/**
|
|
@@ -49,8 +49,19 @@ export function register(server, state) {
|
|
|
49
49
|
case 'email':
|
|
50
50
|
return respond(extractions.email || {})
|
|
51
51
|
|
|
52
|
-
case 'storage':
|
|
53
|
-
|
|
52
|
+
case 'storage': {
|
|
53
|
+
const storage = extractions.storage || {}
|
|
54
|
+
const uploaders = extractions.uploaders || {}
|
|
55
|
+
return respond({
|
|
56
|
+
...storage,
|
|
57
|
+
carrierwave_uploaders: uploaders.uploaders
|
|
58
|
+
? Object.entries(uploaders.uploaders).map(([name, u]) => ({
|
|
59
|
+
name,
|
|
60
|
+
...u,
|
|
61
|
+
}))
|
|
62
|
+
: [],
|
|
63
|
+
})
|
|
64
|
+
}
|
|
54
65
|
|
|
55
66
|
case 'caching':
|
|
56
67
|
return respond(extractions.caching || {})
|
|
@@ -98,26 +98,27 @@ export function register(server, state) {
|
|
|
98
98
|
.map(([n]) => n)
|
|
99
99
|
|
|
100
100
|
// Custom pattern counts
|
|
101
|
+
const dp = tier2.design_patterns || {}
|
|
101
102
|
const customPatterns = {
|
|
102
|
-
services:
|
|
103
|
+
services: dp.services || 0,
|
|
103
104
|
concerns: Object.values(models).filter((m) => m.type === 'concern')
|
|
104
105
|
.length,
|
|
105
|
-
form_objects:
|
|
106
|
-
presenters:
|
|
107
|
-
policies:
|
|
106
|
+
form_objects: dp.forms || 0,
|
|
107
|
+
presenters: dp.presenters || 0,
|
|
108
|
+
policies: (index.extractions?.authorization?.policies || []).length || 0,
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const overview = {
|
|
111
112
|
rails_version: v.rails || 'unknown',
|
|
112
113
|
ruby_version: v.ruby || 'unknown',
|
|
113
114
|
database: config.database || v.database || 'unknown',
|
|
114
|
-
asset_pipeline: v.asset_pipeline || 'unknown',
|
|
115
|
+
asset_pipeline: v.framework?.assetPipeline || v.asset_pipeline || 'unknown',
|
|
115
116
|
frontend_stack: v.frontend || [],
|
|
116
117
|
authentication: authSummary,
|
|
117
118
|
authorization: authzSummary,
|
|
118
119
|
job_adapter: config.queue_adapter || jobs.adapter || 'unknown',
|
|
119
120
|
cache_store: caching.store || 'unknown',
|
|
120
|
-
test_framework: v.test_framework || 'unknown',
|
|
121
|
+
test_framework: v.framework?.testFramework || v.test_framework || 'unknown',
|
|
121
122
|
key_models: keyModels,
|
|
122
123
|
key_controllers: keyControllers,
|
|
123
124
|
custom_patterns: customPatterns,
|
|
@@ -1,6 +1,87 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { noIndex, respond } from './helpers.js'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Collect seed entity names for a given skill from the index extractions.
|
|
6
|
+
* @param {string} skill
|
|
7
|
+
* @param {object} index
|
|
8
|
+
* @returns {Set<string>}
|
|
9
|
+
*/
|
|
10
|
+
function getSkillSeeds(skill, index) {
|
|
11
|
+
const extractions = index.extractions || {}
|
|
12
|
+
const seeds = new Set()
|
|
13
|
+
|
|
14
|
+
switch (skill) {
|
|
15
|
+
case 'authentication': {
|
|
16
|
+
// Models with Devise, has_secure_password, or Session/Current naming
|
|
17
|
+
for (const [name, model] of Object.entries(extractions.models || {})) {
|
|
18
|
+
if (
|
|
19
|
+
model.has_secure_password ||
|
|
20
|
+
(model.devise_modules && model.devise_modules.length > 0) ||
|
|
21
|
+
/^(User|Session|Current|Account|Identity)$/.test(name)
|
|
22
|
+
) {
|
|
23
|
+
seeds.add(name)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Controllers related to auth
|
|
27
|
+
for (const [name] of Object.entries(extractions.controllers || {})) {
|
|
28
|
+
if (
|
|
29
|
+
/session|registration|password|confirmation|login|signup|auth/i.test(
|
|
30
|
+
name,
|
|
31
|
+
)
|
|
32
|
+
) {
|
|
33
|
+
seeds.add(name)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
break
|
|
37
|
+
}
|
|
38
|
+
case 'database': {
|
|
39
|
+
// All non-concern, non-abstract AR models
|
|
40
|
+
for (const [name, model] of Object.entries(extractions.models || {})) {
|
|
41
|
+
if (model.type !== 'concern' && !model.abstract) seeds.add(name)
|
|
42
|
+
}
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
case 'frontend': {
|
|
46
|
+
for (const sc of extractions.stimulus_controllers || []) {
|
|
47
|
+
seeds.add(sc.identifier || sc.class)
|
|
48
|
+
}
|
|
49
|
+
for (const [name] of Object.entries(extractions.components || {})) {
|
|
50
|
+
seeds.add(name)
|
|
51
|
+
}
|
|
52
|
+
for (const [name] of Object.entries(extractions.controllers || {})) {
|
|
53
|
+
if (/pages|home|static|landing/i.test(name)) seeds.add(name)
|
|
54
|
+
}
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
case 'api': {
|
|
58
|
+
for (const [name, ctrl] of Object.entries(extractions.controllers || {})) {
|
|
59
|
+
if (/api|v\d+|json/i.test(name) || ctrl.api_only) seeds.add(name)
|
|
60
|
+
}
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
case 'jobs': {
|
|
64
|
+
for (const [name] of Object.entries(extractions.workers || {})) {
|
|
65
|
+
seeds.add(name)
|
|
66
|
+
}
|
|
67
|
+
for (const job of extractions.jobs?.jobs || []) {
|
|
68
|
+
seeds.add(job.class || job.name)
|
|
69
|
+
}
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
case 'email': {
|
|
73
|
+
for (const [name] of Object.entries(extractions.mailers || {})) {
|
|
74
|
+
seeds.add(name)
|
|
75
|
+
}
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return seeds
|
|
83
|
+
}
|
|
84
|
+
|
|
4
85
|
/**
|
|
5
86
|
* Register the get_subgraph tool.
|
|
6
87
|
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
@@ -20,47 +101,31 @@ export function register(server, state) {
|
|
|
20
101
|
async ({ skill }) => {
|
|
21
102
|
if (!state.index) return noIndex()
|
|
22
103
|
|
|
23
|
-
const
|
|
24
|
-
authentication
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
'confirmation',
|
|
32
|
-
],
|
|
33
|
-
database: ['model', 'schema', 'migration', 'concern'],
|
|
34
|
-
frontend: ['component', 'stimulus', 'view', 'turbo', 'hotwire'],
|
|
35
|
-
api: ['api', 'serializer', 'blueprint', 'graphql'],
|
|
36
|
-
jobs: ['job', 'worker', 'sidekiq', 'queue'],
|
|
37
|
-
email: ['mailer', 'mail', 'mailbox'],
|
|
38
|
-
}
|
|
104
|
+
const KNOWN_SKILLS = [
|
|
105
|
+
'authentication',
|
|
106
|
+
'database',
|
|
107
|
+
'frontend',
|
|
108
|
+
'api',
|
|
109
|
+
'jobs',
|
|
110
|
+
'email',
|
|
111
|
+
]
|
|
39
112
|
|
|
40
|
-
|
|
41
|
-
if (!domains) {
|
|
113
|
+
if (!KNOWN_SKILLS.includes(skill)) {
|
|
42
114
|
return respond({
|
|
43
115
|
error: `Unknown skill '${skill}'`,
|
|
44
|
-
available:
|
|
116
|
+
available: KNOWN_SKILLS,
|
|
45
117
|
})
|
|
46
118
|
}
|
|
47
119
|
|
|
120
|
+
const seeds = getSkillSeeds(skill, state.index)
|
|
48
121
|
const allRels = state.index.relationships || []
|
|
49
122
|
const rankings = state.index.rankings || {}
|
|
50
|
-
|
|
123
|
+
|
|
124
|
+
// BFS one hop from seeds using relationships
|
|
125
|
+
const relevantEntities = new Set(seeds)
|
|
51
126
|
for (const rel of allRels) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
55
|
-
const toMatch = domains.some((d) => rel.to.toLowerCase().includes(d))
|
|
56
|
-
if (fromMatch || toMatch) {
|
|
57
|
-
relevantEntities.add(rel.from)
|
|
58
|
-
relevantEntities.add(rel.to)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
for (const key of Object.keys(rankings)) {
|
|
62
|
-
if (domains.some((d) => key.toLowerCase().includes(d)))
|
|
63
|
-
relevantEntities.add(key)
|
|
127
|
+
if (seeds.has(rel.from)) relevantEntities.add(rel.to)
|
|
128
|
+
if (seeds.has(rel.to)) relevantEntities.add(rel.from)
|
|
64
129
|
}
|
|
65
130
|
|
|
66
131
|
const subgraphRels = allRels.filter(
|
|
@@ -87,7 +87,11 @@ export function register(server, state) {
|
|
|
87
87
|
const filters = ctrl.filters || []
|
|
88
88
|
for (const f of filters) {
|
|
89
89
|
const filterStr = typeof f === 'string' ? f : f.name || f.method || ''
|
|
90
|
-
|
|
90
|
+
const filterType = typeof f === 'string' ? '' : f.type || ''
|
|
91
|
+
if (
|
|
92
|
+
filterStr.toLowerCase().includes(lowerPattern) ||
|
|
93
|
+
filterType.toLowerCase().includes(lowerPattern)
|
|
94
|
+
)
|
|
91
95
|
matches.push({ type: 'filter', detail: f })
|
|
92
96
|
}
|
|
93
97
|
if (matches.length > 0)
|
package/src/utils/ruby-parser.js
CHANGED
|
@@ -88,6 +88,19 @@ export function extractIncludesExtends(content) {
|
|
|
88
88
|
return { includes, extends: extends_ }
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Strip Ruby single-line comments from source content.
|
|
93
|
+
* Preserves string literals containing # characters.
|
|
94
|
+
* @param {string} content - Ruby file content
|
|
95
|
+
* @returns {string} Content with comment lines removed
|
|
96
|
+
*/
|
|
97
|
+
export function stripRubyComments(content) {
|
|
98
|
+
return content
|
|
99
|
+
.split('\n')
|
|
100
|
+
.filter((line) => !line.trim().startsWith('#'))
|
|
101
|
+
.join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
91
104
|
/**
|
|
92
105
|
* Extract the visibility sections (public/private/protected) from Ruby source.
|
|
93
106
|
* Returns methods grouped by visibility.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Detect spec style (request vs controller specs).
|
|
9
|
+
* Detect spec style (request vs controller specs), with Minitest fallback.
|
|
10
10
|
* @param {Array<{path: string}>} entries
|
|
11
11
|
* @returns {{primary: string, request_count: number, controller_count: number, has_mixed: boolean}}
|
|
12
12
|
*/
|
|
@@ -17,6 +17,19 @@ export function detectSpecStyle(entries) {
|
|
|
17
17
|
const controllerCount = entries.filter((e) =>
|
|
18
18
|
e.path.startsWith('spec/controllers/'),
|
|
19
19
|
).length
|
|
20
|
+
const hasAnySpec = entries.some((e) => e.path.startsWith('spec/'))
|
|
21
|
+
const hasMinitestDir = entries.some((e) => e.path.startsWith('test/'))
|
|
22
|
+
|
|
23
|
+
// Minitest fallback: test/ dir present but no spec/ dir
|
|
24
|
+
if (!hasAnySpec && hasMinitestDir) {
|
|
25
|
+
return {
|
|
26
|
+
primary: 'minitest',
|
|
27
|
+
request_count: 0,
|
|
28
|
+
controller_count: 0,
|
|
29
|
+
has_mixed: false,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
return {
|
|
21
34
|
primary: requestCount >= controllerCount ? 'request' : 'controller',
|
|
22
35
|
request_count: requestCount,
|