@reinteractive/rails-insight 1.0.5 → 1.0.7
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/README.md +6 -5
- package/package.json +1 -1
- package/src/extractors/model.js +97 -11
- package/src/server.js +22 -11
- package/src/tools/index.js +2 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# RailsInsight
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@reinteractive/rails-insight)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
6
|
A Rails-aware codebase indexer that runs as an MCP (Model Context Protocol) server, giving AI coding agents deep structural understanding of your Rails application — models, associations, routes, schema, authentication, jobs, components, and 56 total file categories — without reading every file.
|
|
@@ -57,7 +57,7 @@ Add to your Claude Code MCP configuration:
|
|
|
57
57
|
"mcpServers": {
|
|
58
58
|
"railsinsight": {
|
|
59
59
|
"command": "npx",
|
|
60
|
-
"args": ["@reinteractive/rails-insight"]
|
|
60
|
+
"args": ["-y", "@reinteractive/rails-insight"]
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -72,7 +72,7 @@ In your `.cursor/mcp.json`:
|
|
|
72
72
|
"mcpServers": {
|
|
73
73
|
"railsinsight": {
|
|
74
74
|
"command": "npx",
|
|
75
|
-
"args": ["@reinteractive/rails-insight"]
|
|
75
|
+
"args": ["-y", "@reinteractive/rails-insight"]
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -88,14 +88,15 @@ In your VS Code `.mcp.json` file:
|
|
|
88
88
|
{
|
|
89
89
|
"servers": {
|
|
90
90
|
"railsinsight": {
|
|
91
|
+
"type": "stdio",
|
|
91
92
|
"command": "npx",
|
|
92
|
-
"args": ["@reinteractive/
|
|
93
|
+
"args": ["-y", "@reinteractive/rails-insight"]
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
```
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
Note: VS Code MCP configuration uses the `servers` block (not `mcpServers` as used by Claude Desktop/Cursor). The `"type": "stdio"` field is required.
|
|
99
100
|
|
|
100
101
|
## Available Tools
|
|
101
102
|
|
package/package.json
CHANGED
package/src/extractors/model.js
CHANGED
|
@@ -5,6 +5,82 @@
|
|
|
5
5
|
|
|
6
6
|
import { MODEL_PATTERNS } from '../core/patterns.js'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Join lines where a declaration continues on the next line (ends with comma).
|
|
10
|
+
* This handles multi-line belongs_to, has_many, etc. options.
|
|
11
|
+
* @param {string} content
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
const STATEMENT_START =
|
|
15
|
+
/^(?:belongs_to|has_many|has_one|has_and_belongs_to_many|has_and_belongs|scope\s|validates?\s|def\s|class\s|module\s|end\b|include\s|extend\s|enum\s|before_|after_|around_|delegate\s|attr_|#|private\b|protected\b|accepts_nested)/
|
|
16
|
+
|
|
17
|
+
function joinContinuationLines(content) {
|
|
18
|
+
const lines = content.split('\n')
|
|
19
|
+
const joined = []
|
|
20
|
+
for (let i = 0; i < lines.length; i++) {
|
|
21
|
+
if (
|
|
22
|
+
joined.length > 0 &&
|
|
23
|
+
joined[joined.length - 1].trimEnd().endsWith(',')
|
|
24
|
+
) {
|
|
25
|
+
const nextTrimmed = lines[i].trim()
|
|
26
|
+
if (nextTrimmed && !STATEMENT_START.test(nextTrimmed)) {
|
|
27
|
+
joined[joined.length - 1] =
|
|
28
|
+
joined[joined.length - 1].trimEnd() + ' ' + nextTrimmed
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
joined.push(lines[i])
|
|
33
|
+
}
|
|
34
|
+
return joined.join('\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract scope body using brace-balanced scanning (handles nested braces and
|
|
39
|
+
* multi-line lambda/proc bodies).
|
|
40
|
+
* @param {string} content
|
|
41
|
+
* @returns {Record<string, string>} map of scope name → body string
|
|
42
|
+
*/
|
|
43
|
+
function extractScopeBodies(content) {
|
|
44
|
+
const result = {}
|
|
45
|
+
const lines = content.split('\n')
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
const declMatch = lines[i].match(
|
|
48
|
+
/^\s*scope\s+:(\w+),\s*(?:->|lambda|proc)/,
|
|
49
|
+
)
|
|
50
|
+
if (!declMatch) continue
|
|
51
|
+
const name = declMatch[1]
|
|
52
|
+
// Scan forward to find brace-balanced body
|
|
53
|
+
let depth = 0
|
|
54
|
+
let started = false
|
|
55
|
+
const bodyChars = []
|
|
56
|
+
outer: for (let j = i; j < lines.length; j++) {
|
|
57
|
+
const line = j === i ? lines[j] : lines[j]
|
|
58
|
+
for (const ch of line) {
|
|
59
|
+
if (ch === '{') {
|
|
60
|
+
depth++
|
|
61
|
+
if (depth === 1) {
|
|
62
|
+
started = true
|
|
63
|
+
continue // skip the opening brace itself
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (ch === '}') {
|
|
67
|
+
depth--
|
|
68
|
+
if (depth === 0 && started) break outer
|
|
69
|
+
}
|
|
70
|
+
if (started) bodyChars.push(ch)
|
|
71
|
+
}
|
|
72
|
+
if (started && depth > 0) bodyChars.push(' ')
|
|
73
|
+
}
|
|
74
|
+
if (bodyChars.length > 0) {
|
|
75
|
+
result[name] = bodyChars
|
|
76
|
+
.join('')
|
|
77
|
+
.replace(/\s+/g, ' ')
|
|
78
|
+
.trim()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
|
|
8
84
|
/**
|
|
9
85
|
* Extract all model information from a single model file.
|
|
10
86
|
* @param {import('../providers/interface.js').FileProvider} provider
|
|
@@ -56,8 +132,9 @@ export function extractModel(provider, filePath, className) {
|
|
|
56
132
|
}
|
|
57
133
|
}
|
|
58
134
|
|
|
59
|
-
// Associations
|
|
135
|
+
// Associations (join continuation lines first so multi-line options are captured)
|
|
60
136
|
const associations = []
|
|
137
|
+
const assocContent = joinContinuationLines(content)
|
|
61
138
|
const assocTypes = [
|
|
62
139
|
{ key: 'belongsTo', type: 'belongs_to' },
|
|
63
140
|
{ key: 'hasMany', type: 'has_many' },
|
|
@@ -66,7 +143,7 @@ export function extractModel(provider, filePath, className) {
|
|
|
66
143
|
]
|
|
67
144
|
for (const { key, type } of assocTypes) {
|
|
68
145
|
const re = new RegExp(MODEL_PATTERNS[key].source, 'gm')
|
|
69
|
-
while ((m = re.exec(
|
|
146
|
+
while ((m = re.exec(assocContent))) {
|
|
70
147
|
const entry = { type, name: m[1], options: m[2] || null }
|
|
71
148
|
// Check for through
|
|
72
149
|
if (entry.options) {
|
|
@@ -102,17 +179,16 @@ export function extractModel(provider, filePath, className) {
|
|
|
102
179
|
// Scopes — names array (backward-compat) + scope_queries dict with bodies
|
|
103
180
|
const scopes = []
|
|
104
181
|
const scope_queries = {}
|
|
105
|
-
// Extended pattern: capture the body inside { } after ->
|
|
106
|
-
const scopeBodyRe =
|
|
107
|
-
/^\s*scope\s+:(\w+),\s*->\s*(?:\([^)]*\)\s*)?\{\s*([^}]+)\}/gm
|
|
108
|
-
const scopeSimpleRe = new RegExp(MODEL_PATTERNS.scope.source, 'gm')
|
|
109
182
|
const scopeNamesFound = new Set()
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
183
|
+
// Use brace-balanced extractor for scope bodies (handles multi-line and nested braces)
|
|
184
|
+
const extractedBodies = extractScopeBodies(content)
|
|
185
|
+
for (const [name, body] of Object.entries(extractedBodies)) {
|
|
186
|
+
scopes.push(name)
|
|
187
|
+
scope_queries[name] = body
|
|
188
|
+
scopeNamesFound.add(name)
|
|
114
189
|
}
|
|
115
190
|
// Fall back to name-only for scopes we couldn't extract a body from
|
|
191
|
+
const scopeSimpleRe = new RegExp(MODEL_PATTERNS.scope.source, 'gm')
|
|
116
192
|
while ((m = scopeSimpleRe.exec(content))) {
|
|
117
193
|
if (!scopeNamesFound.has(m[1])) scopes.push(m[1])
|
|
118
194
|
}
|
|
@@ -366,6 +442,14 @@ export function extractModel(provider, filePath, className) {
|
|
|
366
442
|
|
|
367
443
|
// Audited
|
|
368
444
|
const audited = MODEL_PATTERNS.audited.test(content)
|
|
445
|
+
const has_associated_audits = /^\s*has_associated_audits/m.test(content)
|
|
446
|
+
|
|
447
|
+
// accepts_nested_attributes_for
|
|
448
|
+
const nested_attributes = []
|
|
449
|
+
const nestedAttrsRe = /^\s*accepts_nested_attributes_for\s+:(\w+)(?:,\s*(.+))?$/gm
|
|
450
|
+
while ((m = nestedAttrsRe.exec(content))) {
|
|
451
|
+
nested_attributes.push({ name: m[1], options: m[2]?.trim() || null })
|
|
452
|
+
}
|
|
369
453
|
|
|
370
454
|
// STI base detection (has subclasses inheriting from this, detected elsewhere)
|
|
371
455
|
const sti_base = false
|
|
@@ -396,7 +480,7 @@ export function extractModel(provider, filePath, className) {
|
|
|
396
480
|
continue
|
|
397
481
|
}
|
|
398
482
|
|
|
399
|
-
const mm = line.match(/^\s*def\s+(
|
|
483
|
+
const mm = line.match(/^\s*def\s+((?:self\.)?\w+[?!=]?)/)
|
|
400
484
|
if (mm) {
|
|
401
485
|
// Close previous method
|
|
402
486
|
if (currentMethodName && !inPrivate) {
|
|
@@ -485,6 +569,8 @@ export function extractModel(provider, filePath, className) {
|
|
|
485
569
|
state_machine,
|
|
486
570
|
paper_trail,
|
|
487
571
|
audited,
|
|
572
|
+
has_associated_audits,
|
|
573
|
+
nested_attributes,
|
|
488
574
|
public_methods,
|
|
489
575
|
method_line_ranges,
|
|
490
576
|
}
|
package/src/server.js
CHANGED
|
@@ -38,6 +38,25 @@ export async function startLocal(projectRoot, options = {}) {
|
|
|
38
38
|
const provider = new LocalFSProvider(projectRoot)
|
|
39
39
|
const verbose = options.verbose || false
|
|
40
40
|
|
|
41
|
+
// Connect the transport immediately so VS Code's MCP handshake completes
|
|
42
|
+
// without waiting for the index to be built. Tools return a "not ready"
|
|
43
|
+
// response until state.index is populated below.
|
|
44
|
+
const server = new McpServer({
|
|
45
|
+
name: 'railsinsight',
|
|
46
|
+
version: PKG_VERSION,
|
|
47
|
+
capabilities: { tools: {} },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const state = registerTools(server, {
|
|
51
|
+
index: null,
|
|
52
|
+
provider,
|
|
53
|
+
tier: options.tier || 'pro',
|
|
54
|
+
verbose,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const transport = new StdioServerTransport()
|
|
58
|
+
await server.connect(transport)
|
|
59
|
+
|
|
41
60
|
if (verbose) {
|
|
42
61
|
process.stderr.write(`[railsinsight] Indexing ${projectRoot}...\n`)
|
|
43
62
|
}
|
|
@@ -47,19 +66,11 @@ export async function startLocal(projectRoot, options = {}) {
|
|
|
47
66
|
verbose,
|
|
48
67
|
})
|
|
49
68
|
|
|
69
|
+
state.index = index
|
|
70
|
+
|
|
50
71
|
if (verbose) {
|
|
51
|
-
process.stderr.write(`[railsinsight] Index built
|
|
72
|
+
process.stderr.write(`[railsinsight] Index built.\n`)
|
|
52
73
|
}
|
|
53
|
-
|
|
54
|
-
const server = createServer({
|
|
55
|
-
index,
|
|
56
|
-
provider,
|
|
57
|
-
tier: options.tier || 'pro',
|
|
58
|
-
verbose,
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
const transport = new StdioServerTransport()
|
|
62
|
-
await server.connect(transport)
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
/**
|