@reinteractive/rails-insight 1.0.11 → 1.0.13

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.13",
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
 
@@ -223,7 +223,11 @@ export async function buildIndex(provider, options = {}) {
223
223
  extractionErrors,
224
224
  )
225
225
  if (model) extractions.models[className] = model
226
- } else if (entry.categoryName === 'controllers') {
226
+ } else if (
227
+ entry.categoryName === 'controllers' ||
228
+ (entry.categoryName === 'authentication' &&
229
+ entry.path.includes('_controller.rb'))
230
+ ) {
227
231
  const ctrl = safeExtract(
228
232
  `controller:${entry.path}`,
229
233
  () => extractController(provider, entry.path),
@@ -232,7 +236,8 @@ export async function buildIndex(provider, options = {}) {
232
236
  extractionErrors,
233
237
  )
234
238
  if (ctrl) {
235
- const name = pathToClassName(entry.path)
239
+ // Use the controller's own fully-qualified class name to avoid namespace collisions
240
+ const name = ctrl.class || pathToClassName(entry.path)
236
241
  extractions.controllers[name] = ctrl
237
242
  }
238
243
  } else if (entry.categoryName === 'components') {
@@ -581,7 +586,10 @@ function computeStatistics(manifest, extractions, relationships) {
581
586
  const entries = manifest.entries || []
582
587
  return {
583
588
  total_files: entries.length,
584
- models: Object.keys(extractions.models || {}).length,
589
+ models: Object.values(extractions.models || {}).filter(
590
+ (m) => m.type !== 'concern' && !m.abstract,
591
+ ).length,
592
+ models_in_manifest: (manifest.stats || {}).models || 0,
585
593
  controllers: Object.keys(extractions.controllers || {}).length,
586
594
  components: Object.keys(extractions.components || {}).length,
587
595
  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,8 @@ 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,
25
+ validatesOldStyle: /^\s*validates_(\w+?)(?:_of)?\s+:(\w+)(?:,\s*(.+))?$/m,
24
26
 
25
27
  // === SCOPES ===
26
28
  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/,
@@ -10,7 +10,7 @@ export const ROUTE_PATTERNS = {
10
10
  constraints: /^\s*constraints\s*(?:\((.+)\))?\s*do/m,
11
11
  httpVerb:
12
12
  /^\s*(?:get|post|put|patch|delete)\s+['"]([^'"]+)['"](?:.*?(?:to:|=>)\s*['"]([^'"#]+)#?([^'"]*)['"'])?/m,
13
- root: /^\s*root\s+(?:to:\s*)?['"]([^'"#]+)#?([^'"]*)['"']/m,
13
+ root: /^\s*root\s+(?:(?::to\s*=>|to:)\s*)?['"]([^'"#]+)#?([^'"]*)['"']/m,
14
14
  mount:
15
15
  /^\s*mount\s+(\w+(?:(?:::|\.)\w+)*)\s*(?:=>|,\s*at:)\s*['"]([^'"]+)['"]/m,
16
16
  concern: /^\s*concern\s+:(\w+)\s+do/m,
@@ -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
@@ -240,9 +242,13 @@ function detectFramework(gemfile, gems, appConfig, provider) {
240
242
  let cacheStore = null
241
243
  if (hasGem('solid_cache')) cacheStore = 'solid_cache'
242
244
  else if (hasGem('redis')) cacheStore = 'redis'
243
- // Also check config
244
- const prodConfig =
245
+ // Also check config — strip comment lines first to avoid false positives
246
+ const prodConfigRaw =
245
247
  provider.readFile('config/environments/production.rb') || ''
248
+ const prodConfig = prodConfigRaw
249
+ .split('\n')
250
+ .filter((l) => !l.trim().startsWith('#'))
251
+ .join('\n')
246
252
  const cacheStoreMatch = prodConfig.match(/config\.cache_store\s*=\s*:(\w+)/)
247
253
  if (cacheStoreMatch) cacheStore = cacheStoreMatch[1]
248
254
 
@@ -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,10 +575,36 @@ 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
+ let abilityFile = 'app/models/ability.rb'
580
+ // Fallback: scan model and authorization files for CanCan::Ability
581
+ if (!abilityContent || !AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent)) {
582
+ const abilityEntries = entries.filter(
583
+ (e) =>
584
+ (e.category === 'model' ||
585
+ e.categoryName === 'models' ||
586
+ e.category === 1 ||
587
+ e.categoryName === 'authorization' ||
588
+ e.category === 9) &&
589
+ e.path.endsWith('.rb'),
590
+ )
591
+ for (const entry of abilityEntries) {
592
+ const c = provider.readFile(entry.path)
593
+ if (
594
+ c &&
595
+ (AUTHORIZATION_PATTERNS.abilityClass.test(c) ||
596
+ AUTHORIZATION_PATTERNS.includeCanCan.test(c))
597
+ ) {
598
+ abilityContent = c
599
+ abilityFile = entry.path
600
+ break
601
+ }
602
+ }
603
+ }
579
604
  if (
580
605
  abilityContent &&
581
- AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent)
606
+ (AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent) ||
607
+ AUTHORIZATION_PATTERNS.includeCanCan.test(abilityContent))
582
608
  ) {
583
609
  const abilities = []
584
610
  const canRe = new RegExp(AUTHORIZATION_PATTERNS.canDef.source, 'gm')
@@ -591,6 +617,21 @@ export function extractAuthorization(
591
617
  abilities.push({ type: 'cannot', definition: m[1].trim() })
592
618
  }
593
619
  result.abilities = abilities
620
+
621
+ // Extract roles from has_role? calls in the ability file
622
+ const roleRe = /has_role\?\s*\(:?['"]?(\w+)['"]?\)/g
623
+ const roles = new Set()
624
+ while ((m = roleRe.exec(abilityContent))) {
625
+ roles.add(m[1])
626
+ }
627
+ if (roles.size > 0) {
628
+ result.roles = {
629
+ source: 'ability_class',
630
+ model: 'User',
631
+ roles: [...roles],
632
+ file: abilityFile,
633
+ }
634
+ }
594
635
  }
595
636
  }
596
637
 
@@ -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.
@@ -61,16 +62,42 @@ export function extractConfig(provider) {
61
62
  }
62
63
  }
63
64
 
65
+ // Fallback: try config/database.yml.example
66
+ if (!result.database.adapter) {
67
+ const dbExample = provider.readFile('config/database.yml.example')
68
+ if (dbExample) {
69
+ const parsed = parseYaml(dbExample)
70
+ const section =
71
+ parsed.production || parsed.development || parsed.default || {}
72
+ result.database.adapter = section.adapter || null
73
+ if (result.database.adapter) result.database.source = 'database.yml.example'
74
+ }
75
+ }
76
+
77
+ // Fallback: detect adapter from Gemfile when database.yml is absent
78
+ if (!result.database.adapter) {
79
+ const gemfile = provider.readFile('Gemfile') || ''
80
+ if (/gem\s+['"]mysql2['"]/.test(gemfile)) result.database.adapter = 'mysql2'
81
+ else if (/gem\s+['"]pg['"]/.test(gemfile))
82
+ result.database.adapter = 'postgresql'
83
+ else if (/gem\s+['"]sqlite3['"]/.test(gemfile))
84
+ result.database.adapter = 'sqlite3'
85
+ else if (/gem\s+['"]trilogy['"]/.test(gemfile))
86
+ result.database.adapter = 'trilogy'
87
+ if (result.database.adapter) result.database.source = 'gemfile'
88
+ }
89
+
64
90
  // config/environments/*.rb
65
91
  for (const env of ['production', 'development', 'test']) {
66
92
  const content = provider.readFile(`config/environments/${env}.rb`)
67
93
  if (!content) continue
68
94
 
95
+ const activeContent = stripRubyComments(content)
69
96
  const envConfig = {}
70
- const csMatch = content.match(CONFIG_PATTERNS.cacheStore)
97
+ const csMatch = activeContent.match(CONFIG_PATTERNS.cacheStore)
71
98
  if (csMatch) envConfig.cache_store = csMatch[1]
72
99
 
73
- if (CONFIG_PATTERNS.forceSSL.test(content)) envConfig.force_ssl = true
100
+ if (CONFIG_PATTERNS.forceSSL.test(activeContent)) envConfig.force_ssl = true
74
101
 
75
102
  if (Object.keys(envConfig).length > 0) {
76
103
  result.environments[env] = envConfig
@@ -40,12 +40,12 @@ export function extractController(provider, filePath) {
40
40
  }
41
41
 
42
42
  // Filters — tag authorization guards
43
- const filters = []
43
+ const rawFilters = []
44
44
  const filterRe = new RegExp(CONTROLLER_PATTERNS.filterType.source, 'gm')
45
45
  while ((m = filterRe.exec(content))) {
46
46
  const filterMethod = m[2]
47
47
  const isAuthzGuard = /^require_\w+!$/.test(filterMethod)
48
- filters.push({
48
+ rawFilters.push({
49
49
  type: m[1],
50
50
  method: filterMethod,
51
51
  ...(isAuthzGuard ? { authorization_guard: true } : {}),
@@ -53,6 +53,60 @@ export function extractController(provider, filePath) {
53
53
  })
54
54
  }
55
55
 
56
+ // Expand multi-method filters: `before_action :a, :b, :c` → separate entries
57
+ const filters = []
58
+ for (const filter of rawFilters) {
59
+ const opts = filter.options
60
+ if (!opts) {
61
+ filters.push(filter)
62
+ continue
63
+ }
64
+
65
+ // Top-level comma split (ignores commas inside brackets)
66
+ const parts = []
67
+ let depth = 0
68
+ let current = ''
69
+ for (const ch of opts) {
70
+ if (ch === '[' || ch === '(' || ch === '{') depth++
71
+ else if (ch === ']' || ch === ')' || ch === '}') depth--
72
+ if (ch === ',' && depth === 0) {
73
+ parts.push(current.trim())
74
+ current = ''
75
+ } else {
76
+ current += ch
77
+ }
78
+ }
79
+ if (current.trim()) parts.push(current.trim())
80
+
81
+ const additionalMethods = []
82
+ const realOptions = []
83
+
84
+ for (const part of parts) {
85
+ if (/^:(\w+!?)$/.test(part)) {
86
+ // Bare symbol — another method, not a keyword option
87
+ additionalMethods.push(part.replace(/^:/, ''))
88
+ } else {
89
+ // Keyword option like `only: [:show]`, `if: :condition`
90
+ realOptions.push(part)
91
+ }
92
+ }
93
+
94
+ filters.push({
95
+ ...filter,
96
+ options: realOptions.length > 0 ? realOptions.join(', ') : null,
97
+ })
98
+
99
+ for (const method of additionalMethods) {
100
+ const isAuthz = /^require_\w+!$/.test(method)
101
+ filters.push({
102
+ type: filter.type,
103
+ method,
104
+ ...(isAuthz ? { authorization_guard: true } : {}),
105
+ options: realOptions.length > 0 ? realOptions.join(', ') : null,
106
+ })
107
+ }
108
+ }
109
+
56
110
  // Actions (public methods before private/protected) with line ranges
57
111
  const actions = []
58
112
  const action_line_ranges = {}
@@ -127,7 +127,7 @@ export function extractModel(provider, filePath, className) {
127
127
  }
128
128
  while ((m = extendRe.exec(content))) {
129
129
  const mod = m[1]
130
- if (mod !== 'ActiveSupport::Concern' && mod !== 'FriendlyId') {
130
+ if (mod !== 'ActiveSupport::Concern') {
131
131
  extends_.push(mod)
132
132
  }
133
133
  }
@@ -175,6 +175,20 @@ 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
+ }
182
+ // Old-style validators: validates_presence_of, validates_length_of, etc.
183
+ const oldStyleRe = /^\s*validates_(\w+?)(?:_of)?\s+:(\w+)(?:,\s*(.+))?$/gm
184
+ while ((m = oldStyleRe.exec(content))) {
185
+ const validationType = m[1]
186
+ const attr = m[2]
187
+ validations.push({
188
+ attributes: [attr],
189
+ rules: `${validationType}: true${m[3] ? ', ' + m[3] : ''}`,
190
+ })
191
+ }
178
192
 
179
193
  // Scopes — names array (backward-compat) + scope_queries dict with bodies
180
194
  const scopes = []
@@ -269,11 +283,17 @@ export function extractModel(provider, filePath, className) {
269
283
  }
270
284
  }
271
285
 
272
- // Callbacks
286
+ // Callbacks — strip inline comments before matching; skip block-only callbacks
287
+ const cbLines = content
288
+ .split('\n')
289
+ .map((l) => l.replace(/#[^{].*$/, '').trimEnd())
290
+ .join('\n')
273
291
  const callbacks = []
274
292
  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 })
293
+ while ((m = cbRe.exec(cbLines))) {
294
+ const method = m[2]
295
+ if (method === 'do' || method === '{') continue
296
+ callbacks.push({ type: m[1], method, options: m[3] || null })
277
297
  }
278
298
 
279
299
  // Delegations
@@ -376,26 +396,28 @@ export function extractModel(provider, filePath, className) {
376
396
  ? turboRefreshesMatch[1]
377
397
  : null
378
398
 
379
- // Devise modules
399
+ // Devise modules — use matchAll to handle multiple devise() calls
380
400
  let devise_modules = []
381
- const deviseMatch = content.match(MODEL_PATTERNS.devise)
382
- if (deviseMatch) {
383
- // Devise declaration can span multiple lines
401
+ const deviseGlobalRe = /^\s*devise\s+(.+)/gm
402
+ let deviseMatch
403
+ while ((deviseMatch = deviseGlobalRe.exec(content))) {
384
404
  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
405
+ // Only continue if line ends with comma (argument list continues)
406
+ const afterMatch = content.slice(deviseMatch.index + deviseMatch[0].length)
407
+ if (deviseMatch[0].trimEnd().endsWith(',')) {
408
+ const continuationLines = afterMatch.split('\n')
409
+ for (const line of continuationLines) {
410
+ const trimmed = line.trim()
411
+ if (trimmed.length === 0) continue
412
+ if (/^:/.test(trimmed) || /^,\s*:/.test(trimmed)) {
413
+ deviseStr += ' ' + trimmed
414
+ } else {
415
+ break
416
+ }
396
417
  }
397
418
  }
398
- devise_modules = (deviseStr.match(/:(\w+)/g) || []).map((s) => s.slice(1))
419
+ const modules = (deviseStr.match(/:(\w+)/g) || []).map((s) => s.slice(1))
420
+ devise_modules.push(...modules)
399
421
  }
400
422
 
401
423
  // 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 = {
@@ -66,9 +66,11 @@ export function extractTestConventions(provider, entries, gemInfo = {}) {
66
66
  pattern_reference_files: [],
67
67
  }
68
68
 
69
- // Scan spec files for convention patterns
69
+ // Scan spec/test files for convention patterns
70
70
  const specEntries = entries.filter(
71
- (e) => e.categoryName === 'testing' && e.path.endsWith('_spec.rb'),
71
+ (e) =>
72
+ e.categoryName === 'testing' &&
73
+ (e.path.endsWith('_spec.rb') || e.path.endsWith('_test.rb')),
72
74
  )
73
75
 
74
76
  // Count spec files by specCategory
@@ -319,9 +321,13 @@ function findPatternReferences(provider, specEntries) {
319
321
  const content = provider.readFile(entry.path)
320
322
  if (!content) continue
321
323
 
322
- const describeCount = (content.match(/^\s*(?:describe|context)\s/gm) || [])
323
- .length
324
- const exampleCount = (content.match(/^\s*it\s/gm) || []).length
324
+ // Handle both RSpec and Minitest structural patterns
325
+ const describeCount = (
326
+ content.match(/^\s*(?:describe|context|class\s+\w+Test)\s/gm) || []
327
+ ).length
328
+ const exampleCount = (
329
+ content.match(/^\s*(?:it\s|def\s+test_|test\s+['"])/gm) || []
330
+ ).length
325
331
 
326
332
  // Skip trivially small files
327
333
  if (exampleCount < 3) continue
@@ -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,