@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reinteractive/rails-insight",
3
- "version": "1.0.11",
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
+ }
@@ -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
 
@@ -232,7 +232,8 @@ export async function buildIndex(provider, options = {}) {
232
232
  extractionErrors,
233
233
  )
234
234
  if (ctrl) {
235
- const name = pathToClassName(entry.path)
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.keys(extractions.models || {}).length,
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+(index|show|create|new|update|edit|destroy)\?/g,
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*(.+))?$/m,
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+['"]?([^'"]+)['"]?/g,
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 ver = rubyVersion.trim().match(/^(\d+\.\d+\.\d+)/)
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('webpacker')) jsBundling = 'webpacker'
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
@@ -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(deviseConfig))) {
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
- const abilityContent = provider.readFile('app/models/ability.rb')
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 storeMatch = content.match(CACHING_PATTERNS.cacheStore)
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
  }
@@ -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 = content.match(CONFIG_PATTERNS.cacheStore)
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(content)) envConfig.force_ssl = true
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
@@ -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(content))) {
276
- callbacks.push({ type: m[1], method: m[2], options: m[3] || null })
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 deviseMatch = content.match(MODEL_PATTERNS.devise)
382
- if (deviseMatch) {
383
- // Devise declaration can span multiple lines
391
+ const deviseGlobalRe = /^\s*devise\s+(.+)/gm
392
+ let deviseMatch
393
+ while ((deviseMatch = deviseGlobalRe.exec(content))) {
384
394
  let deviseStr = deviseMatch[1]
385
- // Continue capturing if line ends with comma
386
- const deviseStartIdx = content.indexOf(deviseMatch[0])
387
- const afterMatch = content.slice(deviseStartIdx + deviseMatch[0].length)
388
- const continuationLines = afterMatch.split('\n')
389
- for (const line of continuationLines) {
390
- const trimmed = line.trim()
391
- if (trimmed.length === 0) continue
392
- if (/^:/.test(trimmed) || /^,/.test(trimmed) || /^\w+.*:/.test(trimmed)) {
393
- deviseStr += ' ' + trimmed
394
- } else {
395
- break
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
- devise_modules = (deviseStr.match(/:(\w+)/g) || []).map((s) => s.slice(1))
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 sections = cableYml.split(/\n(?=\w)/)
29
- for (const section of sections) {
30
- const envMatch = section.match(/^(\w+):/)
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')
@@ -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 drawMatch = trimmed.match(ROUTE_PATTERNS.draw)
60
- if (drawMatch) {
61
- const drawFile = drawMatch[1]
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
- const drawContent = provider.readFile('config/routes/' + drawFile + '.rb')
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(storageYml))) {
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(storageYml)) {
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(storageYml)) {
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((e) => e.category === 'model')
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 = {
@@ -11,17 +11,16 @@ import { VIEW_PATTERNS } from '../core/patterns.js'
11
11
  * @returns {string}
12
12
  */
13
13
  function detectEngine(entries) {
14
- let erb = 0,
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
- if (haml > erb && haml > slim) return 'haml'
23
- if (slim > erb && slim > haml) return 'slim'
24
- return 'erb'
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
- return respond(extractions.storage || {})
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: tier2.services?.length || tier2.service_objects?.length || 0,
103
+ services: dp.services || 0,
103
104
  concerns: Object.values(models).filter((m) => m.type === 'concern')
104
105
  .length,
105
- form_objects: tier2.form_objects?.length || 0,
106
- presenters: tier2.presenters?.length || 0,
107
- policies: tier3.policies?.count || 0,
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 skillDomains = {
24
- authentication: [
25
- 'auth',
26
- 'devise',
27
- 'session',
28
- 'current',
29
- 'password',
30
- 'registration',
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
- const domains = skillDomains[skill]
41
- if (!domains) {
113
+ if (!KNOWN_SKILLS.includes(skill)) {
42
114
  return respond({
43
115
  error: `Unknown skill '${skill}'`,
44
- available: Object.keys(skillDomains),
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
- const relevantEntities = new Set()
123
+
124
+ // BFS one hop from seeds using relationships
125
+ const relevantEntities = new Set(seeds)
51
126
  for (const rel of allRels) {
52
- const fromMatch = domains.some((d) =>
53
- rel.from.toLowerCase().includes(d),
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(
@@ -24,6 +24,7 @@ export function register(server, state) {
24
24
  })
25
25
  }
26
26
  const start = Date.now()
27
+ state.index = null
27
28
  state.index = await buildIndex(state.provider, { verbose: state.verbose })
28
29
  const duration_ms = Date.now() - start
29
30
  return respond({
@@ -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
- if (filterStr.toLowerCase().includes(lowerPattern))
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)
@@ -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,