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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b375ce08baa45d08d3c20625d27280862175bb022677e1f5c5ae90d1eb7a1493
4
- data.tar.gz: 7d8de9d9b1642b447c03cc3b36820d2008dee648a475ebffaac038a9f081a178
3
+ metadata.gz: 0ed19c9c79914f2b5ad8707d51a634c4bae469e3d7d07bd06cf23ed6d89a6027
4
+ data.tar.gz: b58364346943cfe18414ff86cfb08b95a5bfe5ffef5d9c9949d8eb2221af1c7d
5
5
  SHA512:
6
- metadata.gz: 6ced1f26479b6614723aaba77812ec4efe9ff35c107a40e4e8865ec88553eace62aa5bd990371c0eb1b2ae4bfd13d7983c1d101bb3403ef7b8e8922022150106
7
- data.tar.gz: 39b6117bb858aa1dca13e65d05244aeccece63fb6afde51ee5ff1c0d3986512aabd3bd74e0ae00ee5ad0bda3382025573caebe7aa273155ff5a3fd85db03e812
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
  [![CI](https://github.com/igmarin/rails-ai-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/igmarin/rails-ai-bridge/actions)
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
12
12
  ![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/igmarin/rails-ai-bridge?utm_source=oss&utm_medium=github&utm_campaign=igmarin%2Frails-ai-bridge&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews)
13
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](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.0**). Earlier iterations of the same codebase were distributed as `rails-ai-context`.
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.0.x | :white_check_mark: |
8
- | 2.x | :white_check_mark: |
9
- | 1.x | :white_check_mark: |
10
- | < 1.0 | :x: |
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 +db/schema.rb+ exists for schema-driven AI context.
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 the schema file exists; +:warn+ otherwise
10
+ # @return [Doctor::Check] +:pass+ when a schema file exists; +:warn+ otherwise
9
11
  def call
10
- schema_path = File.join(app.root, 'db/schema.rb')
12
+ schema_file = present_schema_file
11
13
  check(
12
14
  'Schema',
13
- File.exist?(schema_path),
14
- pass: { message: 'db/schema.rb found' },
15
- fail: { status: :warn, message: 'db/schema.rb not found', fix: 'Run `rails db:schema:dump` to generate it' }
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
- # falling back to text-parsing +db/schema.rb+ via
8
- # {Schema::StaticSchemaParser} when no connection is present (CI, Claude
9
- # Code, offline environments).
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
- # Fallback: parse db/schema.rb as text when the DB is not connected.
131
- # Delegates all parsing to {Schema::StaticSchemaParser}.
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 the file is absent
140
+ # @return [Hash] parsed schema result, or +{ error: }+ when neither file exists
134
141
  def static_schema_parse
135
- path = schema_file_path
136
- return { error: "No schema.rb found at #{path}" } unless File.exist?(path)
137
-
138
- Schema::StaticSchemaParser.new(content: File.read(path), config: config).call
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.0). It is NOT a god class despite high betweenness centrality in
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
- raise ArgumentError, "Invalid git URL #{url.inspect}: URLs must not start with '-'" if url.start_with?('-')
76
- raise ArgumentError, "Invalid destination #{dest.inspect}: paths must not start with '-'" if dest.to_s.start_with?('-')
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiBridge
4
- VERSION = '3.6.0'
4
+ VERSION = '3.6.2'
5
5
  end
@@ -56,5 +56,5 @@ Gem::Specification.new do |spec|
56
56
  spec.add_dependency 'zeitwerk', '~> 2.6' # Autoloading
57
57
 
58
58
  # Semantic code analysis via Shopify's rubydex
59
- spec.add_dependency 'rubydex', '~> 0.2.9'
59
+ spec.add_dependency 'rubydex', '~> 0.3.0'
60
60
  end
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.0
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.2.9
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.2.9
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