rails-ai-bridge 3.6.2 → 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: 0ed19c9c79914f2b5ad8707d51a634c4bae469e3d7d07bd06cf23ed6d89a6027
4
- data.tar.gz: b58364346943cfe18414ff86cfb08b95a5bfe5ffef5d9c9949d8eb2221af1c7d
3
+ metadata.gz: eb96afa6948003790b0b563b8eef3730f2b438827727c153bcb785dadcc4174c
4
+ data.tar.gz: ab704409578f9717495b18007632dcd1730db3758e14e3af31f7e1683a99c58c
5
5
  SHA512:
6
- metadata.gz: f883afb7b2d7c806c765e54ca0169ce845fe8a087633676408f5dec3931373f0f152edd4a7a1e49f876b26f213f5e8960dc994294fd17087df5c437a1445f9b3
7
- data.tar.gz: e5d84fdd428ab13733063d67ef6589421a387c4c5a844ff04950afe1e9a90a79e3ad7556336853a0087f3bdeb797f8ece670ae192f5069e2f21daa12b4438512
6
+ metadata.gz: 298fa9f82f3217c75701931742d9107dd9cf964a957740d120de309fe7fd56573124b06b425fe60529de35311792402611570eba579d4f6e7b98cd8af0f3e26f
7
+ data.tar.gz: 81464c5b6590fed85d45b87620bf7f8f3338336e2a9e83f36e6c584de86a7869abaad9430ef402bdda784e9d06fe2ad30ac3d8b89b62dafc80868ebc7ae74307
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ 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
+
10
33
  ## [3.6.2] - 2026-08-07
11
34
 
12
35
  ### Added
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:
@@ -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
@@ -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.
@@ -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.2'
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.2
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Marin
@@ -305,6 +305,7 @@ files:
305
305
  - lib/rails_ai_bridge/serializers/formatters/sections/turbo_formatter.rb
306
306
  - lib/rails_ai_bridge/serializers/formatters/sections/views_formatter.rb
307
307
  - lib/rails_ai_bridge/serializers/json_serializer.rb
308
+ - lib/rails_ai_bridge/serializers/managed_region.rb
308
309
  - lib/rails_ai_bridge/serializers/markdown_serializer.rb
309
310
  - lib/rails_ai_bridge/serializers/provider_document_header.rb
310
311
  - lib/rails_ai_bridge/serializers/providers/base.rb