@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.
Files changed (90) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +210 -0
  3. package/bin/railsinsight.js +128 -0
  4. package/package.json +62 -0
  5. package/src/core/blast-radius.js +496 -0
  6. package/src/core/constants.js +39 -0
  7. package/src/core/context-loader.js +227 -0
  8. package/src/core/drift-detector.js +168 -0
  9. package/src/core/formatter.js +197 -0
  10. package/src/core/graph.js +510 -0
  11. package/src/core/indexer.js +595 -0
  12. package/src/core/patterns/api.js +27 -0
  13. package/src/core/patterns/auth.js +25 -0
  14. package/src/core/patterns/authorization.js +24 -0
  15. package/src/core/patterns/caching.js +19 -0
  16. package/src/core/patterns/component.js +18 -0
  17. package/src/core/patterns/config.js +15 -0
  18. package/src/core/patterns/controller.js +42 -0
  19. package/src/core/patterns/email.js +20 -0
  20. package/src/core/patterns/factory.js +31 -0
  21. package/src/core/patterns/gemfile.js +9 -0
  22. package/src/core/patterns/helper.js +10 -0
  23. package/src/core/patterns/job.js +12 -0
  24. package/src/core/patterns/model.js +123 -0
  25. package/src/core/patterns/realtime.js +17 -0
  26. package/src/core/patterns/route.js +27 -0
  27. package/src/core/patterns/schema.js +25 -0
  28. package/src/core/patterns/stimulus.js +13 -0
  29. package/src/core/patterns/storage.js +16 -0
  30. package/src/core/patterns/uploader.js +16 -0
  31. package/src/core/patterns/view.js +20 -0
  32. package/src/core/patterns/worker.js +12 -0
  33. package/src/core/patterns.js +27 -0
  34. package/src/core/scanner.js +394 -0
  35. package/src/core/version-detector.js +295 -0
  36. package/src/extractors/api.js +284 -0
  37. package/src/extractors/auth.js +853 -0
  38. package/src/extractors/authorization.js +785 -0
  39. package/src/extractors/caching.js +84 -0
  40. package/src/extractors/component.js +221 -0
  41. package/src/extractors/config.js +81 -0
  42. package/src/extractors/controller.js +273 -0
  43. package/src/extractors/coverage-snapshot.js +296 -0
  44. package/src/extractors/email.js +123 -0
  45. package/src/extractors/factory-registry.js +225 -0
  46. package/src/extractors/gemfile.js +440 -0
  47. package/src/extractors/helper.js +55 -0
  48. package/src/extractors/jobs.js +122 -0
  49. package/src/extractors/model.js +506 -0
  50. package/src/extractors/realtime.js +102 -0
  51. package/src/extractors/routes.js +251 -0
  52. package/src/extractors/schema.js +178 -0
  53. package/src/extractors/stimulus.js +149 -0
  54. package/src/extractors/storage.js +100 -0
  55. package/src/extractors/test-conventions.js +340 -0
  56. package/src/extractors/tier2.js +417 -0
  57. package/src/extractors/tier3.js +84 -0
  58. package/src/extractors/uploader.js +138 -0
  59. package/src/extractors/views.js +131 -0
  60. package/src/extractors/worker.js +62 -0
  61. package/src/git/diff-parser.js +132 -0
  62. package/src/providers/interface.js +12 -0
  63. package/src/providers/local-fs.js +318 -0
  64. package/src/server.js +71 -0
  65. package/src/tools/blast-radius-tools.js +129 -0
  66. package/src/tools/free-tools.js +44 -0
  67. package/src/tools/handlers/get-controller.js +93 -0
  68. package/src/tools/handlers/get-coverage-gaps.js +100 -0
  69. package/src/tools/handlers/get-deep-analysis.js +294 -0
  70. package/src/tools/handlers/get-domain-clusters.js +113 -0
  71. package/src/tools/handlers/get-factory-registry.js +43 -0
  72. package/src/tools/handlers/get-full-index.js +28 -0
  73. package/src/tools/handlers/get-model.js +108 -0
  74. package/src/tools/handlers/get-overview.js +153 -0
  75. package/src/tools/handlers/get-routes.js +18 -0
  76. package/src/tools/handlers/get-schema.js +40 -0
  77. package/src/tools/handlers/get-subgraph.js +82 -0
  78. package/src/tools/handlers/get-test-conventions.js +18 -0
  79. package/src/tools/handlers/get-well-tested-examples.js +51 -0
  80. package/src/tools/handlers/helpers.js +115 -0
  81. package/src/tools/handlers/index-project.js +36 -0
  82. package/src/tools/handlers/search-patterns.js +104 -0
  83. package/src/tools/index.js +34 -0
  84. package/src/tools/pro-tools.js +13 -0
  85. package/src/utils/file-reader.js +20 -0
  86. package/src/utils/inflector.js +223 -0
  87. package/src/utils/ruby-parser.js +115 -0
  88. package/src/utils/spec-style-detector.js +26 -0
  89. package/src/utils/token-counter.js +46 -0
  90. package/src/utils/yaml-parser.js +135 -0
package/src/server.js ADDED
@@ -0,0 +1,71 @@
1
+ import { createRequire } from 'node:module'
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import { LocalFSProvider } from './providers/local-fs.js'
5
+ import { buildIndex } from './core/indexer.js'
6
+ import { registerTools } from './tools/index.js'
7
+
8
+ const require = createRequire(import.meta.url)
9
+ const { version: PKG_VERSION } = require('../package.json')
10
+
11
+ /**
12
+ * Create an MCP server with tool registrations.
13
+ * @param {Object} options
14
+ * @param {Object} options.index - Pre-built index (null if not yet built)
15
+ * @param {string} options.tier - 'free' | 'pro' | 'team'
16
+ * @param {boolean} options.verbose - Enable verbose logging
17
+ * @param {import('./providers/interface.js').FileProvider} [options.provider] - File provider for re-indexing
18
+ * @returns {McpServer}
19
+ */
20
+ export function createServer(options) {
21
+ const server = new McpServer({
22
+ name: 'railsinsight',
23
+ version: PKG_VERSION,
24
+ capabilities: { tools: {} },
25
+ })
26
+
27
+ registerTools(server, options)
28
+
29
+ return server
30
+ }
31
+
32
+ /**
33
+ * Start the server in local mode with stdio transport.
34
+ * @param {string} projectRoot - Absolute path to Rails project
35
+ * @param {Object} options
36
+ */
37
+ export async function startLocal(projectRoot, options = {}) {
38
+ const provider = new LocalFSProvider(projectRoot)
39
+ const verbose = options.verbose || false
40
+
41
+ if (verbose) {
42
+ process.stderr.write(`[railsinsight] Indexing ${projectRoot}...\n`)
43
+ }
44
+
45
+ const index = await buildIndex(provider, {
46
+ claudeMdPath: options.claudeMdPath,
47
+ verbose,
48
+ })
49
+
50
+ if (verbose) {
51
+ process.stderr.write(`[railsinsight] Index built. Starting MCP server...\n`)
52
+ }
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
+ }
64
+
65
+ /**
66
+ * Start the server in remote mode with Streamable HTTP transport.
67
+ * @param {Object} options
68
+ */
69
+ export async function startRemote(options = {}) {
70
+ throw new Error('Remote mode is not yet implemented. Use local mode.')
71
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Blast Radius MCP Tools
3
+ * Registers get_blast_radius and get_review_context tools.
4
+ */
5
+
6
+ import { z } from 'zod'
7
+ import { computeBlastRadius, buildReviewContext } from '../core/blast-radius.js'
8
+ import { detectChangedFiles } from '../git/diff-parser.js'
9
+ import { noIndex, respond } from './handlers/helpers.js'
10
+ import { DEFAULT_TOKEN_BUDGET } from '../core/constants.js'
11
+
12
+ /**
13
+ * Register blast radius analysis tools on the MCP server.
14
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
15
+ * @param {Object} state - Mutable state object with { index, provider, verbose }
16
+ */
17
+ export function registerBlastRadiusTools(server, state) {
18
+ server.tool(
19
+ 'get_blast_radius',
20
+ 'Analyse the impact of code changes. Accepts explicit file paths or auto-detects from git diff. Returns impacted entities classified by risk level (CRITICAL/HIGH/MEDIUM/LOW) with affected tests. Call this before making changes to understand what else might break, or after changes to identify what needs testing.',
21
+ {
22
+ files: z
23
+ .array(z.string())
24
+ .optional()
25
+ .describe('Explicit list of changed file paths'),
26
+ base_ref: z
27
+ .string()
28
+ .optional()
29
+ .describe('Git ref to diff against (default: HEAD)'),
30
+ staged: z
31
+ .boolean()
32
+ .optional()
33
+ .describe('Only staged changes (default: false)'),
34
+ max_depth: z
35
+ .number()
36
+ .optional()
37
+ .describe('BFS traversal depth limit (default: 3)'),
38
+ },
39
+ async (args) => {
40
+ if (!state.index) return noIndex()
41
+
42
+ const changedFiles = await resolveChangedFiles(args, state)
43
+ if (
44
+ changedFiles.error &&
45
+ changedFiles.files.length === 0 &&
46
+ !args.files?.length
47
+ ) {
48
+ return respond({ error: changedFiles.error })
49
+ }
50
+
51
+ const result = computeBlastRadius(state.index, changedFiles.files, {
52
+ maxDepth: args.max_depth || 3,
53
+ })
54
+ return respond(result)
55
+ },
56
+ )
57
+
58
+ server.tool(
59
+ 'get_review_context',
60
+ 'Get a token-budgeted structural summary of entities impacted by code changes. Returns compact Rails-aware descriptions of each impacted model, controller, and component — enough context for an AI agent to review the change safely. Call get_blast_radius first, or provide files directly.',
61
+ {
62
+ files: z
63
+ .array(z.string())
64
+ .optional()
65
+ .describe('Explicit list of changed file paths'),
66
+ base_ref: z
67
+ .string()
68
+ .optional()
69
+ .describe('Git ref to diff against (default: HEAD)'),
70
+ token_budget: z
71
+ .number()
72
+ .optional()
73
+ .describe('Maximum tokens for the response (default: 8000)'),
74
+ risk_filter: z
75
+ .string()
76
+ .optional()
77
+ .describe('Minimum risk level to include (default: LOW)'),
78
+ },
79
+ async (args) => {
80
+ if (!state.index) return noIndex()
81
+
82
+ const changedFiles = await resolveChangedFiles(args, state)
83
+ if (
84
+ changedFiles.error &&
85
+ changedFiles.files.length === 0 &&
86
+ !args.files?.length
87
+ ) {
88
+ return respond({ error: changedFiles.error })
89
+ }
90
+
91
+ const tokenBudget = args.token_budget || DEFAULT_TOKEN_BUDGET
92
+ const blastResult = computeBlastRadius(state.index, changedFiles.files)
93
+
94
+ if (args.risk_filter) {
95
+ const levels = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
96
+ const minIdx = levels.indexOf(args.risk_filter)
97
+ if (minIdx >= 0) {
98
+ blastResult.impacted = blastResult.impacted.filter(
99
+ (e) => levels.indexOf(e.risk) <= minIdx,
100
+ )
101
+ }
102
+ }
103
+
104
+ const reviewContext = buildReviewContext(
105
+ state.index,
106
+ blastResult,
107
+ tokenBudget,
108
+ )
109
+ return respond(reviewContext)
110
+ },
111
+ )
112
+ }
113
+
114
+ async function resolveChangedFiles(args, state) {
115
+ if (args.files && args.files.length > 0) {
116
+ return {
117
+ files: args.files.map((path) => ({ path, status: 'modified' })),
118
+ error: null,
119
+ }
120
+ }
121
+
122
+ if (state.provider && typeof state.provider.execCommand === 'function') {
123
+ return detectChangedFiles(state.provider, args.base_ref || 'HEAD', {
124
+ staged: args.staged || false,
125
+ })
126
+ }
127
+
128
+ return { files: [], error: 'No files provided and git detection unavailable' }
129
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Primary MCP tools — always registered regardless of tier.
3
+ * Each tool handler lives in its own file under ./handlers/.
4
+ * This barrel imports all handlers and wires them up.
5
+ */
6
+
7
+ import { register as indexProject } from './handlers/index-project.js'
8
+ import { register as getOverview } from './handlers/get-overview.js'
9
+ import { register as getFullIndex } from './handlers/get-full-index.js'
10
+ import { register as getModel } from './handlers/get-model.js'
11
+ import { register as getController } from './handlers/get-controller.js'
12
+ import { register as getRoutes } from './handlers/get-routes.js'
13
+ import { register as getSchema } from './handlers/get-schema.js'
14
+ import { register as getSubgraph } from './handlers/get-subgraph.js'
15
+ import { register as searchPatterns } from './handlers/search-patterns.js'
16
+ import { register as getDeepAnalysis } from './handlers/get-deep-analysis.js'
17
+ import { register as getCoverageGaps } from './handlers/get-coverage-gaps.js'
18
+ import { register as getTestConventions } from './handlers/get-test-conventions.js'
19
+ import { register as getDomainClusters } from './handlers/get-domain-clusters.js'
20
+ import { register as getFactoryRegistry } from './handlers/get-factory-registry.js'
21
+ import { register as getWellTestedExamples } from './handlers/get-well-tested-examples.js'
22
+
23
+ /**
24
+ * Register all primary tools on the MCP server.
25
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
26
+ * @param {Object} state - Mutable state object with { index, provider, verbose }
27
+ */
28
+ export function registerFreeTools(server, state) {
29
+ indexProject(server, state)
30
+ getOverview(server, state)
31
+ getFullIndex(server, state)
32
+ getModel(server, state)
33
+ getController(server, state)
34
+ getRoutes(server, state)
35
+ getSchema(server, state)
36
+ getSubgraph(server, state)
37
+ searchPatterns(server, state)
38
+ getDeepAnalysis(server, state)
39
+ getCoverageGaps(server, state)
40
+ getTestConventions(server, state)
41
+ getDomainClusters(server, state)
42
+ getFactoryRegistry(server, state)
43
+ getWellTestedExamples(server, state)
44
+ }
@@ -0,0 +1,93 @@
1
+ import { z } from 'zod'
2
+ import { noIndex, respond } from './helpers.js'
3
+
4
+ /**
5
+ * Register the get_controller 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_controller',
12
+ 'Deep extraction for a specific controller: actions with routes, filters, rate limiting, strong params, rescue handlers.',
13
+ {
14
+ name: z
15
+ .string()
16
+ .describe('Controller class name (e.g. "SessionsController")'),
17
+ },
18
+ async ({ name }) => {
19
+ if (!state.index) return noIndex()
20
+ const controllers = state.index.extractions?.controllers || {}
21
+ const ctrl = controllers[name]
22
+ if (!ctrl) {
23
+ return respond({
24
+ error: `Controller '${name}' not found`,
25
+ available: Object.keys(controllers),
26
+ })
27
+ }
28
+
29
+ // Enrich with route mapping
30
+ const routes = state.index.extractions?.routes || {}
31
+ const allRoutes = routes.routes || []
32
+ const ctrlBase = name
33
+ .replace(/Controller$/, '')
34
+ .toLowerCase()
35
+ .replace(/::/g, '/')
36
+
37
+ const actionRoutes = {}
38
+ for (const action of ctrl.actions || []) {
39
+ const match = allRoutes.find(
40
+ (r) =>
41
+ r.controller &&
42
+ r.action &&
43
+ (r.controller.toLowerCase() === ctrlBase ||
44
+ r.controller.toLowerCase().endsWith(`/${ctrlBase}`) ||
45
+ r.controller.toLowerCase() ===
46
+ name.replace('Controller', '').toLowerCase()) &&
47
+ r.action === action,
48
+ )
49
+ if (match) {
50
+ actionRoutes[action] = {
51
+ method: match.method || match.verb,
52
+ path: match.path || match.pattern,
53
+ }
54
+ }
55
+ }
56
+
57
+ // Resolve inherited authorization from superclass
58
+ let inherited_authorization = null
59
+ if (ctrl.superclass && ctrl.superclass !== 'ApplicationController') {
60
+ const parentCtrl = controllers[ctrl.superclass]
61
+ if (parentCtrl) {
62
+ const parentGuards = (parentCtrl.filters || []).filter(
63
+ (f) => f.authorization_guard,
64
+ )
65
+ if (parentGuards.length > 0) {
66
+ const authzData = state.index.extractions?.authorization || {}
67
+ const guardMethods = authzData.concern?.guard_methods || {}
68
+ const guardInfo = guardMethods[parentGuards[0].method]
69
+ inherited_authorization = {
70
+ from: ctrl.superclass,
71
+ guard: parentGuards[0].method,
72
+ requirement: guardInfo?.requirement || null,
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ return respond({
79
+ ...ctrl,
80
+ ...(inherited_authorization ? { inherited_authorization } : {}),
81
+ action_routes:
82
+ Object.keys(actionRoutes).length > 0 ? actionRoutes : null,
83
+ actions_detail: (ctrl.actions || []).reduce((acc, action) => {
84
+ acc[action] = {
85
+ ...(actionRoutes[action] || {}),
86
+ key_logic: ctrl.action_summaries?.[action] || null,
87
+ }
88
+ return acc
89
+ }, {}),
90
+ })
91
+ },
92
+ )
93
+ }
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod'
2
+ import { noIndex, respond } from './helpers.js'
3
+
4
+ /**
5
+ * Register the get_coverage_gaps 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_coverage_gaps',
12
+ 'Returns prioritised list of files needing test coverage, with structural context from RailsInsight and per-method coverage data from SimpleCov.',
13
+ {
14
+ category: z
15
+ .string()
16
+ .optional()
17
+ .describe(
18
+ 'Filter by spec category (e.g. "model_specs", "request_specs")',
19
+ ),
20
+ min_gap: z
21
+ .number()
22
+ .optional()
23
+ .describe('Minimum coverage gap percentage to include (default: 0)'),
24
+ limit: z
25
+ .number()
26
+ .optional()
27
+ .describe('Maximum results to return (default: 20)'),
28
+ },
29
+ async ({ category, min_gap = 0, limit = 20 }) => {
30
+ if (!state.index) return noIndex()
31
+ const extractions = state.index.extractions || {}
32
+ const coverageSnapshot = extractions.coverage_snapshot || {}
33
+ const models = extractions.models || {}
34
+ const controllers = extractions.controllers || {}
35
+ const manifest = state.index.manifest || {}
36
+
37
+ const gaps = []
38
+ const entries = manifest.entries || []
39
+
40
+ for (const [name, model] of Object.entries(models)) {
41
+ if (!model.file) continue
42
+ const fileCov = coverageSnapshot.per_file?.[model.file]
43
+ const coverage = fileCov ? fileCov.line_coverage : 0
44
+ const gap = 100 - (coverage || 0)
45
+
46
+ if (gap < min_gap) continue
47
+ if (category && category !== 'model_specs') continue
48
+
49
+ gaps.push({
50
+ file: model.file,
51
+ entity: name,
52
+ entity_type: 'model',
53
+ coverage: coverage || 0,
54
+ gap,
55
+ public_methods: model.public_methods?.length || 0,
56
+ associations: model.associations?.length || 0,
57
+ uncovered_methods: (coverageSnapshot.uncovered_methods || [])
58
+ .filter((m) => m.entity === name)
59
+ .map((m) => ({ method: m.method, coverage: m.coverage })),
60
+ })
61
+ }
62
+
63
+ for (const [name, ctrl] of Object.entries(controllers)) {
64
+ if (!ctrl.file) continue
65
+ const fileCov = coverageSnapshot.per_file?.[ctrl.file]
66
+ const coverage = fileCov ? fileCov.line_coverage : 0
67
+ const gap = 100 - (coverage || 0)
68
+
69
+ if (gap < min_gap) continue
70
+ if (
71
+ category &&
72
+ category !== 'request_specs' &&
73
+ category !== 'controller_specs'
74
+ )
75
+ continue
76
+
77
+ gaps.push({
78
+ file: ctrl.file,
79
+ entity: name,
80
+ entity_type: 'controller',
81
+ coverage: coverage || 0,
82
+ gap,
83
+ actions: ctrl.actions?.length || 0,
84
+ uncovered_methods: (coverageSnapshot.uncovered_methods || [])
85
+ .filter((m) => m.entity === name)
86
+ .map((m) => ({ method: m.method, coverage: m.coverage })),
87
+ })
88
+ }
89
+
90
+ gaps.sort((a, b) => b.gap - a.gap)
91
+
92
+ return respond({
93
+ coverage_available: coverageSnapshot.available || false,
94
+ overall: coverageSnapshot.overall || null,
95
+ gaps: gaps.slice(0, limit),
96
+ total_gaps: gaps.length,
97
+ })
98
+ },
99
+ )
100
+ }