rails-ai-bridge 3.6.0 → 3.6.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +2 -1
- data/SECURITY.md +6 -4
- data/UPGRADING.md +29 -0
- data/docs/mcp-security.md +13 -0
- data/lib/generators/rails_ai_bridge/install/install_generator.rb +1 -0
- data/lib/rails_ai_bridge/doctor/checkers/schema_checker.rb +19 -6
- data/lib/rails_ai_bridge/introspectors/schema/static_structure_sql_parser.rb +293 -0
- data/lib/rails_ai_bridge/introspectors/schema_introspector.rb +24 -10
- data/lib/rails_ai_bridge/path_resolver.rb +1 -1
- data/lib/rails_ai_bridge/registry/skill_source_resolver.rb +13 -2
- data/lib/rails_ai_bridge/version.rb +1 -1
- data/rails-ai-bridge.gemspec +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ed19c9c79914f2b5ad8707d51a634c4bae469e3d7d07bd06cf23ed6d89a6027
|
|
4
|
+
data.tar.gz: b58364346943cfe18414ff86cfb08b95a5bfe5ffef5d9c9949d8eb2221af1c7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f883afb7b2d7c806c765e54ca0169ce845fe8a087633676408f5dec3931373f0f152edd4a7a1e49f876b26f213f5e8960dc994294fd17087df5c437a1445f9b3
|
|
7
|
+
data.tar.gz: e5d84fdd428ab13733063d67ef6589421a387c4c5a844ff04950afe1e9a90a79e3ad7556336853a0087f3bdeb797f8ece670ae192f5069e2f21daa12b4438512
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [3.6.2] - 2026-08-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`structure.sql` support in offline/static schema introspection** (#96/#97/#116) — apps using `config.active_record.schema_format = :sql` (no `db/schema.rb`) now get table, column, index, and foreign-key context offline via `Introspectors::Schema::StaticStructureSqlParser`. The live-connection path was already format-agnostic. Output shape matches the live introspector so formatters work unchanged. Partition-child tables (`CREATE TABLE … PARTITION OF …`) are not expanded (follow-up).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **`ai:doctor` schema check for `schema_format = :sql`** (#96/#97/#116) — Schema check passes when `db/structure.sql` is present; fix hint points at `rails db:migrate` (or `rails db:schema:dump`).
|
|
19
|
+
|
|
20
|
+
## [3.6.1] - 2026-08-07
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
|
|
24
|
+
- **Skill-pack git URL scheme allowlist** (#105/#110) — `DefaultGitRunner#clone_repo` accepts only `https://`, SCP-style `git@host:path`, and `ssh://`. Rejects `file://`, plain `http://`, and empty URLs. Validation `ArgumentError`s do not interpolate the raw URL, so credentials in userinfo cannot leak via exception messages.
|
|
25
|
+
- **SECURITY.md supported-versions table** (#106/#112) — documents 3.6.x / 3.5.x as supported, best-effort for older 3.x, and end-of-life for 1.x / 2.x.
|
|
26
|
+
- **Residual MCP HTTP risk checklist** (#107/#113) — operator checklist in `docs/mcp-security.md` (open HTTP default, CORS `*`, in-memory rate limit) with an install-generator pointer.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **`rubydex` bumped to `~> 0.3.0`** (#103/#111) — was `~> 0.2.9`. Run `bundle update rubydex` in host apps.
|
|
31
|
+
- **Dependency audit (2026-08)** (#99) — `bundle-audit` clean; official `mcp` remains on **0.25.x** (`< 1.0`). Migration to `mcp` 1.x remains open in #104.
|
|
32
|
+
|
|
8
33
|
## [3.6.0]
|
|
9
34
|
|
|
10
35
|
### Changed
|
data/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
[](https://github.com/igmarin/rails-ai-bridge/actions)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|

|
|
13
|
+
[](https://deepwiki.com/igmarin/rails-ai-bridge)
|
|
13
14
|
|
|
14
15
|
---
|
|
15
16
|
|
|
@@ -780,7 +781,7 @@ Bug reports and pull requests: [github.com/igmarin/rails-ai-bridge/issues](https
|
|
|
780
781
|
|
|
781
782
|
## Acknowledgments & Origins
|
|
782
783
|
|
|
783
|
-
This gem ships as **rails-ai-bridge** (Ruby **`RailsAiBridge`**, version **3.6.
|
|
784
|
+
This gem ships as **rails-ai-bridge** (Ruby **`RailsAiBridge`**, version **3.6.2**). Earlier iterations of the same codebase were distributed as `rails-ai-context`.
|
|
784
785
|
|
|
785
786
|
RailsMCP evolved from
|
|
786
787
|
[crisnahine/rails-ai-context](https://github.com/crisnahine/rails-ai-context),
|
data/SECURITY.md
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
| Version | Supported |
|
|
6
6
|
|---------|--------------------|
|
|
7
|
-
| 3.
|
|
8
|
-
|
|
|
9
|
-
|
|
|
10
|
-
|
|
|
7
|
+
| 3.6.x | :white_check_mark: |
|
|
8
|
+
| 3.5.x | :white_check_mark: |
|
|
9
|
+
| 3.0.x–3.4.x | :white_check_mark: (security fixes best-effort) |
|
|
10
|
+
| 2.x | :x: (end of life — upgrade to 3.x) |
|
|
11
|
+
| 1.x | :x: |
|
|
12
|
+
| < 1.0 | :x: |
|
|
11
13
|
|
|
12
14
|
## Reporting a Vulnerability
|
|
13
15
|
|
data/UPGRADING.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Upgrading rails-ai-bridge
|
|
2
2
|
|
|
3
|
+
## Upgrading from 3.6.1 to 3.6.2
|
|
4
|
+
|
|
5
|
+
**No configuration changes required.**
|
|
6
|
+
|
|
7
|
+
If your app uses `config.active_record.schema_format = :sql`, offline schema
|
|
8
|
+
introspection and `rails ai:doctor` now use `db/structure.sql` automatically
|
|
9
|
+
(no need for `db/schema.rb`). Live DB introspection was already format-agnostic.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Upgrading from 3.6.0 to 3.6.1
|
|
15
|
+
|
|
16
|
+
**One action required if you are pinned to `rubydex` 0.2.x:**
|
|
17
|
+
|
|
18
|
+
The `rubydex` gem constraint moved from `~> 0.2.9` to `~> 0.3.0`. After upgrading
|
|
19
|
+
rails-ai-bridge:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle update rubydex
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
No configuration or application code changes are required for the security and
|
|
26
|
+
documentation updates in 3.6.1. Skill-pack git sources must use `https://`,
|
|
27
|
+
SCP-style `git@host:path`, or `ssh://` (plain `http://` and `file://` are rejected).
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
|
|
3
32
|
## Upgrading from 3.5.x to 3.6.0
|
|
4
33
|
|
|
5
34
|
**One action required if you are pinned to an older `rubydex`:**
|
data/docs/mcp-security.md
CHANGED
|
@@ -72,3 +72,16 @@ The stdio MCP server has no Bearer layer; anyone who can run the process can use
|
|
|
72
72
|
|
|
73
73
|
- [SECURITY.md](../SECURITY.md) — reporting vulnerabilities and design summary
|
|
74
74
|
- [docs/GUIDE.md](GUIDE.md) — full configuration and MCP tool reference
|
|
75
|
+
|
|
76
|
+
## Residual risk checklist (operators)
|
|
77
|
+
|
|
78
|
+
Use this before exposing HTTP MCP beyond a single-developer machine:
|
|
79
|
+
|
|
80
|
+
| Risk | Default | Mitigation |
|
|
81
|
+
|------|---------|------------|
|
|
82
|
+
| Unauthenticated HTTP when no token/resolver/JWT is configured | Open access (local DX) | Set `RAILS_AI_BRIDGE_MCP_TOKEN` or `config.http_mcp_token`, and/or `config.require_http_auth = true` |
|
|
83
|
+
| `cors_origins` includes `*` | CORS disabled unless configured | Prefer exact origins; never combine `*` with browsers on untrusted networks |
|
|
84
|
+
| In-memory rate limit | Per-process only | Use `config.mcp.rate_limiter` / reverse proxy / WAF for multi-worker or multi-host |
|
|
85
|
+
| Information disclosure via tools | Read-only tools still reveal schema/routes/code | Prefer stdio; bind HTTP to `127.0.0.1`; use exclusions/presets for regulated data |
|
|
86
|
+
|
|
87
|
+
See also [SECURITY.md](../SECURITY.md) for production `auto_mount` requirements and private vulnerability reporting.
|
|
@@ -189,6 +189,7 @@ module RailsAiBridge
|
|
|
189
189
|
# return 401 unless one of the auth mechanisms above is configured.
|
|
190
190
|
# Default is false for backward compatibility with local development.
|
|
191
191
|
# config.mcp.require_http_auth = true
|
|
192
|
+
# See docs/mcp-security.md — Residual risk checklist (operators).
|
|
192
193
|
#
|
|
193
194
|
# Timing-safe token comparison is built in, but add rate limiting too
|
|
194
195
|
# (e.g. Rack::Attack throttle on config.http_path) to prevent brute-force.
|
|
@@ -3,18 +3,31 @@
|
|
|
3
3
|
module RailsAiBridge
|
|
4
4
|
class Doctor
|
|
5
5
|
module Checkers
|
|
6
|
-
# Verifies
|
|
6
|
+
# Verifies a schema file exists for schema-driven AI context. Accepts
|
|
7
|
+
# either +db/schema.rb+ (+schema_format = :ruby+) or +db/structure.sql+
|
|
8
|
+
# (+schema_format = :sql+).
|
|
7
9
|
class SchemaChecker < BaseChecker
|
|
8
|
-
# @return [Doctor::Check] +:pass+ when
|
|
10
|
+
# @return [Doctor::Check] +:pass+ when a schema file exists; +:warn+ otherwise
|
|
9
11
|
def call
|
|
10
|
-
|
|
12
|
+
schema_file = present_schema_file
|
|
11
13
|
check(
|
|
12
14
|
'Schema',
|
|
13
|
-
|
|
14
|
-
pass: { message:
|
|
15
|
-
fail: {
|
|
15
|
+
schema_file,
|
|
16
|
+
pass: { message: "#{schema_file} found" },
|
|
17
|
+
fail: {
|
|
18
|
+
status: :warn,
|
|
19
|
+
message: 'db/schema.rb or db/structure.sql not found',
|
|
20
|
+
fix: 'Run `rails db:migrate` (or `rails db:schema:dump`) to generate one'
|
|
21
|
+
}
|
|
16
22
|
)
|
|
17
23
|
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] the schema file that exists (schema.rb preferred), or +nil+
|
|
28
|
+
def present_schema_file
|
|
29
|
+
%w[db/schema.rb db/structure.sql].find { |rel| File.exist?(File.join(app.root, rel)) }
|
|
30
|
+
end
|
|
18
31
|
end
|
|
19
32
|
end
|
|
20
33
|
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiBridge
|
|
4
|
+
module Introspectors
|
|
5
|
+
module Schema
|
|
6
|
+
# Parses a +db/structure.sql+ file as plain text, without a live database
|
|
7
|
+
# connection. This is the +schema_format = :sql+ counterpart to
|
|
8
|
+
# {StaticSchemaParser}: apps that keep their schema as SQL (common on
|
|
9
|
+
# Postgres, where +schema.rb+ cannot represent partitions, views,
|
|
10
|
+
# extensions, or custom SQL) have no +db/schema.rb+ to fall back to in
|
|
11
|
+
# offline environments (CI, Claude Code, agent contexts).
|
|
12
|
+
#
|
|
13
|
+
# Each instance is single-use: construct it with the file content and a
|
|
14
|
+
# configuration object, call {#call}, and discard. No mutable state
|
|
15
|
+
# escapes the instance.
|
|
16
|
+
#
|
|
17
|
+
# == Supported DDL (pg_dump / structure.sql form)
|
|
18
|
+
#
|
|
19
|
+
# * +CREATE TABLE [IF NOT EXISTS] [schema.]name (+ — opens a table context
|
|
20
|
+
# * +<name> <type> ...+ — a column line inside the table body; the leading
|
|
21
|
+
# identifier is the column and the remainder (minus +NOT NULL+/+DEFAULT+)
|
|
22
|
+
# is the SQL type. Table-level constraint lines (+CONSTRAINT+,
|
|
23
|
+
# +PRIMARY KEY+, +FOREIGN KEY+, …) are skipped.
|
|
24
|
+
# * +);+ — closes the current table context
|
|
25
|
+
# * +CREATE [UNIQUE] INDEX name ON [schema.]table USING method (cols)+ —
|
|
26
|
+
# adds an index entry (first simple column) to the named table.
|
|
27
|
+
# Functional/expression indexes (e.g. +lower(email)+) are skipped.
|
|
28
|
+
# * +ALTER TABLE [ONLY] table ADD CONSTRAINT ... FOREIGN KEY (col)
|
|
29
|
+
# REFERENCES ref_table (pk)+ — adds a foreign-key entry to +table+
|
|
30
|
+
# (pg_dump emits these in a separate constraints section).
|
|
31
|
+
#
|
|
32
|
+
# Unlike {StaticSchemaParser} (whose +schema.rb+ static form leaves foreign
|
|
33
|
+
# keys empty), +structure.sql+ spells foreign keys out as parseable DDL, so
|
|
34
|
+
# this parser populates them offline — matching what the live
|
|
35
|
+
# {SchemaIntrospector} path reports.
|
|
36
|
+
#
|
|
37
|
+
# Internal Rails tables (+ar_internal_metadata+, +schema_migrations+) and
|
|
38
|
+
# any table matching {Config::Introspection#excluded_tables} are silently
|
|
39
|
+
# skipped.
|
|
40
|
+
#
|
|
41
|
+
# @example
|
|
42
|
+
# content = File.read("db/structure.sql")
|
|
43
|
+
# result = StaticStructureSqlParser.new(content: content, config: RailsAiBridge.configuration).call
|
|
44
|
+
# # => { adapter: "static_parse", tables: { ... }, total_tables: N, note: "..." }
|
|
45
|
+
#
|
|
46
|
+
# @see RailsAiBridge::Introspectors::SchemaIntrospector
|
|
47
|
+
# @see RailsAiBridge::Introspectors::Schema::StaticSchemaParser
|
|
48
|
+
class StaticStructureSqlParser
|
|
49
|
+
# Regex matching a +CREATE TABLE+ declaration, tolerating +IF NOT EXISTS+,
|
|
50
|
+
# a schema qualifier (+public.+), and optional quoting of either part.
|
|
51
|
+
TABLE_LINE = /\ACREATE TABLE (?:IF NOT EXISTS\s+)?(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?\s*\(/
|
|
52
|
+
|
|
53
|
+
# Regex matching the end of a table body (+);+ at column zero).
|
|
54
|
+
TABLE_END_LINE = /\A\)/
|
|
55
|
+
|
|
56
|
+
# Regex matching a column definition inside a table body: leading
|
|
57
|
+
# whitespace, an identifier (optionally quoted), then the type/modifiers.
|
|
58
|
+
COLUMN_LINE = /\A\s+"?([A-Za-z_]\w*)"?\s+(.+)/
|
|
59
|
+
|
|
60
|
+
# Regex matching a +CREATE INDEX+ statement. Captures the target table
|
|
61
|
+
# and the raw parenthesised column list; only the first column is kept
|
|
62
|
+
# (parity with {StaticSchemaParser}).
|
|
63
|
+
INDEX_LINE = /\ACREATE\s+(?:UNIQUE\s+)?INDEX\s+.+?\s+ON\s+(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?\s+(?:USING\s+\w+\s+)?\(([^)]+)\)/
|
|
64
|
+
|
|
65
|
+
# Regex matching an +ALTER TABLE [ONLY] [schema.]table+ statement, which
|
|
66
|
+
# in pg_dump precedes an +ADD CONSTRAINT+ line. Captures the target table.
|
|
67
|
+
ALTER_TABLE_LINE = /\AALTER TABLE (?:ONLY\s+)?(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?/
|
|
68
|
+
|
|
69
|
+
# Regex matching an +ADD CONSTRAINT ... FOREIGN KEY (cols) REFERENCES
|
|
70
|
+
# [schema.]ref_table (pk)+ clause. Captures local columns, referenced
|
|
71
|
+
# table, and referenced columns.
|
|
72
|
+
FOREIGN_KEY_LINE = /FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?\s*\(([^)]+)\)/
|
|
73
|
+
|
|
74
|
+
# Regex matching an +ON DELETE <action>+ clause on a foreign-key line.
|
|
75
|
+
ON_DELETE = /ON DELETE ([A-Z ]+?)(?=\s+ON UPDATE|\s+(?:NOT\s+)?(?:DEFERRABLE|VALID)|[,;)]|\z)/i
|
|
76
|
+
|
|
77
|
+
# Regex matching an +ON UPDATE <action>+ clause on a foreign-key line.
|
|
78
|
+
ON_UPDATE = /ON UPDATE ([A-Z ]+?)(?=\s+(?:NOT\s+)?(?:DEFERRABLE|VALID)|[,;)]|\z)/i
|
|
79
|
+
|
|
80
|
+
# Rails-managed tables that must never appear in introspection output.
|
|
81
|
+
INTERNAL_TABLES = %w[ar_internal_metadata schema_migrations].freeze
|
|
82
|
+
|
|
83
|
+
# Table-level constraint keywords that share a column line's shape but
|
|
84
|
+
# are not columns.
|
|
85
|
+
CONSTRAINT_KEYWORDS = %w[CONSTRAINT PRIMARY FOREIGN UNIQUE CHECK EXCLUDE LIKE DEFERRABLE].freeze
|
|
86
|
+
|
|
87
|
+
# @param content [String] full text of +db/structure.sql+
|
|
88
|
+
# @param config [RailsAiBridge::Config::Introspection, RailsAiBridge::Configuration]
|
|
89
|
+
# any object that responds to +#excluded_table?+
|
|
90
|
+
def initialize(content:, config:)
|
|
91
|
+
@content = content
|
|
92
|
+
@config = config
|
|
93
|
+
@tables = {}
|
|
94
|
+
@current_table = nil
|
|
95
|
+
@in_table = false
|
|
96
|
+
@alter_target = nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Parse the structure.sql content and return the tables hash. Never
|
|
100
|
+
# raises — malformed or non-UTF-8 input is caught and reported as an
|
|
101
|
+
# error hash, per the introspector contract.
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash{Symbol => Object}] with keys +:adapter+, +:tables+,
|
|
104
|
+
# +:total_tables+, and +:note+; or +{ error: }+ on failure
|
|
105
|
+
def call
|
|
106
|
+
@content.each_line { |line| parse_line(line) }
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
adapter: 'static_parse',
|
|
110
|
+
tables: @tables,
|
|
111
|
+
total_tables: @tables.size,
|
|
112
|
+
note: 'Parsed from db/structure.sql (no DB connection)'
|
|
113
|
+
}
|
|
114
|
+
rescue StandardError => error
|
|
115
|
+
{ error: "Failed to parse db/structure.sql: #{error.message}" }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Dispatches a single line to the table-body handler or the top-level
|
|
121
|
+
# (create/index/alter/foreign-key) handlers.
|
|
122
|
+
#
|
|
123
|
+
# @param line [String]
|
|
124
|
+
# @return [void]
|
|
125
|
+
def parse_line(line)
|
|
126
|
+
return parse_body_line(line) if @in_table
|
|
127
|
+
return if parse_table_line?(line)
|
|
128
|
+
return if parse_index_line?(line)
|
|
129
|
+
return if parse_alter_table_line?(line)
|
|
130
|
+
|
|
131
|
+
parse_foreign_key_line?(line)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Opens a table context on a +CREATE TABLE+ line. Sets +@current_table+
|
|
135
|
+
# to +nil+ for skipped tables while still tracking that we are inside a
|
|
136
|
+
# body, so the closing +);+ is honoured.
|
|
137
|
+
#
|
|
138
|
+
# @param line [String]
|
|
139
|
+
# @return [Boolean] +true+ if the line matched
|
|
140
|
+
def parse_table_line?(line)
|
|
141
|
+
match = TABLE_LINE.match(line)
|
|
142
|
+
return false unless match
|
|
143
|
+
|
|
144
|
+
name = match[1]
|
|
145
|
+
@in_table = true
|
|
146
|
+
@current_table = skip_table?(name) ? nil : name
|
|
147
|
+
@tables[@current_table] = { columns: [], indexes: [], foreign_keys: [] } if @current_table
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Handles a line while inside a table body: either the closing paren or a
|
|
152
|
+
# column definition (constraint lines are ignored).
|
|
153
|
+
#
|
|
154
|
+
# @param line [String]
|
|
155
|
+
# @return [void]
|
|
156
|
+
def parse_body_line(line)
|
|
157
|
+
if TABLE_END_LINE.match?(line)
|
|
158
|
+
@in_table = false
|
|
159
|
+
@current_table = nil
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
parse_column_line(line) if @current_table
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Appends a column to the current table unless the line is a table-level
|
|
167
|
+
# constraint.
|
|
168
|
+
#
|
|
169
|
+
# @param line [String]
|
|
170
|
+
# @return [void]
|
|
171
|
+
def parse_column_line(line)
|
|
172
|
+
match = COLUMN_LINE.match(line)
|
|
173
|
+
return unless match
|
|
174
|
+
return if constraint_keyword?(match[1])
|
|
175
|
+
|
|
176
|
+
@tables[@current_table][:columns] << { name: match[1], type: normalize_type(match[2]) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Adds an index entry (first column only) to the matching table. No-ops
|
|
180
|
+
# when the table is not present in +@tables+.
|
|
181
|
+
#
|
|
182
|
+
# @param line [String]
|
|
183
|
+
# @return [Boolean] +true+ if the line matched
|
|
184
|
+
def parse_index_line?(line)
|
|
185
|
+
match = INDEX_LINE.match(line)
|
|
186
|
+
return false unless match
|
|
187
|
+
|
|
188
|
+
column = first_index_column(match[2])
|
|
189
|
+
@tables[match[1]]&.dig(:indexes)&.push({ columns: column }) if column
|
|
190
|
+
true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Records the target table of an +ALTER TABLE+ statement so a following
|
|
194
|
+
# +ADD CONSTRAINT ... FOREIGN KEY+ line can attach to it. Sets
|
|
195
|
+
# +@alter_target+ to +nil+ for skipped/unknown tables.
|
|
196
|
+
#
|
|
197
|
+
# @param line [String]
|
|
198
|
+
# @return [Boolean] +true+ if the line matched
|
|
199
|
+
def parse_alter_table_line?(line)
|
|
200
|
+
match = ALTER_TABLE_LINE.match(line)
|
|
201
|
+
return false unless match
|
|
202
|
+
|
|
203
|
+
@alter_target = @tables.key?(match[1]) ? match[1] : nil
|
|
204
|
+
true
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Appends a foreign-key entry to the current +@alter_target+ table. Mirrors
|
|
208
|
+
# the live introspector's shape (+from_table+, +to_table+, +column+,
|
|
209
|
+
# +primary_key+, +on_delete+, +on_update+), keeping the first column of a
|
|
210
|
+
# composite key for parity with index handling. No-ops without a target.
|
|
211
|
+
#
|
|
212
|
+
# @param line [String]
|
|
213
|
+
# @return [Boolean] +true+ if the line matched
|
|
214
|
+
def parse_foreign_key_line?(line)
|
|
215
|
+
match = FOREIGN_KEY_LINE.match(line)
|
|
216
|
+
return false unless match
|
|
217
|
+
return true unless @alter_target
|
|
218
|
+
|
|
219
|
+
@tables[@alter_target][:foreign_keys] << {
|
|
220
|
+
from_table: @alter_target,
|
|
221
|
+
to_table: match[2],
|
|
222
|
+
column: first_identifier(match[1]),
|
|
223
|
+
primary_key: first_identifier(match[3]),
|
|
224
|
+
on_delete: fk_action(line, ON_DELETE),
|
|
225
|
+
on_update: fk_action(line, ON_UPDATE)
|
|
226
|
+
}.compact
|
|
227
|
+
true
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Strips a trailing comma and the +DEFAULT ...+ / +NOT NULL+ / +NULL+
|
|
231
|
+
# modifiers to leave the bare SQL type.
|
|
232
|
+
#
|
|
233
|
+
# @param raw [String] everything after the column name
|
|
234
|
+
# @return [String] the SQL type (e.g. +"character varying"+, +"bigint"+)
|
|
235
|
+
def normalize_type(raw)
|
|
236
|
+
raw.strip
|
|
237
|
+
.sub(/,\s*\z/, '')
|
|
238
|
+
.sub(/\s+DEFAULT\b.*\z/i, '')
|
|
239
|
+
.sub(/\s+NOT\s+NULL\s*\z/i, '')
|
|
240
|
+
.sub(/\s+NULL\s*\z/i, '')
|
|
241
|
+
.strip
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Extracts the first column identifier from an index's parenthesised
|
|
245
|
+
# column list. Keeps plain and opclass-qualified columns
|
|
246
|
+
# (+col varchar_pattern_ops+ → +col+) but returns +nil+ for functional
|
|
247
|
+
# or expression indexes (+lower(email)+) so they are skipped rather than
|
|
248
|
+
# mis-attributed to the function name.
|
|
249
|
+
#
|
|
250
|
+
# @param columns [String] raw text between the index parentheses
|
|
251
|
+
# @return [String, nil]
|
|
252
|
+
def first_index_column(columns)
|
|
253
|
+
first = columns.split(',').first&.strip
|
|
254
|
+
return nil if first.nil? || first.include?('(')
|
|
255
|
+
|
|
256
|
+
first.slice(/[A-Za-z_]\w*/)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Returns the first identifier from a (possibly composite) column list.
|
|
260
|
+
#
|
|
261
|
+
# @param columns [String] comma-separated column list
|
|
262
|
+
# @return [String, nil]
|
|
263
|
+
def first_identifier(columns)
|
|
264
|
+
columns.split(',').first&.slice(/[A-Za-z_]\w*/)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Extracts a normalized foreign-key referential action (e.g. +CASCADE+,
|
|
268
|
+
# +SET NULL+) from a line, or +nil+ when the clause is absent.
|
|
269
|
+
#
|
|
270
|
+
# @param line [String]
|
|
271
|
+
# @param pattern [Regexp] {ON_DELETE} or {ON_UPDATE}
|
|
272
|
+
# @return [String, nil]
|
|
273
|
+
def fk_action(line, pattern)
|
|
274
|
+
match = pattern.match(line)
|
|
275
|
+
match && match[1].strip.squeeze(' ').upcase
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# @param name [String]
|
|
279
|
+
# @return [Boolean] +true+ when +name+ is a table-level constraint keyword
|
|
280
|
+
def constraint_keyword?(name)
|
|
281
|
+
CONSTRAINT_KEYWORDS.include?(name.upcase)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# @param name [String]
|
|
285
|
+
# @return [Boolean] +true+ when +name+ is internal or excluded by config
|
|
286
|
+
def skip_table?(name)
|
|
287
|
+
INTERNAL_TABLES.any? { |t| name.start_with?(t) } ||
|
|
288
|
+
@config.excluded_table?(name)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -4,11 +4,13 @@ module RailsAiBridge
|
|
|
4
4
|
module Introspectors
|
|
5
5
|
# Extracts database schema information — tables, columns, indexes, and
|
|
6
6
|
# foreign keys — from a live ActiveRecord connection when available, or by
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# text-parsing the schema file when no connection is present (CI, Claude
|
|
8
|
+
# Code, offline environments). The static fallback prefers +db/schema.rb+
|
|
9
|
+
# ({Schema::StaticSchemaParser}) and falls back to +db/structure.sql+
|
|
10
|
+
# ({Schema::StaticStructureSqlParser}) for +schema_format = :sql+ apps.
|
|
10
11
|
#
|
|
11
12
|
# @see Schema::StaticSchemaParser
|
|
13
|
+
# @see Schema::StaticStructureSqlParser
|
|
12
14
|
class SchemaIntrospector
|
|
13
15
|
# @return [Rails::Application]
|
|
14
16
|
attr_reader :app
|
|
@@ -127,15 +129,27 @@ module RailsAiBridge
|
|
|
127
129
|
File.join(app.root, 'db', 'schema.rb')
|
|
128
130
|
end
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
def structure_sql_path
|
|
133
|
+
File.join(app.root, 'db', 'structure.sql')
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Fallback used when the DB is not connected. Prefers +db/schema.rb+
|
|
137
|
+
# (Ruby DSL) and falls back to +db/structure.sql+ (raw SQL) so
|
|
138
|
+
# +schema_format = :sql+ apps still get schema context offline.
|
|
132
139
|
#
|
|
133
|
-
# @return [Hash] parsed schema result, or +{ error: }+ when
|
|
140
|
+
# @return [Hash] parsed schema result, or +{ error: }+ when neither file exists
|
|
134
141
|
def static_schema_parse
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
if File.exist?(schema_file_path)
|
|
143
|
+
Schema::StaticSchemaParser.new(content: File.read(schema_file_path), config: config).call
|
|
144
|
+
elsif File.exist?(structure_sql_path)
|
|
145
|
+
Schema::StaticStructureSqlParser.new(content: File.read(structure_sql_path), config: config).call
|
|
146
|
+
else
|
|
147
|
+
{ error: "No db/schema.rb or db/structure.sql found in #{File.join(app.root, 'db')}" }
|
|
148
|
+
end
|
|
149
|
+
rescue StandardError => error
|
|
150
|
+
# Guards the exist?/read race (file removed between check and read) and
|
|
151
|
+
# any other read failure, honouring the introspector never-raise contract.
|
|
152
|
+
{ error: "Failed to read schema file: #{error.message}" }
|
|
139
153
|
end
|
|
140
154
|
end
|
|
141
155
|
end
|
|
@@ -9,7 +9,7 @@ module RailsAiBridge
|
|
|
9
9
|
# PathResolver is deliberately used by every introspector that needs to
|
|
10
10
|
# locate files on disk (controller, model, view, stimulus, turbo, auth,
|
|
11
11
|
# api, config, action_text, activeStorage, nonArModels — 11 callers as of
|
|
12
|
-
# v3.6.
|
|
12
|
+
# v3.6.2). It is NOT a god class despite high betweenness centrality in
|
|
13
13
|
# graph analyses: a foundational path-resolution utility is expected to
|
|
14
14
|
# sit at the centre of the introspector graph. Splitting it would spread
|
|
15
15
|
# path-safety logic (traversal guards, safe joins) across multiple files
|
|
@@ -69,11 +69,22 @@ module RailsAiBridge
|
|
|
69
69
|
#
|
|
70
70
|
# @param url [String] git repository URL
|
|
71
71
|
# @param dest [String] destination directory path
|
|
72
|
+
# @raise [ArgumentError] if the URL or destination is unsafe or not allowlisted
|
|
72
73
|
# @raise [RuntimeError] if git clone command fails, returns non-zero, or times out
|
|
73
74
|
# @return [void]
|
|
75
|
+
# @note Skill-pack clones accept only +https://+, SCP-style
|
|
76
|
+
# +git@host:path+, and +ssh://+ remote URLs.
|
|
77
|
+
ALLOWED_GIT_URL_PATTERN = %r{\A(?:https://|ssh://|git@[A-Za-z0-9._-]+:)}
|
|
78
|
+
|
|
74
79
|
def clone_repo(url, dest)
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
url = url.to_s
|
|
81
|
+
# Avoid interpolating +url+ into errors: userinfo may contain credentials.
|
|
82
|
+
raise ArgumentError, "Invalid git URL: URLs must not start with '-'" if url.start_with?('-')
|
|
83
|
+
raise ArgumentError, "Invalid destination: paths must not start with '-'" if dest.to_s.start_with?('-')
|
|
84
|
+
unless url.match?(ALLOWED_GIT_URL_PATTERN)
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
'Invalid git URL: scheme is not allowlisted (use https://, git@host:path, or ssh://)'
|
|
87
|
+
end
|
|
77
88
|
|
|
78
89
|
with_timeout('git clone') do
|
|
79
90
|
_stdout, stderr, status = Open3.capture3('git', 'clone', '--', url, dest)
|
data/rails-ai-bridge.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-ai-bridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.6.
|
|
4
|
+
version: 3.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ismael Marin
|
|
@@ -89,14 +89,14 @@ dependencies:
|
|
|
89
89
|
requirements:
|
|
90
90
|
- - "~>"
|
|
91
91
|
- !ruby/object:Gem::Version
|
|
92
|
-
version: 0.
|
|
92
|
+
version: 0.3.0
|
|
93
93
|
type: :runtime
|
|
94
94
|
prerelease: false
|
|
95
95
|
version_requirements: !ruby/object:Gem::Requirement
|
|
96
96
|
requirements:
|
|
97
97
|
- - "~>"
|
|
98
98
|
- !ruby/object:Gem::Version
|
|
99
|
-
version: 0.
|
|
99
|
+
version: 0.3.0
|
|
100
100
|
description: Rails-AI-Bridge introspects your Rails application and exposes structure
|
|
101
101
|
to AI assistants via static context files and a live Model Context Protocol (MCP)
|
|
102
102
|
server. It classifies Active Record models semantically (Core, Join, Supporting),
|
|
@@ -218,6 +218,7 @@ files:
|
|
|
218
218
|
- lib/rails_ai_bridge/introspectors/rake_task_introspector.rb
|
|
219
219
|
- lib/rails_ai_bridge/introspectors/route_introspector.rb
|
|
220
220
|
- lib/rails_ai_bridge/introspectors/schema/static_schema_parser.rb
|
|
221
|
+
- lib/rails_ai_bridge/introspectors/schema/static_structure_sql_parser.rb
|
|
221
222
|
- lib/rails_ai_bridge/introspectors/schema_introspector.rb
|
|
222
223
|
- lib/rails_ai_bridge/introspectors/seeds_introspector.rb
|
|
223
224
|
- lib/rails_ai_bridge/introspectors/semantic_introspector.rb
|