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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +50 -0
- data/lib/generators/rails_ai_bridge/install/install_generator.rb +8 -0
- data/lib/rails_ai_bridge/config/output.rb +6 -0
- data/lib/rails_ai_bridge/configuration.rb +1 -0
- data/lib/rails_ai_bridge/doctor/checkers/bridge_freshness_checker.rb +20 -1
- data/lib/rails_ai_bridge/freshness_header.rb +11 -0
- data/lib/rails_ai_bridge/serializers/context_file_serializer.rb +89 -11
- data/lib/rails_ai_bridge/serializers/managed_region.rb +88 -0
- data/lib/rails_ai_bridge/tasks/rails_ai_bridge.rake +18 -0
- data/lib/rails_ai_bridge/version.rb +1 -1
- data/lib/rails_ai_bridge.rb +5 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb96afa6948003790b0b563b8eef3730f2b438827727c153bcb785dadcc4174c
|
|
4
|
+
data.tar.gz: ab704409578f9717495b18007632dcd1730db3758e14e3af31f7e1683a99c58c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
145
|
-
|
|
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,
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
data/lib/rails_ai_bridge.rb
CHANGED
|
@@ -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.
|
|
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
|