Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.45] - 2026-04-11

### Fixed

- **`get_overview` returns database adapter string instead of raw config object**: When the config extractor produces `config.database` as an object (e.g. `{ adapter: 'postgresql', pool: 5 }`), the handler now extracts `.adapter` and returns a plain string. Previously the entire object was returned verbatim, failing schema comparisons downstream
- **Devise modules not extracted when declared with parentheses**: The `deviseModules` pattern `/^\s*devise\s+(.+)/m` required a space immediately after `devise`, missing the `devise(:database_authenticatable, ...)` parenthesis form used by some apps. Changed to `/^\s*devise[\s(](.*)/m` to handle both forms
- **Namespaced Pundit policy classes not detected**: The `policyClass` pattern `/class\s+(\w+)Policy\s*<\s*(\w+)/` did not match declarations like `class Admin::LogsPolicy < ApplicationPolicy` where the class name includes a module prefix. Updated to `/class\s+(?:[\w]+::)*(\w+)Policy\s*<\s*(\w+)/` to handle namespaced policy classes in `app/policies/*/`

## [1.0.43] - 2026-04-06

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@reinteractive/rails-insight",
"version": "1.0.44",
"version": "1.0.45",
"description": "Rails-aware codebase indexer — MCP server for AI agents",
"type": "module",
"bin": {
Expand Down
3 changes: 2 additions & 1 deletion src/core/patterns/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
export const AUTH_PATTERNS = {
// Devise
deviseConfig: /config\.(\w+)\s*=\s*(.+)/g,
deviseModules: /^\s*devise\s+(.+)/m,
// Matches both `devise :module, ...` (space) and `devise(...)` (parens)
deviseModules: /^\s*devise[\s(](.*)/m,
deviseController:
/class\s+\w+::(\w+Controller)\s*<\s*Devise::(\w+Controller)/,
omniauthProvider: /provider\s+:(\w+)/g,
Expand Down
4 changes: 3 additions & 1 deletion src/core/patterns/authorization.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/
export const AUTHORIZATION_PATTERNS = {
// Pundit
policyClass: /class\s+(\w+)Policy\s*<\s*(\w+)/,
// Supports plain `class LoginPolicy < ApplicationPolicy` and
// namespaced `class Admin::LogsPolicy < ApplicationPolicy`
policyClass: /class\s+(?:[\w]+::)*(\w+)Policy\s*<\s*(\w+)/,
policyMethod: /def\s+(\w+)\?/g,
policyScopeClass: /class\s+Scope\s*<\s*(?:ApplicationPolicy::)?Scope/,
policyScopeResolve: /def\s+resolve/,
Expand Down
10 changes: 9 additions & 1 deletion src/tools/handlers/get-overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,18 @@ export function register(server, state) {
(index.extractions?.authorization?.policies || []).length || 0,
}

// Extract database adapter as a plain string (config.database may be an object
// with { adapter, pool, ... } from the config extractor)
const dbConfig = config.database
const databaseStr =
dbConfig && typeof dbConfig === 'object'
? dbConfig.adapter || 'unknown'
: dbConfig || v.database || 'unknown'

const overview = {
rails_version: v.rails || 'unknown',
ruby_version: v.ruby || 'unknown',
database: config.database || v.database || 'unknown',
database: databaseStr,
asset_pipeline:
v.framework?.assetPipeline || v.asset_pipeline || 'unknown',
frontend_stack: v.frontend || [],
Expand Down
36 changes: 36 additions & 0 deletions test/extractors/authorization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,42 @@ end`,
})
})

describe('namespaced policy classes', () => {
// Regression: policyClass regex must match Admin::LogsPolicy < ApplicationPolicy
const files = {
'app/policies/admin/logs_policy.rb': `
module Admin
class LogsPolicy < ApplicationPolicy
def index?
user.admin?
end

def show?
user.admin?
end
end
end`,
}

const entries = [
{ path: 'app/policies/admin/logs_policy.rb', category: 'policy' },
]

const gemInfo = { gems: { pundit: { version: '2.3' } } }
const provider = mockProvider(files)
const result = extractAuthorization(provider, entries, gemInfo)

it('detects namespaced policy class', () => {
expect(result.policies).toHaveLength(1)
})

it('extracts resource name from namespaced policy', () => {
const logsPolicy = result.policies.find((p) => p.resource === 'Logs')
expect(logsPolicy).toBeTruthy()
expect(logsPolicy.class).toBe('LogsPolicy')
})
})

describe('CanCanCan authorization', () => {
const files = {
'app/models/ability.rb': `
Expand Down
11 changes: 11 additions & 0 deletions test/patterns/auth-patterns.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ describe('AUTH_PATTERNS', () => {
expect(m).toBeTruthy()
expect(m[1]).toContain('database_authenticatable')
})
it('matches devise( parenthesis form', () => {
expect(
' devise(:database_authenticatable, :registerable)',
).toMatch(AUTH_PATTERNS.deviseModules)
})
it('extracts modules from devise( parenthesis form', () => {
const str = ' devise(:database_authenticatable, :registerable)'
const m = str.match(AUTH_PATTERNS.deviseModules)
expect(m).toBeTruthy()
expect(m[1]).toContain('database_authenticatable')
})
})

describe('deviseController', () => {
Expand Down
83 changes: 83 additions & 0 deletions test/tools/get-overview-database.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import { register } from '../../src/tools/handlers/get-overview.js'

function createMockServer() {
const tools = {}
return {
server: {
tool(name, description, schema, handler) {
tools[name] = handler
},
},
async callTool(name) {
return tools[name]()
},
}
}

function parseResponse(result) {
const text = result.content?.[0]?.text
return text ? JSON.parse(text) : null
}

function makeState(overrides = {}) {
return {
index: {
versions: { rails: '7.2.0', ruby: '3.2.0', ...overrides.versions },
extractions: {
auth: {},
models: {},
controllers: {},
authorization: {},
caching: {},
jobs: {},
tier2: {},
tier3: {},
config: { ...overrides.config },
...overrides.extractions,
},
statistics: {},
},
}
}

describe('get_overview handler — database field', () => {
it('returns adapter string when config.database is an object', async () => {
// Regression: config.database may be {adapter: 'postgresql', pool: {}} —
// the handler must extract the adapter string, not return the object.
const state = makeState({
config: { database: { adapter: 'postgresql', pool: 5 } },
})
const mock = createMockServer()
register(mock.server, state)
const result = await mock.callTool('get_overview')
const data = parseResponse(result)

expect(typeof data.database).toBe('string')
expect(data.database).toBe('postgresql')
})

it('returns adapter string when config.database is already a string', async () => {
const state = makeState({
config: { database: 'sqlite3' },
})
const mock = createMockServer()
register(mock.server, state)
const result = await mock.callTool('get_overview')
const data = parseResponse(result)

expect(typeof data.database).toBe('string')
expect(data.database).toBe('sqlite3')
})

it('falls back to "unknown" when database config is absent', async () => {
const state = makeState({ config: {} })
const mock = createMockServer()
register(mock.server, state)
const result = await mock.callTool('get_overview')
const data = parseResponse(result)

expect(typeof data.database).toBe('string')
expect(data.database).toBe('unknown')
})
})
Loading