@reinteractive/rails-insight 1.0.12 → 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.12",
3
+ "version": "1.0.13",
4
4
  "description": "Rails-aware codebase indexer — MCP server for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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),
@@ -585,6 +589,7 @@ function computeStatistics(manifest, extractions, relationships) {
585
589
  models: Object.values(extractions.models || {}).filter(
586
590
  (m) => m.type !== 'concern' && !m.abstract,
587
591
  ).length,
592
+ models_in_manifest: (manifest.stats || {}).models || 0,
588
593
  controllers: Object.keys(extractions.controllers || {}).length,
589
594
  components: Object.keys(extractions.components || {}).length,
590
595
  relationships: relationships.length,
@@ -22,6 +22,7 @@ export const MODEL_PATTERNS = {
22
22
  validates: /^\s*validates?\s+:?(\w+(?:,\s*:\w+)*)(?:,\s*(.+))?$/m,
23
23
  validate: /^\s*validate\s+:(\w+)/m,
24
24
  validatesWithValidator: /^\s*validates_with\s+(\S+)(?:,\s*(.+))?$/m,
25
+ validatesOldStyle: /^\s*validates_(\w+?)(?:_of)?\s+:(\w+)(?:,\s*(.+))?$/m,
25
26
 
26
27
  // === SCOPES ===
27
28
  scope: /^\s*scope\s+:(\w+),\s*(?:->|lambda|proc)/m,
@@ -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,
@@ -242,9 +242,13 @@ function detectFramework(gemfile, gems, appConfig, provider) {
242
242
  let cacheStore = null
243
243
  if (hasGem('solid_cache')) cacheStore = 'solid_cache'
244
244
  else if (hasGem('redis')) cacheStore = 'redis'
245
- // Also check config
246
- const prodConfig =
245
+ // Also check config — strip comment lines first to avoid false positives
246
+ const prodConfigRaw =
247
247
  provider.readFile('config/environments/production.rb') || ''
248
+ const prodConfig = prodConfigRaw
249
+ .split('\n')
250
+ .filter((l) => !l.trim().startsWith('#'))
251
+ .join('\n')
248
252
  const cacheStoreMatch = prodConfig.match(/config\.cache_store\s*=\s*:(\w+)/)
249
253
  if (cacheStoreMatch) cacheStore = cacheStoreMatch[1]
250
254
 
@@ -576,28 +576,35 @@ export function extractAuthorization(
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
579
+ let abilityFile = 'app/models/ability.rb'
580
+ // Fallback: scan model and authorization files for CanCan::Ability
580
581
  if (!abilityContent || !AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent)) {
581
- const modelEntries = entries.filter(
582
+ const abilityEntries = entries.filter(
582
583
  (e) =>
583
- (e.category === 'model' || e.categoryName === 'models') &&
584
+ (e.category === 'model' ||
585
+ e.categoryName === 'models' ||
586
+ e.category === 1 ||
587
+ e.categoryName === 'authorization' ||
588
+ e.category === 9) &&
584
589
  e.path.endsWith('.rb'),
585
590
  )
586
- for (const entry of modelEntries) {
591
+ for (const entry of abilityEntries) {
587
592
  const c = provider.readFile(entry.path)
588
593
  if (
589
594
  c &&
590
- AUTHORIZATION_PATTERNS.abilityClass.test(c) &&
591
- AUTHORIZATION_PATTERNS.includeCanCan.test(c)
595
+ (AUTHORIZATION_PATTERNS.abilityClass.test(c) ||
596
+ AUTHORIZATION_PATTERNS.includeCanCan.test(c))
592
597
  ) {
593
598
  abilityContent = c
599
+ abilityFile = entry.path
594
600
  break
595
601
  }
596
602
  }
597
603
  }
598
604
  if (
599
605
  abilityContent &&
600
- AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent)
606
+ (AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent) ||
607
+ AUTHORIZATION_PATTERNS.includeCanCan.test(abilityContent))
601
608
  ) {
602
609
  const abilities = []
603
610
  const canRe = new RegExp(AUTHORIZATION_PATTERNS.canDef.source, 'gm')
@@ -610,6 +617,21 @@ export function extractAuthorization(
610
617
  abilities.push({ type: 'cannot', definition: m[1].trim() })
611
618
  }
612
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
+ }
613
635
  }
614
636
  }
615
637
 
@@ -62,6 +62,31 @@ export function extractConfig(provider) {
62
62
  }
63
63
  }
64
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
+
65
90
  // config/environments/*.rb
66
91
  for (const env of ['production', 'development', 'test']) {
67
92
  const content = provider.readFile(`config/environments/${env}.rb`)
@@ -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
  }
@@ -179,6 +179,16 @@ export function extractModel(provider, filePath, className) {
179
179
  while ((m = vwRe.exec(content))) {
180
180
  custom_validators.push(`validates_with:${m[1]}`)
181
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
+ }
182
192
 
183
193
  // Scopes — names array (backward-compat) + scope_queries dict with bodies
184
194
  const scopes = []
@@ -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