rails-ai-bridge 3.6.1 → 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: ecc139c0066902b92342f732a9e9344b5aa5468153f6193708fa95815bbe0535
4
- data.tar.gz: 05fe2c9d0ada4aa7a836b1dea1ec70f60ad75a94f87c4b4ff5b29e6c4fe9ca81
3
+ metadata.gz: 0ed19c9c79914f2b5ad8707d51a634c4bae469e3d7d07bd06cf23ed6d89a6027
4
+ data.tar.gz: b58364346943cfe18414ff86cfb08b95a5bfe5ffef5d9c9949d8eb2221af1c7d
5
5
  SHA512:
6
- metadata.gz: 73de5c66876b8b4ca8e7a50e3bdaaf8700984f23f884f492d7ebc880aee3fd3345efd847c4d4252012ac920c8e5450877767e79e48ecdc141f74b0d0d5dc5dd8
7
- data.tar.gz: '08b0c68f5f5af762af8c87e937ba5360f5fa6732f07b3543d24bb000e118f5dad55ca4228984f7f88dc471ea70b8f3a75f8ae4b3a3dbd4eacbef205be1683cfa'
6
+ metadata.gz: f883afb7b2d7c806c765e54ca0169ce845fe8a087633676408f5dec3931373f0f152edd4a7a1e49f876b26f213f5e8960dc994294fd17087df5c437a1445f9b3
7
+ data.tar.gz: e5d84fdd428ab13733063d67ef6589421a387c4c5a844ff04950afe1e9a90a79e3ad7556336853a0087f3bdeb797f8ece670ae192f5069e2f21daa12b4438512
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
+
10
20
  ## [3.6.1] - 2026-08-07
11
21
 
12
22
  ### Security
data/README.md CHANGED
@@ -781,7 +781,7 @@ Bug reports and pull requests: [github.com/igmarin/rails-ai-bridge/issues](https
781
781
 
782
782
  ## Acknowledgments & Origins
783
783
 
784
- This gem ships as **rails-ai-bridge** (Ruby **`RailsAiBridge`**, version **3.6.1**). 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`.
785
785
 
786
786
  RailsMCP evolved from
787
787
  [crisnahine/rails-ai-context](https://github.com/crisnahine/rails-ai-context),
data/UPGRADING.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## Upgrading from 3.6.0 to 3.6.1
4
15
 
5
16
  **One action required if you are pinned to `rubydex` 0.2.x:**
@@ -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.1). 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiBridge
4
- VERSION = '3.6.1'
4
+ VERSION = '3.6.2'
5
5
  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.1
4
+ version: 3.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Marin
@@ -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