rails-ai-bridge 3.6.1 → 3.7.0

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: eb96afa6948003790b0b563b8eef3730f2b438827727c153bcb785dadcc4174c
4
+ data.tar.gz: ab704409578f9717495b18007632dcd1730db3758e14e3af31f7e1683a99c58c
5
5
  SHA512:
6
- metadata.gz: 73de5c66876b8b4ca8e7a50e3bdaaf8700984f23f884f492d7ebc880aee3fd3345efd847c4d4252012ac920c8e5450877767e79e48ecdc141f74b0d0d5dc5dd8
7
- data.tar.gz: '08b0c68f5f5af762af8c87e937ba5360f5fa6732f07b3543d24bb000e118f5dad55ca4228984f7f88dc471ea70b8f3a75f8ae4b3a3dbd4eacbef205be1683cfa'
6
+ metadata.gz: 298fa9f82f3217c75701931742d9107dd9cf964a957740d120de309fe7fd56573124b06b425fe60529de35311792402611570eba579d4f6e7b98cd8af0f3e26f
7
+ data.tar.gz: 81464c5b6590fed85d45b87620bf7f8f3338336e2a9e83f36e6c584de86a7869abaad9430ef402bdda784e9d06fe2ad30ac3d8b89b62dafc80868ebc7ae74307
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.0.0] - 2026-08-08 (planned)
11
+
12
+ ### Changed (breaking)
13
+
14
+ - **`mcp` gem raised to 1.x** (#104/#118) — gemspec now requires `mcp >= 1.0, < 2.0` (was `>= 0.25, < 1.0`). Full suite green on **mcp 1.1.0** with no production code changes. Hosts must run `bundle update mcp` after upgrading. See [UPGRADING.md](UPGRADING.md) for details.
15
+
16
+ ### Fixed
17
+
18
+ - **Ruby 4.0 test timing** (#104/#118) — ReDoS and perf specs use `Process.clock_gettime` instead of the `benchmark` gem (no longer a default gem on Ruby 4.0+).
19
+
20
+ ## [3.7.0] - 2026-08-08
21
+
22
+ ### Added
23
+
24
+ - **Managed regions preserve hand-authored content in provider files** (#98/#119) — opt in with `config.output.managed_region = true` (or `MERGE=1 rails ai:bridge`) and generated context is confined to a `<!-- BEGIN rails-ai-bridge: … -->` / `<!-- END rails-ai-bridge -->` block. Prose written above or below the block survives every regeneration. A pre-existing hand-authored file gets the block **appended** rather than clobbered; a file this gem previously generated (detected via its leading freshness header) is replaced, so opting in never leaves a stale second copy of the context above the block. Markdown provider files only (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.cursorrules`, `.devinrules`); `.ai-context.json` never receives markers. Default behavior is unchanged — files are still rewritten in full unless you opt in.
25
+ - **`ai:doctor` reads freshness from inside the managed region** (#98/#119) — files whose freshness header is preceded by hand-authored prose are no longer misreported as stale. `.ai-context.json` is still read whole, since it never carries markers.
26
+
27
+ ### Fixed
28
+
29
+ - **Decoupled `ManagedRegionLayout` from `FreshnessHeader::HEADER_PATTERN`** (#120) — extracted a public `FreshnessHeader.gem_generated?` predicate so the layout doesn't reach into a private constant. Memoized `whole_file_output` to avoid duplicate header checks per write cycle.
30
+ - **Trailing blank lines on append** (#120) — `ManagedRegion.merge` now uses `rstrip` instead of `chomp`, so appending to a file with multiple trailing newlines doesn't produce extra blank lines.
31
+ - **Documented marker edge cases** (#120) — README now warns about marker-shaped lines in hand-authored prose and about deleting both markers.
32
+
33
+ ## [3.6.2] - 2026-08-07
34
+
35
+ ### Added
36
+
37
+ - **`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).
38
+
39
+ ### Fixed
40
+
41
+ - **`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`).
42
+
10
43
  ## [3.6.1] - 2026-08-07
11
44
 
12
45
  ### Security
data/README.md CHANGED
@@ -536,6 +536,7 @@ end
536
536
  | `http_port` | `6029` | HTTP server port |
537
537
  | `cache_ttl` | `30` | Cache TTL in seconds |
538
538
  | `watcher_formats` | `:all` | Formats regenerated by `rails ai:watch` (e.g. `%i[claude cursor]` to limit churn) |
539
+ | `managed_region` | `false` | Confine generated output to a marked block so hand-authored content in `CLAUDE.md` & co. survives regeneration |
539
540
  | `parallel_introspection` | `false` | Run introspectors concurrently (requires `concurrent-ruby`, which is already a Rails dependency) |
540
541
  | `parallel_pool_size` | `4` | Max threads in the parallel pool; capped at the number of active introspectors so no idle threads are created |
541
542
  | `parallel_timeout_seconds` | `10` | Per-introspector future timeout (seconds); timed-out introspectors return `{ error: "timed out after Ns" }` without blocking the others |
@@ -550,6 +551,55 @@ end
550
551
  Other HTTP MCP knobs live only on the nested object, for example `RailsAiBridge.configuration.mcp.authorize`, `mcp.mode`, `mcp.security_profile`, and `mcp.require_auth_in_production` — see [docs/GUIDE.md](docs/GUIDE.md) and [docs/mcp-security.md](docs/mcp-security.md).
551
552
  </details>
552
553
 
554
+ ### Keeping your own notes in `CLAUDE.md`
555
+
556
+ By default every `rails ai:bridge` run rewrites each provider file in full, so hand-authored
557
+ guidance is lost. Opt into **managed regions** to keep it:
558
+
559
+ ```ruby
560
+ RailsAiBridge.configure do |config|
561
+ config.output.managed_region = true
562
+ end
563
+ ```
564
+
565
+ or per run: `MERGE=1 rails ai:bridge` (and `MERGE=0` to force a full rewrite).
566
+
567
+ Generated content is then confined to a marked block, and anything you write around it is
568
+ left alone:
569
+
570
+ ```markdown
571
+ # House rules
572
+
573
+ Always run `bin/rubocop` before committing.
574
+
575
+ <!-- BEGIN rails-ai-bridge: generated. Edits inside this block are overwritten on `rails ai:bridge`. -->
576
+ …generated context…
577
+ <!-- END rails-ai-bridge -->
578
+ ```
579
+
580
+ - **No file yet** → the marked block is written on its own.
581
+ - **Markers present** → only the block is replaced; content above and below is untouched.
582
+ - **Existing file, no markers** → the block is **appended**; your file is not clobbered.
583
+ - **Existing file that this gem generated** (its first line is the `<!-- Generated at: … -->`
584
+ header) → replaced, not appended, so turning the flag on doesn't leave a stale second
585
+ copy of the context above the block. If you had appended prose to that generated file,
586
+ wrap the generated part in the markers by hand before your first run to keep it.
587
+
588
+ Markdown provider files only (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`,
589
+ `.github/copilot-instructions.md`, `.cursorrules`, `.devinrules`). `.ai-context.json` never
590
+ receives markers. Freshness skipping and `ai:doctor` staleness both read through the region,
591
+ so unchanged runs are still no-ops.
592
+
593
+ **Caveats:**
594
+
595
+ - Avoid writing marker-shaped lines (`<!-- BEGIN rails-ai-bridge: … -->` or
596
+ `<!-- END rails-ai-bridge -->`) in your own prose — the first BEGIN and the first
597
+ following END delimit the managed region, so a stray marker in hand-authored content
598
+ would truncate or misidentify the block.
599
+ - If you delete both markers from a file, the old generated content is treated as your
600
+ own prose on the next run and a new block is appended. Re-add markers around the
601
+ generated part before your next run, or delete it manually.
602
+
553
603
  ### Extending the built-ins
554
604
 
555
605
  If you need host-app or companion-gem extensions, register them explicitly in the initializer:
@@ -781,7 +831,7 @@ Bug reports and pull requests: [github.com/igmarin/rails-ai-bridge/issues](https
781
831
 
782
832
  ## Acknowledgments & Origins
783
833
 
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`.
834
+ 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
835
 
786
836
  RailsMCP evolved from
787
837
  [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:**
@@ -146,6 +146,14 @@ module RailsAiBridge
146
146
  # from config/rails_ai_bridge/overrides.md before this has any effect.
147
147
  # config.assistant_overrides_path = "config/rails_ai_bridge/overrides.md"
148
148
 
149
+ # Keep hand-authored prose inline in CLAUDE.md / AGENTS.md / GEMINI.md.
150
+ # When true, generated content is confined to a marked block:
151
+ # <!-- BEGIN rails-ai-bridge: generated ... --> ... <!-- END rails-ai-bridge -->
152
+ # Anything you write above or below that block survives every regeneration, and an
153
+ # existing file without markers gets the block appended rather than overwritten.
154
+ # Never applies to .ai-context.json. Per-run override: `MERGE=1 rails ai:bridge`.
155
+ # config.output.managed_region = true
156
+
149
157
  # Model list size caps for compact output (0 = show no names, only MCP pointer):
150
158
  # Reduce these for apps with large model counts to keep files within size limits.
151
159
  # config.copilot_compact_model_list_limit = 15 # default
@@ -29,6 +29,11 @@ module RailsAiBridge
29
29
  # Defaults to +:all+. Set to e.g. +%i[claude cursor]+ to limit churn.
30
30
  attr_accessor :watcher_formats
31
31
 
32
+ # @return [Boolean] when +true+, generated output is confined to a marked region
33
+ # in each markdown provider file so hand-authored content around it survives
34
+ # regeneration. Never applies to +.ai-context.json+. Defaults to +false+.
35
+ attr_accessor :managed_region
36
+
32
37
  def initialize
33
38
  @output_dir = nil
34
39
  @context_mode = :compact
@@ -38,6 +43,7 @@ module RailsAiBridge
38
43
  @copilot_compact_model_list_limit = 5
39
44
  @codex_compact_model_list_limit = 3
40
45
  @watcher_formats = :all
46
+ @managed_region = false
41
47
  end
42
48
 
43
49
  # Resolve the effective output directory.
@@ -151,6 +151,7 @@ module RailsAiBridge
151
151
  :copilot_compact_model_list_limit, :copilot_compact_model_list_limit=,
152
152
  :codex_compact_model_list_limit, :codex_compact_model_list_limit=,
153
153
  :watcher_formats, :watcher_formats=,
154
+ :managed_region, :managed_region=,
154
155
  :output_dir_for
155
156
  end
156
157
  end
@@ -95,18 +95,37 @@ module RailsAiBridge
95
95
 
96
96
  # Checks whether a single file's embedded fingerprint matches the current one.
97
97
  #
98
+ # Reads through the managed region when one is present, so files generated with
99
+ # +config.output.managed_region+ (where hand-authored prose precedes the freshness
100
+ # header) are not misreported as stale.
101
+ #
98
102
  # @param fmt [Symbol] format key
99
103
  # @param filepath [String] absolute file path
100
104
  # @param current_fp [String] current source fingerprint
101
105
  # @return [Boolean] +true+ if the file is stale or unreadable
102
106
  # :reek:UtilityFunction
103
107
  def stale?(fmt, filepath, current_fp)
104
- content = File.read(filepath)
108
+ content = read_generated_payload(fmt, filepath)
105
109
  RailsAiBridge::FreshnessHeader.extract_fingerprint_for(fmt, content) != current_fp
106
110
  rescue StandardError
107
111
  true
108
112
  end
109
113
 
114
+ # Reads a bridge file, narrowing to the managed region for formats that can carry
115
+ # one. JSON never gets markers, so it is read whole — narrowing it on a stray
116
+ # marker-shaped string value would truncate valid JSON.
117
+ #
118
+ # @param fmt [Symbol] format key
119
+ # @param filepath [String] absolute file path
120
+ # @return [String] content to read freshness metadata from
121
+ # :reek:UtilityFunction
122
+ def read_generated_payload(fmt, filepath)
123
+ content = File.read(filepath)
124
+ return content if RailsAiBridge::Serializers::ContextFileSerializer::UNMANAGEABLE_FORMATS.include?(fmt)
125
+
126
+ RailsAiBridge::Serializers::ManagedRegion.generated_payload(content)
127
+ end
128
+
110
129
  # Builds the diagnostic outcome from a scan result.
111
130
  #
112
131
  # @param scan [ScanResult] file scan results
@@ -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
@@ -84,6 +84,17 @@ module RailsAiBridge
84
84
  match ? match[3] : nil
85
85
  end
86
86
 
87
+ # Returns +true+ when +content+ starts with the gem's freshness header,
88
+ # indicating it is prior whole-file output from this gem (not hand-authored).
89
+ #
90
+ # @param content [String, nil]
91
+ # @return [Boolean]
92
+ def gem_generated?(content)
93
+ return false unless content
94
+
95
+ HEADER_PATTERN.match?(content)
96
+ end
97
+
87
98
  private
88
99
 
89
100
  # Embeds freshness metadata into JSON content via a +_meta+ key.
@@ -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
@@ -20,18 +20,24 @@ module RailsAiBridge
20
20
 
21
21
  VALID_ON_CONFLICT_SYMBOLS = %i[overwrite skip prompt].freeze
22
22
 
23
+ # Managed regions are markdown-comment delimited, so JSON output never participates.
24
+ UNMANAGEABLE_FORMATS = %i[json].freeze
25
+
23
26
  # @param context [Hash] introspection context from {RailsAiBridge.introspect}
24
27
  # @param format [Symbol, Array<Symbol>] format(s) to generate
25
28
  # @param split_rules [Boolean] whether to generate per-assistant rule directories
26
29
  # @param on_conflict [:overwrite, :skip, :prompt, #call] conflict resolution strategy;
27
30
  # any object responding to +:call+ is invoked with the filepath and must return a
28
31
  # truthy value to allow overwriting
32
+ # @param managed_region [Boolean, nil] confine generated output to a marked region so
33
+ # hand-authored content in the file survives; +nil+ inherits +config.output.managed_region+
29
34
  # @raise [ArgumentError] when +on_conflict+ is not a recognised symbol or callable
30
- def initialize(context, format: :all, split_rules: true, on_conflict: :overwrite)
35
+ def initialize(context, format: :all, split_rules: true, on_conflict: :overwrite, managed_region: nil)
31
36
  @context = context
32
37
  @format = format
33
38
  @split_rules = split_rules
34
39
  @conflict_policy = ConflictPolicy.build(on_conflict)
40
+ @managed_region = managed_region.nil? ? RailsAiBridge.configuration.managed_region : managed_region
35
41
  end
36
42
 
37
43
  # Write context files to the configured output directory, skipping unchanged ones.
@@ -70,10 +76,22 @@ module RailsAiBridge
70
76
  filepath = File.join(output_dir, filename)
71
77
  FileUtils.mkdir_p(File.dirname(filepath))
72
78
 
73
- writer = FreshnessWriter.new(fmt, serialize(fmt), fingerprint, timestamp_now)
79
+ writer = FreshnessWriter.new(fmt, serialize(fmt), fingerprint, timestamp_now, layout: layout_for(fmt))
74
80
  writer.write_to(filepath, @conflict_policy, written, skipped)
75
81
  end
76
82
 
83
+ # @param fmt [Symbol] format key
84
+ # @return [WholeFileLayout, ManagedRegionLayout] how the payload occupies the file
85
+ def layout_for(fmt)
86
+ managed_region?(fmt) ? ManagedRegionLayout.new : WholeFileLayout.new
87
+ end
88
+
89
+ # @param fmt [Symbol] format key
90
+ # @return [Boolean] +true+ when this format should write into a marked region
91
+ def managed_region?(fmt)
92
+ @managed_region && UNMANAGEABLE_FORMATS.exclude?(fmt)
93
+ end
94
+
77
95
  # @param filepath [String] candidate output path
78
96
  # @return [Boolean] +true+ when the file should be overwritten
79
97
  def overwrite?(filepath)
@@ -99,6 +117,56 @@ module RailsAiBridge
99
117
  end
100
118
  end
101
119
 
120
+ # Default layout: the generated payload is the entire file.
121
+ class WholeFileLayout
122
+ # @param existing [String, nil] current file content
123
+ # @return [String, nil] previously generated payload
124
+ def previous_payload(existing) = existing
125
+
126
+ # @param _existing [String, nil] current file content (discarded)
127
+ # @param payload [String] freshly generated content
128
+ # @return [String] content to write
129
+ def compose(_existing, payload) = payload
130
+ end
131
+
132
+ # Managed-region layout: only the marked block belongs to the gem; anything the
133
+ # user wrote above or below it is carried through untouched.
134
+ class ManagedRegionLayout
135
+ # @param existing [String, nil] current file content
136
+ # @return [String, nil] payload inside the markers, the whole file when it is
137
+ # unmarked gem output, or +nil+ when the file is hand-authored
138
+ def previous_payload(existing)
139
+ ManagedRegion.extract(existing) || whole_file_output(existing)
140
+ end
141
+
142
+ # @param existing [String, nil] current file content
143
+ # @param payload [String] freshly generated content
144
+ # @return [String] content to write
145
+ def compose(existing, payload)
146
+ ManagedRegion.merge(whole_file_output(existing) ? nil : existing, payload)
147
+ end
148
+
149
+ private
150
+
151
+ # A file led by the freshness header is prior whole-file output from this gem, so
152
+ # none of it is hand-authored. Replacing it keeps the first run after opting in
153
+ # from appending a second copy of the context below the stale one — a duplicate
154
+ # that would then never refresh, because it now reads as user content.
155
+ #
156
+ # Memoized per instance so the header check runs once per write cycle, not twice
157
+ # (previous_payload + compose both call it).
158
+ #
159
+ # @param existing [String, nil] current file content
160
+ # @return [String, nil] +existing+ when it is unmarked gem output
161
+ def whole_file_output(existing)
162
+ return @cached_result if @cached_for == existing
163
+
164
+ @cached_for = existing
165
+ @cached_result = existing if existing && !ManagedRegion.markers?(existing) && FreshnessHeader.gem_generated?(existing)
166
+ end
167
+ end
168
+ private_constant :WholeFileLayout, :ManagedRegionLayout
169
+
102
170
  # Encapsulates format-specific freshness metadata embedding and file write logic.
103
171
  # Separating this from ContextFileSerializer removes ControlParameter and UtilityFunction
104
172
  # reek warnings from the serializer (the fmt-branching now lives in the right class).
@@ -107,11 +175,13 @@ module RailsAiBridge
107
175
  # @param raw_content [String] serialized content before freshness embedding
108
176
  # @param fingerprint [String] 12-char source fingerprint
109
177
  # @param timestamp_now [String] ISO 8601 UTC timestamp
110
- def initialize(fmt, raw_content, fingerprint, timestamp_now)
178
+ # @param layout [#previous_payload, #compose] how the payload occupies the file
179
+ def initialize(fmt, raw_content, fingerprint, timestamp_now, layout: WholeFileLayout.new)
111
180
  @fmt = fmt
112
181
  @raw_content = raw_content
113
182
  @fingerprint = fingerprint
114
183
  @timestamp_now = timestamp_now
184
+ @layout = layout
115
185
  end
116
186
 
117
187
  # Writes the file to disk, skipping if unchanged or blocked by the conflict policy.
@@ -124,13 +194,13 @@ module RailsAiBridge
124
194
  # :reek:LongParameterList
125
195
  def write_to(filepath, conflict_policy, written, skipped)
126
196
  existing_content = read_existing(filepath)
127
- timestamp_to_use = resolve_timestamp(existing_content)
128
- candidate = build_candidate_content(timestamp_to_use)
197
+ timestamp_to_use = resolve_timestamp(@layout.previous_payload(existing_content))
198
+ candidate = compose(existing_content, timestamp_to_use)
129
199
 
130
200
  if skip?(filepath, existing_content, candidate, conflict_policy)
131
201
  skipped << filepath
132
202
  else
133
- write_file(filepath, candidate, timestamp_to_use)
203
+ write_file(filepath, existing_content, candidate, timestamp_to_use)
134
204
  written << filepath
135
205
  end
136
206
  end
@@ -141,10 +211,11 @@ module RailsAiBridge
141
211
  File.exist?(filepath) ? File.read(filepath) : nil
142
212
  end
143
213
 
144
- def resolve_timestamp(existing_content)
145
- return @timestamp_now unless existing_content
214
+ # @param previous_payload [String, nil] the gem-owned portion of the existing file
215
+ def resolve_timestamp(previous_payload)
216
+ return @timestamp_now unless previous_payload
146
217
 
147
- embedded_fp, embedded_ts = FreshnessHeader.extract_metadata_for(@fmt, existing_content)
218
+ embedded_fp, embedded_ts = FreshnessHeader.extract_metadata_for(@fmt, previous_payload)
148
219
  embedded_fp == @fingerprint && embedded_ts ? embedded_ts : @timestamp_now
149
220
  end
150
221
 
@@ -152,12 +223,19 @@ module RailsAiBridge
152
223
  FreshnessHeader.embed_for(@fmt, @raw_content, timestamp, @fingerprint)
153
224
  end
154
225
 
226
+ # Full file content for the given timestamp, including any hand-authored
227
+ # content the layout preserves.
228
+ def compose(existing_content, timestamp)
229
+ @layout.compose(existing_content, build_candidate_content(timestamp))
230
+ end
231
+
155
232
  def skip?(filepath, existing_content, candidate, conflict_policy)
156
233
  existing_content && (existing_content == candidate || !conflict_policy.overwrite?(filepath))
157
234
  end
158
235
 
159
- def write_file(filepath, candidate, timestamp_to_use)
160
- final_content = timestamp_to_use == @timestamp_now ? candidate : build_candidate_content(@timestamp_now)
236
+ # :reek:LongParameterList
237
+ def write_file(filepath, existing_content, candidate, timestamp_to_use)
238
+ final_content = timestamp_to_use == @timestamp_now ? candidate : compose(existing_content, @timestamp_now)
161
239
  File.write(filepath, final_content)
162
240
  end
163
241
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiBridge
4
+ module Serializers
5
+ # Delimits the gem-owned portion of a generated provider file so hand-authored
6
+ # content around it survives regeneration.
7
+ #
8
+ # A managed file looks like:
9
+ #
10
+ # House rules the team wrote by hand.
11
+ #
12
+ # <!-- BEGIN rails-ai-bridge: generated. Edits inside this block are overwritten on `rails ai:bridge`. -->
13
+ # …generated context…
14
+ # <!-- END rails-ai-bridge -->
15
+ #
16
+ # Only the first region is treated as managed; everything else in the file belongs
17
+ # to the user and is never rewritten. An unterminated BEGIN marker (a truncated or
18
+ # hand-edited file) is treated as running to end of file, so the next run heals it
19
+ # rather than nesting a second block inside the first.
20
+ module ManagedRegion
21
+ BEGIN_MARKER = '<!-- BEGIN rails-ai-bridge: generated. Edits inside this block are overwritten on `rails ai:bridge`. -->'
22
+ END_MARKER = '<!-- END rails-ai-bridge -->'
23
+
24
+ # Tolerant of marker-text drift (older gem versions worded the notice differently)
25
+ # and of trailing whitespace, so a region written by any version is still recognised.
26
+ BEGIN_PATTERN = /^<!-- BEGIN rails-ai-bridge:[^\n]*-->[^\S\n]*\n/
27
+ END_PATTERN = /^<!-- END rails-ai-bridge -->[^\S\n]*(?:\n|\z)/
28
+ REGION_PATTERN = /#{BEGIN_PATTERN}(.*?)(?:#{END_PATTERN}|\z)/m
29
+
30
+ class << self
31
+ # Wraps generated content in the managed-region markers.
32
+ #
33
+ # @param payload [String] generated content
34
+ # @return [String] marked block, newline-terminated
35
+ def wrap(payload)
36
+ "#{BEGIN_MARKER}\n#{payload.to_s.chomp}\n#{END_MARKER}\n"
37
+ end
38
+
39
+ # @param content [String, nil] file content to inspect
40
+ # @return [Boolean] +true+ when a managed region is present
41
+ def markers?(content)
42
+ return false unless content
43
+
44
+ REGION_PATTERN.match?(content)
45
+ end
46
+
47
+ # Extracts the generated payload from a managed file.
48
+ #
49
+ # @param content [String, nil] file content to inspect
50
+ # @return [String, nil] payload without markers, or +nil+ when unmarked
51
+ def extract(content)
52
+ return nil unless content
53
+
54
+ content[REGION_PATTERN, 1]&.chomp
55
+ end
56
+
57
+ # Returns the gem-owned portion of a file: the managed region when one is
58
+ # present, otherwise the whole file. Lets callers that only care about the
59
+ # generated payload (freshness metadata, staleness checks) stay agnostic
60
+ # about whether managed regions are enabled.
61
+ #
62
+ # @param content [String, nil] file content to inspect
63
+ # @return [String, nil]
64
+ def generated_payload(content)
65
+ extract(content) || content
66
+ end
67
+
68
+ # Combines existing file content with a freshly generated payload.
69
+ #
70
+ # * no existing content → the marked block alone
71
+ # * existing content with markers → only the region is replaced
72
+ # * existing content without markers → the block is appended, preserving the file
73
+ #
74
+ # @param existing [String, nil] current file content
75
+ # @param payload [String] generated content
76
+ # @return [String] content to write
77
+ def merge(existing, payload)
78
+ block = wrap(payload)
79
+ return block if existing.nil? || existing.strip.empty?
80
+ # Block form: a String replacement would interpret backslash escapes in the payload.
81
+ return existing.sub(REGION_PATTERN) { block } if markers?(existing)
82
+
83
+ "#{existing.rstrip}\n\n#{block}"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -41,6 +41,20 @@ module RailsAiBridge
41
41
  puts "📐 Context mode: #{mode}"
42
42
  end
43
43
 
44
+ # Overrides managed-region output from the +MERGE+ env var when it is set.
45
+ # +MERGE=1+ confines generated content to a marked block in each markdown provider
46
+ # file, preserving hand-authored prose around it. +MERGE=0+ forces whole-file writes
47
+ # even when the initializer turns managed regions on.
48
+ #
49
+ # @return [void]
50
+ def self.apply_managed_region_override
51
+ return unless ENV.key?('MERGE')
52
+
53
+ enabled = TRUTHY_ENV_VALUES.include?(ENV['MERGE'].to_s.downcase.strip)
54
+ RailsAiBridge.configuration.managed_region = enabled
55
+ puts enabled ? '🧩 Managed region: on (hand-authored content preserved)' : '🧩 Managed region: off (files rewritten in full)'
56
+ end
57
+
44
58
  # Returns :prompt when CONFIRM is one of "1", "true", "yes", "y" so rake tasks
45
59
  # ask before overwriting changed files. CONFIRM=0 or CONFIRM=false stays silent.
46
60
  # Resolves the conflict strategy from the +CONFIRM+ env var.
@@ -81,6 +95,7 @@ namespace :ai do
81
95
  require 'rails_ai_bridge'
82
96
 
83
97
  RailsAiBridge::RakeHelpers.apply_context_mode_override
98
+ RailsAiBridge::RakeHelpers.apply_managed_region_override
84
99
  RailsAiBridge::RakeHelpers.run_pre_generation_checks
85
100
 
86
101
  puts "🔍 Introspecting #{Rails.application.class.module_parent_name}..."
@@ -104,6 +119,7 @@ namespace :ai do
104
119
  require 'rails_ai_bridge'
105
120
 
106
121
  RailsAiBridge::RakeHelpers.apply_context_mode_override
122
+ RailsAiBridge::RakeHelpers.apply_managed_region_override
107
123
  RailsAiBridge::RakeHelpers.run_pre_generation_checks
108
124
 
109
125
  format = (args[:format] || ENV['FORMAT'] || 'claude').to_sym
@@ -128,6 +144,7 @@ namespace :ai do
128
144
  require 'rails_ai_bridge'
129
145
 
130
146
  RailsAiBridge::RakeHelpers.apply_context_mode_override
147
+ RailsAiBridge::RakeHelpers.apply_managed_region_override
131
148
  RailsAiBridge::RakeHelpers.run_pre_generation_checks
132
149
 
133
150
  puts "🔍 Introspecting #{Rails.application.class.module_parent_name}..."
@@ -148,6 +165,7 @@ namespace :ai do
148
165
  require 'rails_ai_bridge'
149
166
 
150
167
  RailsAiBridge::RakeHelpers.apply_context_mode_override
168
+ RailsAiBridge::RakeHelpers.apply_managed_region_override
151
169
  RailsAiBridge.configuration.context_mode = :full
152
170
  RailsAiBridge::RakeHelpers.run_pre_generation_checks
153
171
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiBridge
4
- VERSION = '3.6.1'
4
+ VERSION = '3.7.0'
5
5
  end
@@ -55,6 +55,8 @@ module RailsAiBridge
55
55
  # @option options [:overwrite, :skip, :prompt, #call] :on_conflict behaviour when a file exists with
56
56
  # different content. +:overwrite+ (default) silently replaces; +:skip+ keeps the existing file;
57
57
  # +:prompt+ asks via stdin; any callable receives the filepath and returns truthy to overwrite.
58
+ # @option options [Boolean] :managed_region confine generated output to a marked region in each
59
+ # markdown provider file so hand-authored content survives; defaults to +config.output.managed_region+
58
60
  # @return [Hash{Symbol => Array<String>}] files grouped under +:written+ and +:skipped+
59
61
  # @raise [ArgumentError] when an unknown option key is passed
60
62
  def generate_context(app = nil, **options)
@@ -99,7 +101,7 @@ module RailsAiBridge
99
101
  private
100
102
 
101
103
  def validate_generate_context_options!(options)
102
- allowed = %i[format split_rules on_conflict].to_set
104
+ allowed = %i[format split_rules on_conflict managed_region].to_set
103
105
  unknown = options.keys.to_set - allowed
104
106
  return if unknown.empty?
105
107
 
@@ -110,7 +112,8 @@ module RailsAiBridge
110
112
  Serializers::ContextFileSerializer.new(context,
111
113
  format: options.fetch(:format, :all),
112
114
  split_rules: options.fetch(:split_rules, true),
113
- on_conflict: options.fetch(:on_conflict, :overwrite))
115
+ on_conflict: options.fetch(:on_conflict, :overwrite),
116
+ managed_region: options.fetch(:managed_region, nil))
114
117
  end
115
118
 
116
119
  public
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.7.0
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
@@ -304,6 +305,7 @@ files:
304
305
  - lib/rails_ai_bridge/serializers/formatters/sections/turbo_formatter.rb
305
306
  - lib/rails_ai_bridge/serializers/formatters/sections/views_formatter.rb
306
307
  - lib/rails_ai_bridge/serializers/json_serializer.rb
308
+ - lib/rails_ai_bridge/serializers/managed_region.rb
307
309
  - lib/rails_ai_bridge/serializers/markdown_serializer.rb
308
310
  - lib/rails_ai_bridge/serializers/provider_document_header.rb
309
311
  - lib/rails_ai_bridge/serializers/providers/base.rb