@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 +2 -2
- package/src/core/blast-radius.js +1 -1
- package/src/core/indexer.js +11 -3
- package/src/core/patterns/authorization.js +1 -1
- package/src/core/patterns/gemfile.js +1 -1
- package/src/core/patterns/model.js +2 -0
- package/src/core/patterns/realtime.js +1 -1
- package/src/core/patterns/route.js +1 -1
- package/src/core/version-detector.js +10 -4
- package/src/extractors/auth.js +33 -1
- package/src/extractors/authorization.js +43 -2
- package/src/extractors/caching.js +3 -1
- package/src/extractors/config.js +29 -2
- package/src/extractors/controller.js +56 -2
- package/src/extractors/model.js +42 -20
- package/src/extractors/realtime.js +5 -10
- package/src/extractors/routes.js +23 -5
- package/src/extractors/storage.js +34 -4
- package/src/extractors/test-conventions.js +11 -5
- 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.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
|
+
}
|
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
|
@@ -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 (
|
|
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
|
-
|
|
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.
|
|
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+(
|
|
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,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+['"]
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
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,10 +575,36 @@ 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
|
+
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
|
|
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.
|
|
@@ -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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 = {}
|
package/src/extractors/model.js
CHANGED
|
@@ -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'
|
|
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(
|
|
276
|
-
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
401
|
+
const deviseGlobalRe = /^\s*devise\s+(.+)/gm
|
|
402
|
+
let deviseMatch
|
|
403
|
+
while ((deviseMatch = deviseGlobalRe.exec(content))) {
|
|
384
404
|
let deviseStr = deviseMatch[1]
|
|
385
|
-
//
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
@@ -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) =>
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
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,
|