@reinteractive/rails-insight 1.0.1
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/LICENSE +15 -0
- package/README.md +210 -0
- package/bin/railsinsight.js +128 -0
- package/package.json +62 -0
- package/src/core/blast-radius.js +496 -0
- package/src/core/constants.js +39 -0
- package/src/core/context-loader.js +227 -0
- package/src/core/drift-detector.js +168 -0
- package/src/core/formatter.js +197 -0
- package/src/core/graph.js +510 -0
- package/src/core/indexer.js +595 -0
- package/src/core/patterns/api.js +27 -0
- package/src/core/patterns/auth.js +25 -0
- package/src/core/patterns/authorization.js +24 -0
- package/src/core/patterns/caching.js +19 -0
- package/src/core/patterns/component.js +18 -0
- package/src/core/patterns/config.js +15 -0
- package/src/core/patterns/controller.js +42 -0
- package/src/core/patterns/email.js +20 -0
- package/src/core/patterns/factory.js +31 -0
- package/src/core/patterns/gemfile.js +9 -0
- package/src/core/patterns/helper.js +10 -0
- package/src/core/patterns/job.js +12 -0
- package/src/core/patterns/model.js +123 -0
- package/src/core/patterns/realtime.js +17 -0
- package/src/core/patterns/route.js +27 -0
- package/src/core/patterns/schema.js +25 -0
- package/src/core/patterns/stimulus.js +13 -0
- package/src/core/patterns/storage.js +16 -0
- package/src/core/patterns/uploader.js +16 -0
- package/src/core/patterns/view.js +20 -0
- package/src/core/patterns/worker.js +12 -0
- package/src/core/patterns.js +27 -0
- package/src/core/scanner.js +394 -0
- package/src/core/version-detector.js +295 -0
- package/src/extractors/api.js +284 -0
- package/src/extractors/auth.js +853 -0
- package/src/extractors/authorization.js +785 -0
- package/src/extractors/caching.js +84 -0
- package/src/extractors/component.js +221 -0
- package/src/extractors/config.js +81 -0
- package/src/extractors/controller.js +273 -0
- package/src/extractors/coverage-snapshot.js +296 -0
- package/src/extractors/email.js +123 -0
- package/src/extractors/factory-registry.js +225 -0
- package/src/extractors/gemfile.js +440 -0
- package/src/extractors/helper.js +55 -0
- package/src/extractors/jobs.js +122 -0
- package/src/extractors/model.js +506 -0
- package/src/extractors/realtime.js +102 -0
- package/src/extractors/routes.js +251 -0
- package/src/extractors/schema.js +178 -0
- package/src/extractors/stimulus.js +149 -0
- package/src/extractors/storage.js +100 -0
- package/src/extractors/test-conventions.js +340 -0
- package/src/extractors/tier2.js +417 -0
- package/src/extractors/tier3.js +84 -0
- package/src/extractors/uploader.js +138 -0
- package/src/extractors/views.js +131 -0
- package/src/extractors/worker.js +62 -0
- package/src/git/diff-parser.js +132 -0
- package/src/providers/interface.js +12 -0
- package/src/providers/local-fs.js +318 -0
- package/src/server.js +71 -0
- package/src/tools/blast-radius-tools.js +129 -0
- package/src/tools/free-tools.js +44 -0
- package/src/tools/handlers/get-controller.js +93 -0
- package/src/tools/handlers/get-coverage-gaps.js +100 -0
- package/src/tools/handlers/get-deep-analysis.js +294 -0
- package/src/tools/handlers/get-domain-clusters.js +113 -0
- package/src/tools/handlers/get-factory-registry.js +43 -0
- package/src/tools/handlers/get-full-index.js +28 -0
- package/src/tools/handlers/get-model.js +108 -0
- package/src/tools/handlers/get-overview.js +153 -0
- package/src/tools/handlers/get-routes.js +18 -0
- package/src/tools/handlers/get-schema.js +40 -0
- package/src/tools/handlers/get-subgraph.js +82 -0
- package/src/tools/handlers/get-test-conventions.js +18 -0
- package/src/tools/handlers/get-well-tested-examples.js +51 -0
- package/src/tools/handlers/helpers.js +115 -0
- package/src/tools/handlers/index-project.js +36 -0
- package/src/tools/handlers/search-patterns.js +104 -0
- package/src/tools/index.js +34 -0
- package/src/tools/pro-tools.js +13 -0
- package/src/utils/file-reader.js +20 -0
- package/src/utils/inflector.js +223 -0
- package/src/utils/ruby-parser.js +115 -0
- package/src/utils/spec-style-detector.js +26 -0
- package/src/utils/token-counter.js +46 -0
- package/src/utils/yaml-parser.js +135 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { noIndex, respond } from './helpers.js'
|
|
2
|
+
import { MAX_KEY_ENTITIES } from '../../core/constants.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Register the get_overview tool.
|
|
6
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
7
|
+
* @param {Object} state - Mutable state with { index, provider, verbose }
|
|
8
|
+
*/
|
|
9
|
+
export function register(server, state) {
|
|
10
|
+
server.tool(
|
|
11
|
+
'get_overview',
|
|
12
|
+
'Project summary: Rails/Ruby versions, database, auth strategy, key models and controllers, frontend stack, file counts. Call this first.',
|
|
13
|
+
{},
|
|
14
|
+
async () => {
|
|
15
|
+
const index = state.index
|
|
16
|
+
if (!index) return noIndex()
|
|
17
|
+
const v = index.versions || {}
|
|
18
|
+
const config = index.extractions?.config || {}
|
|
19
|
+
const auth = index.extractions?.auth || {}
|
|
20
|
+
const authorization = index.extractions?.authorization || {}
|
|
21
|
+
const caching = index.extractions?.caching || {}
|
|
22
|
+
const models = index.extractions?.models || {}
|
|
23
|
+
const controllers = index.extractions?.controllers || {}
|
|
24
|
+
const tier2 = index.extractions?.tier2 || {}
|
|
25
|
+
const tier3 = index.extractions?.tier3 || {}
|
|
26
|
+
|
|
27
|
+
// Auth summary
|
|
28
|
+
const authSummary = {
|
|
29
|
+
strategy: auth.primary_strategy || auth.strategy || 'none',
|
|
30
|
+
models: [],
|
|
31
|
+
features: [],
|
|
32
|
+
}
|
|
33
|
+
if (auth.native_auth) {
|
|
34
|
+
authSummary.models = ['User', 'Session', 'Current'].filter(
|
|
35
|
+
(m) => models[m],
|
|
36
|
+
)
|
|
37
|
+
if (auth.has_secure_password)
|
|
38
|
+
authSummary.features.push('has_secure_password')
|
|
39
|
+
if (models['Session']) authSummary.features.push('database_sessions')
|
|
40
|
+
if (auth.native_auth.password_reset)
|
|
41
|
+
authSummary.features.push('password_reset')
|
|
42
|
+
} else if (auth.devise) {
|
|
43
|
+
authSummary.models = Object.keys(auth.devise.models || {})
|
|
44
|
+
authSummary.features = Object.values(auth.devise.models || {}).flatMap(
|
|
45
|
+
(m) => m.modules || [],
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Authorization summary
|
|
50
|
+
const authzSummary = {
|
|
51
|
+
strategy:
|
|
52
|
+
authorization.strategy || authorization.primary_strategy || 'none',
|
|
53
|
+
library:
|
|
54
|
+
authorization.library !== undefined ? authorization.library : null,
|
|
55
|
+
roles: [],
|
|
56
|
+
enforcement: null,
|
|
57
|
+
admin_boundary: null,
|
|
58
|
+
}
|
|
59
|
+
if (authorization.role_definition?.roles) {
|
|
60
|
+
authzSummary.roles = Object.keys(authorization.role_definition.roles)
|
|
61
|
+
} else {
|
|
62
|
+
const userModel = models['User']
|
|
63
|
+
if (userModel?.enums?.role) {
|
|
64
|
+
const roleEnum = userModel.enums.role
|
|
65
|
+
authzSummary.roles = Array.isArray(roleEnum.values)
|
|
66
|
+
? roleEnum.values
|
|
67
|
+
: Object.keys(roleEnum.values || {})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (authorization.concern?.guard_methods) {
|
|
71
|
+
const guardCount = Object.keys(
|
|
72
|
+
authorization.concern.guard_methods,
|
|
73
|
+
).length
|
|
74
|
+
authzSummary.enforcement = `before_action guard methods in Authorization concern (${guardCount} guards)`
|
|
75
|
+
}
|
|
76
|
+
const adminNs = authorization.controller_enforcement_map?.admin_namespace
|
|
77
|
+
if (adminNs?.base_guard) {
|
|
78
|
+
authzSummary.admin_boundary = adminNs.base_guard
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Key models
|
|
82
|
+
const keyModels = Object.entries(models)
|
|
83
|
+
.filter(([n, m]) => m.type !== 'concern' && !m.abstract)
|
|
84
|
+
.sort(
|
|
85
|
+
(a, b) =>
|
|
86
|
+
(b[1].associations?.length || 0) - (a[1].associations?.length || 0),
|
|
87
|
+
)
|
|
88
|
+
.slice(0, MAX_KEY_ENTITIES)
|
|
89
|
+
.map(([n]) => n)
|
|
90
|
+
|
|
91
|
+
// Key controllers
|
|
92
|
+
const keyControllers = Object.entries(controllers)
|
|
93
|
+
.sort(
|
|
94
|
+
(a, b) => (b[1].actions?.length || 0) - (a[1].actions?.length || 0),
|
|
95
|
+
)
|
|
96
|
+
.slice(0, MAX_KEY_ENTITIES)
|
|
97
|
+
.map(([n]) => n)
|
|
98
|
+
|
|
99
|
+
// Custom pattern counts
|
|
100
|
+
const customPatterns = {
|
|
101
|
+
services: tier2.services?.length || tier2.service_objects?.length || 0,
|
|
102
|
+
concerns: Object.values(models).filter((m) => m.type === 'concern')
|
|
103
|
+
.length,
|
|
104
|
+
form_objects: tier2.form_objects?.length || 0,
|
|
105
|
+
presenters: tier2.presenters?.length || 0,
|
|
106
|
+
policies: tier3.policies?.count || 0,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const overview = {
|
|
110
|
+
rails_version: v.rails || 'unknown',
|
|
111
|
+
ruby_version: v.ruby || 'unknown',
|
|
112
|
+
database: config.database || v.database || 'unknown',
|
|
113
|
+
asset_pipeline: v.asset_pipeline || 'unknown',
|
|
114
|
+
frontend_stack: v.frontend || [],
|
|
115
|
+
authentication: authSummary,
|
|
116
|
+
authorization: authzSummary,
|
|
117
|
+
job_adapter: config.queue_adapter || 'unknown',
|
|
118
|
+
cache_store: caching.store || 'unknown',
|
|
119
|
+
test_framework: v.test_framework || 'unknown',
|
|
120
|
+
key_models: keyModels,
|
|
121
|
+
key_controllers: keyControllers,
|
|
122
|
+
custom_patterns: customPatterns,
|
|
123
|
+
file_counts: index.statistics || {},
|
|
124
|
+
workers: {
|
|
125
|
+
sidekiq_native_count: Object.keys(index.extractions?.workers || {})
|
|
126
|
+
.length,
|
|
127
|
+
queues: [
|
|
128
|
+
...new Set(
|
|
129
|
+
Object.values(index.extractions?.workers || {}).map(
|
|
130
|
+
(w) => w.queue,
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
helpers: {
|
|
136
|
+
count: Object.keys(index.extractions?.helpers || {}).length,
|
|
137
|
+
},
|
|
138
|
+
uploaders: {
|
|
139
|
+
count: Object.keys(index.extractions?.uploaders?.uploaders || {})
|
|
140
|
+
.length,
|
|
141
|
+
mounted: (index.extractions?.uploaders?.mounted || []).length,
|
|
142
|
+
},
|
|
143
|
+
pwa: index.pwa || { detected: false },
|
|
144
|
+
extraction_errors: (index.extraction_errors || []).length,
|
|
145
|
+
...(index.extraction_errors?.length > 0
|
|
146
|
+
? { extraction_error_details: index.extraction_errors }
|
|
147
|
+
: {}),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return respond(overview)
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { noIndex, respond } from './helpers.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register the get_routes tool.
|
|
5
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
6
|
+
* @param {Object} state - Mutable state with { index, provider, verbose }
|
|
7
|
+
*/
|
|
8
|
+
export function register(server, state) {
|
|
9
|
+
server.tool(
|
|
10
|
+
'get_routes',
|
|
11
|
+
'Complete route map with namespaces, nested resources, member/collection routes.',
|
|
12
|
+
{},
|
|
13
|
+
async () => {
|
|
14
|
+
if (!state.index) return noIndex()
|
|
15
|
+
return respond(state.index.extractions?.routes || {})
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { noIndex, respond, toTableName } from './helpers.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register the get_schema tool.
|
|
5
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
6
|
+
* @param {Object} state - Mutable state with { index, provider, verbose }
|
|
7
|
+
*/
|
|
8
|
+
export function register(server, state) {
|
|
9
|
+
server.tool(
|
|
10
|
+
'get_schema',
|
|
11
|
+
'Database schema with tables, columns, indexes, foreign keys, and model-to-table mapping.',
|
|
12
|
+
{},
|
|
13
|
+
async () => {
|
|
14
|
+
if (!state.index) return noIndex()
|
|
15
|
+
const schema = state.index.extractions?.schema || {}
|
|
16
|
+
const models = state.index.extractions?.models || {}
|
|
17
|
+
|
|
18
|
+
// Add model ↔ table mapping
|
|
19
|
+
const modelTableMap = {}
|
|
20
|
+
for (const [modelName, modelData] of Object.entries(models)) {
|
|
21
|
+
const tableName = modelData.table_name || toTableName(modelName)
|
|
22
|
+
modelTableMap[modelName] = tableName
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Add FK relationship arrows
|
|
26
|
+
const fkArrows = (schema.foreign_keys || []).map((fk) => {
|
|
27
|
+
const col =
|
|
28
|
+
fk.options?.match(/column:\s*['"]?(\w+)['"]?/)?.[1] ||
|
|
29
|
+
`${fk.to_table.replace(/s$/, '')}_id`
|
|
30
|
+
return `${fk.from_table}.${col} → ${fk.to_table}.id`
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return respond({
|
|
34
|
+
...schema,
|
|
35
|
+
model_table_map: modelTableMap,
|
|
36
|
+
fk_arrows: fkArrows,
|
|
37
|
+
})
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { noIndex, respond } from './helpers.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Register the get_subgraph tool.
|
|
6
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
7
|
+
* @param {Object} state - Mutable state with { index, provider, verbose }
|
|
8
|
+
*/
|
|
9
|
+
export function register(server, state) {
|
|
10
|
+
server.tool(
|
|
11
|
+
'get_subgraph',
|
|
12
|
+
'Skill-scoped relationship subgraph with ranked files. Skills: authentication, database, frontend, api, jobs, email.',
|
|
13
|
+
{
|
|
14
|
+
skill: z
|
|
15
|
+
.string()
|
|
16
|
+
.describe(
|
|
17
|
+
'Skill domain (e.g. "authentication", "database", "frontend", "api")',
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
async ({ skill }) => {
|
|
21
|
+
if (!state.index) return noIndex()
|
|
22
|
+
|
|
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
|
+
}
|
|
39
|
+
|
|
40
|
+
const domains = skillDomains[skill]
|
|
41
|
+
if (!domains) {
|
|
42
|
+
return respond({
|
|
43
|
+
error: `Unknown skill '${skill}'`,
|
|
44
|
+
available: Object.keys(skillDomains),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const allRels = state.index.relationships || []
|
|
49
|
+
const rankings = state.index.rankings || {}
|
|
50
|
+
const relevantEntities = new Set()
|
|
51
|
+
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)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const subgraphRels = allRels.filter(
|
|
67
|
+
(r) => relevantEntities.has(r.from) || relevantEntities.has(r.to),
|
|
68
|
+
)
|
|
69
|
+
const rankedFiles = [...relevantEntities]
|
|
70
|
+
.map((e) => ({ entity: e, rank: rankings[e] || 0 }))
|
|
71
|
+
.sort((a, b) => b.rank - a.rank)
|
|
72
|
+
|
|
73
|
+
return respond({
|
|
74
|
+
skill,
|
|
75
|
+
entities: rankedFiles,
|
|
76
|
+
relationships: subgraphRels,
|
|
77
|
+
total_entities: rankedFiles.length,
|
|
78
|
+
total_relationships: subgraphRels.length,
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { noIndex, respond } from './helpers.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register the get_test_conventions tool.
|
|
5
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
6
|
+
* @param {Object} state - Mutable state with { index, provider, verbose }
|
|
7
|
+
*/
|
|
8
|
+
export function register(server, state) {
|
|
9
|
+
server.tool(
|
|
10
|
+
'get_test_conventions',
|
|
11
|
+
'Returns detected test patterns and conventions: spec style (request vs controller), let style, auth helper, factories, shared examples, custom matchers, and pattern reference files.',
|
|
12
|
+
{},
|
|
13
|
+
async () => {
|
|
14
|
+
if (!state.index) return noIndex()
|
|
15
|
+
return respond(state.index.extractions?.test_conventions || {})
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { noIndex, respond } from './helpers.js'
|
|
3
|
+
import { MAX_EXAMPLE_CONTENT_LENGTH } from '../../core/constants.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register the get_well_tested_examples tool.
|
|
7
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
8
|
+
* @param {Object} state - Mutable state with { index, provider, verbose }
|
|
9
|
+
*/
|
|
10
|
+
export function register(server, state) {
|
|
11
|
+
server.tool(
|
|
12
|
+
'get_well_tested_examples',
|
|
13
|
+
'Returns high-quality existing spec files suitable as pattern references for test generation agents. Selected by structural complexity (most describe/context blocks) per spec category.',
|
|
14
|
+
{
|
|
15
|
+
category: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe(
|
|
19
|
+
'Filter by spec category (e.g. "model_specs", "request_specs")',
|
|
20
|
+
),
|
|
21
|
+
limit: z
|
|
22
|
+
.number()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('Maximum results to return (default: 3)'),
|
|
25
|
+
},
|
|
26
|
+
async ({ category, limit = 3 }) => {
|
|
27
|
+
if (!state.index) return noIndex()
|
|
28
|
+
const conventions = state.index.extractions?.test_conventions || {}
|
|
29
|
+
let refs = conventions.pattern_reference_files || []
|
|
30
|
+
|
|
31
|
+
if (category) {
|
|
32
|
+
refs = refs.filter((r) => r.category === category)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const results = refs.slice(0, limit).map((ref) => {
|
|
36
|
+
const content = state.provider?.readFile(ref.path) || null
|
|
37
|
+
return {
|
|
38
|
+
...ref,
|
|
39
|
+
content: content
|
|
40
|
+
? content.slice(0, MAX_EXAMPLE_CONTENT_LENGTH)
|
|
41
|
+
: null,
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return respond({
|
|
46
|
+
examples: results,
|
|
47
|
+
total_available: refs.length,
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers and constants for tool handlers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { tableize } from '../../utils/inflector.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert a PascalCase model name to a snake_case plural table name.
|
|
9
|
+
* @param {string} name e.g. "UserProfile"
|
|
10
|
+
* @returns {string} e.g. "user_profiles"
|
|
11
|
+
*/
|
|
12
|
+
export function toTableName(name) {
|
|
13
|
+
return tableize(name)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert a file path to a Ruby-style class name.
|
|
18
|
+
* @param {string} path
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function pathToClassName(path) {
|
|
22
|
+
const basename = path.split('/').pop().replace('.rb', '')
|
|
23
|
+
return basename
|
|
24
|
+
.split('_')
|
|
25
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
26
|
+
.join('')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** MCP response when no index has been built yet. */
|
|
30
|
+
export function noIndex() {
|
|
31
|
+
return respondError('Index not built. Call index_project first.')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wrap data as an MCP text response. */
|
|
35
|
+
export function respond(data) {
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wrap an error as an MCP error response.
|
|
43
|
+
* @param {string} message - Error message
|
|
44
|
+
* @param {Object} [details] - Additional details
|
|
45
|
+
* @returns {Object} MCP response with isError flag
|
|
46
|
+
*/
|
|
47
|
+
export function respondError(message, details = {}) {
|
|
48
|
+
return {
|
|
49
|
+
content: [
|
|
50
|
+
{ type: 'text', text: JSON.stringify({ error: message, ...details }) },
|
|
51
|
+
],
|
|
52
|
+
isError: true,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Architecturally significant gem categories (for slimmed dependencies output). */
|
|
57
|
+
export const SIGNIFICANT_CATEGORIES = new Set([
|
|
58
|
+
'core',
|
|
59
|
+
'frontend',
|
|
60
|
+
'auth',
|
|
61
|
+
'authorization',
|
|
62
|
+
'background',
|
|
63
|
+
'caching',
|
|
64
|
+
'realtime',
|
|
65
|
+
'testing',
|
|
66
|
+
'deployment',
|
|
67
|
+
'search',
|
|
68
|
+
'admin',
|
|
69
|
+
'payments',
|
|
70
|
+
'monitoring',
|
|
71
|
+
'api',
|
|
72
|
+
'data',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/** Gems to always drop even if in significant categories. */
|
|
76
|
+
export const DROP_GEMS = new Set([
|
|
77
|
+
'railties',
|
|
78
|
+
'activesupport',
|
|
79
|
+
'activerecord',
|
|
80
|
+
'actionpack',
|
|
81
|
+
'actionview',
|
|
82
|
+
'actionmailer',
|
|
83
|
+
'activejob',
|
|
84
|
+
'actioncable',
|
|
85
|
+
'activestorage',
|
|
86
|
+
'actiontext',
|
|
87
|
+
'actionmailbox',
|
|
88
|
+
'tzinfo-data',
|
|
89
|
+
'sprockets',
|
|
90
|
+
'sprockets-rails',
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
/** Well-known absent gems worth noting. */
|
|
94
|
+
export const NOTABLE_ABSENT_CANDIDATES = [
|
|
95
|
+
'devise',
|
|
96
|
+
'pundit',
|
|
97
|
+
'cancancan',
|
|
98
|
+
'sidekiq',
|
|
99
|
+
'redis',
|
|
100
|
+
'activeadmin',
|
|
101
|
+
'administrate',
|
|
102
|
+
'jbuilder',
|
|
103
|
+
'grape',
|
|
104
|
+
'graphql',
|
|
105
|
+
'elasticsearch-rails',
|
|
106
|
+
'searchkick',
|
|
107
|
+
'meilisearch-rails',
|
|
108
|
+
'stripe',
|
|
109
|
+
'pay',
|
|
110
|
+
'sentry-rails',
|
|
111
|
+
'newrelic_rpm',
|
|
112
|
+
'rack-attack',
|
|
113
|
+
'paper_trail',
|
|
114
|
+
'audited',
|
|
115
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { buildIndex } from '../../core/indexer.js'
|
|
3
|
+
import { respond } from './helpers.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register the index_project tool.
|
|
7
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
8
|
+
* @param {Object} state - Mutable state with { index, provider, verbose }
|
|
9
|
+
*/
|
|
10
|
+
export function register(server, state) {
|
|
11
|
+
server.tool(
|
|
12
|
+
'index_project',
|
|
13
|
+
'Re-index the Rails project. In local mode, re-scans the project root. Returns statistics and duration.',
|
|
14
|
+
{
|
|
15
|
+
force: z
|
|
16
|
+
.boolean()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Force full re-index even if cached'),
|
|
19
|
+
},
|
|
20
|
+
async ({ force }) => {
|
|
21
|
+
if (!state.provider) {
|
|
22
|
+
return respond({
|
|
23
|
+
error: 'No project root configured. Start with --project-root.',
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
const start = Date.now()
|
|
27
|
+
state.index = await buildIndex(state.provider, { verbose: state.verbose })
|
|
28
|
+
const duration_ms = Date.now() - start
|
|
29
|
+
return respond({
|
|
30
|
+
status: 'success',
|
|
31
|
+
statistics: state.index.statistics,
|
|
32
|
+
duration_ms,
|
|
33
|
+
})
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { noIndex, respond } from './helpers.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Register the search_patterns tool.
|
|
6
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
7
|
+
* @param {Object} state - Mutable state with { index, provider, verbose }
|
|
8
|
+
*/
|
|
9
|
+
export function register(server, state) {
|
|
10
|
+
server.tool(
|
|
11
|
+
'search_patterns',
|
|
12
|
+
'Search across all extractions for a specific Rails pattern type (e.g. "has_many_through", "before_action", "turbo_broadcast").',
|
|
13
|
+
{
|
|
14
|
+
pattern: z
|
|
15
|
+
.string()
|
|
16
|
+
.describe(
|
|
17
|
+
'Pattern type to search for (e.g. "has_many_through", "devise_confirmable")',
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
async ({ pattern }) => {
|
|
21
|
+
if (!state.index) return noIndex()
|
|
22
|
+
|
|
23
|
+
const results = []
|
|
24
|
+
const extractions = state.index.extractions || {}
|
|
25
|
+
const lowerPattern = pattern.toLowerCase()
|
|
26
|
+
|
|
27
|
+
for (const [name, model] of Object.entries(extractions.models || {})) {
|
|
28
|
+
const matches = []
|
|
29
|
+
if (model.associations) {
|
|
30
|
+
for (const assoc of model.associations) {
|
|
31
|
+
const assocType = assoc.type?.replace(':', '') || ''
|
|
32
|
+
if (
|
|
33
|
+
assocType.includes(lowerPattern) ||
|
|
34
|
+
`${assocType}_${assoc.through || ''}`.includes(lowerPattern)
|
|
35
|
+
) {
|
|
36
|
+
matches.push({ type: 'association', detail: assoc })
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
lowerPattern === 'has_many_through' &&
|
|
40
|
+
assocType === 'has_many' &&
|
|
41
|
+
assoc.through
|
|
42
|
+
) {
|
|
43
|
+
matches.push({ type: 'has_many_through', detail: assoc })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (model.callbacks) {
|
|
48
|
+
for (const cb of model.callbacks) {
|
|
49
|
+
if (
|
|
50
|
+
cb.type?.toLowerCase().includes(lowerPattern) ||
|
|
51
|
+
cb.method?.toLowerCase().includes(lowerPattern)
|
|
52
|
+
) {
|
|
53
|
+
matches.push({ type: 'callback', detail: cb })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (model.concerns) {
|
|
58
|
+
for (const concern of model.concerns) {
|
|
59
|
+
if (concern.toLowerCase().includes(lowerPattern))
|
|
60
|
+
matches.push({ type: 'concern', detail: concern })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (lowerPattern.startsWith('devise') && model.devise_modules) {
|
|
64
|
+
const moduleName = lowerPattern.replace('devise_', '')
|
|
65
|
+
if (model.devise_modules.includes(moduleName))
|
|
66
|
+
matches.push({ type: 'devise_module', detail: moduleName })
|
|
67
|
+
}
|
|
68
|
+
if (model.enums && lowerPattern.includes('enum')) {
|
|
69
|
+
for (const [enumName, enumData] of Object.entries(model.enums)) {
|
|
70
|
+
matches.push({
|
|
71
|
+
type: 'enum',
|
|
72
|
+
detail: { name: enumName, ...enumData },
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (lowerPattern.includes('broadcast') && model.broadcasts) {
|
|
77
|
+
matches.push({ type: 'broadcast', detail: model.broadcasts })
|
|
78
|
+
}
|
|
79
|
+
if (matches.length > 0)
|
|
80
|
+
results.push({ entity: name, entity_type: 'model', matches })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [name, ctrl] of Object.entries(
|
|
84
|
+
extractions.controllers || {},
|
|
85
|
+
)) {
|
|
86
|
+
const matches = []
|
|
87
|
+
const filters = ctrl.filters || []
|
|
88
|
+
for (const f of filters) {
|
|
89
|
+
const filterStr = typeof f === 'string' ? f : f.name || f.method || ''
|
|
90
|
+
if (filterStr.toLowerCase().includes(lowerPattern))
|
|
91
|
+
matches.push({ type: 'filter', detail: f })
|
|
92
|
+
}
|
|
93
|
+
if (matches.length > 0)
|
|
94
|
+
results.push({ entity: name, entity_type: 'controller', matches })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return respond({
|
|
98
|
+
pattern,
|
|
99
|
+
results,
|
|
100
|
+
total_matches: results.reduce((sum, r) => sum + r.matches.length, 0),
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central tool registration with tier gating.
|
|
3
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
4
|
+
* @param {Object} options
|
|
5
|
+
* @param {Object} [options.index] - Pre-built index
|
|
6
|
+
* @param {import('../providers/interface.js').FileProvider} [options.provider] - File provider
|
|
7
|
+
* @param {string} [options.tier] - 'free' | 'pro' | 'team'
|
|
8
|
+
* @param {boolean} [options.verbose] - Verbose logging
|
|
9
|
+
*/
|
|
10
|
+
import { registerFreeTools } from './free-tools.js'
|
|
11
|
+
import { registerProTools } from './pro-tools.js'
|
|
12
|
+
import { registerBlastRadiusTools } from './blast-radius-tools.js'
|
|
13
|
+
|
|
14
|
+
export function registerTools(server, options) {
|
|
15
|
+
const tier = options.tier || 'free'
|
|
16
|
+
|
|
17
|
+
// Mutable state shared between tools
|
|
18
|
+
const state = {
|
|
19
|
+
index: options.index || null,
|
|
20
|
+
provider: options.provider || null,
|
|
21
|
+
verbose: options.verbose || false,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Always register all primary tools (they are all in free-tools now)
|
|
25
|
+
registerFreeTools(server, state)
|
|
26
|
+
|
|
27
|
+
// Blast radius analysis tools
|
|
28
|
+
registerBlastRadiusTools(server, state)
|
|
29
|
+
|
|
30
|
+
// registerProTools is now a no-op stub kept for compatibility
|
|
31
|
+
if (tier === 'pro' || tier === 'team') {
|
|
32
|
+
registerProTools(server, state)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pro tier tools — kept for compatibility.
|
|
3
|
+
* All tools have been consolidated into registerFreeTools in free-tools.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
8
|
+
* @param {Object} state
|
|
9
|
+
*/
|
|
10
|
+
export function registerProTools(server, state) {
|
|
11
|
+
// All tools are now registered via registerFreeTools in free-tools.js.
|
|
12
|
+
// This function is kept for backward compatibility.
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe file reading utility.
|
|
3
|
+
* Wraps FileProvider readFile with encoding handling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Safely read file content, stripping BOM if present.
|
|
8
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
9
|
+
* @param {string} relativePath
|
|
10
|
+
* @returns {string|null}
|
|
11
|
+
*/
|
|
12
|
+
export function safeReadFile(provider, relativePath) {
|
|
13
|
+
const content = provider.readFile(relativePath)
|
|
14
|
+
if (content === null) return null
|
|
15
|
+
// Strip UTF-8 BOM
|
|
16
|
+
if (content.charCodeAt(0) === 0xfeff) {
|
|
17
|
+
return content.slice(1)
|
|
18
|
+
}
|
|
19
|
+
return content
|
|
20
|
+
}
|