rails-hyperdrive 0.3.0 → 0.4.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.
@@ -1,11 +1,9 @@
1
1
  require "digest"
2
- require "rails/hyperdrive/audit_header"
3
2
 
4
3
  module Rails
5
4
  module Hyperdrive
6
- # The lock's source_sha is computed over the install-ready body before any
7
- # header injection, so hashing an installed file's comparable bytes
8
- # (disk_sha) reproduces it exactly for an unedited file.
5
+ # Installed files are byte-identical to their install-ready body, so an
6
+ # unedited file's disk hash reproduces the lock's source_sha exactly.
9
7
  module DriftVerdict
10
8
  STATES = %i[current outdated edited missing orphaned].freeze
11
9
 
@@ -16,25 +14,22 @@ module Rails
16
14
  Digest::SHA256.hexdigest(content.to_s)
17
15
  end
18
16
 
19
- # AuditHeader.strip's line matching can raise on binary content, so
20
- # skill_support files compare as raw bytes.
21
- def disk_sha(file, kind:)
22
- return body_sha(File.binread(file)) if kind.to_s == "skill_support"
23
- body_sha(AuditHeader.strip(File.read(file)))
17
+ def disk_sha(file)
18
+ body_sha(File.binread(file))
24
19
  end
25
20
 
26
- # A file counts as unedited when its comparable bytes still hash to what
27
- # the lock recorded at install time, not to what the gem ships now.
21
+ # A file counts as unedited when its bytes still hash to what the lock
22
+ # recorded at install time, not to what the gem ships now.
28
23
  def unedited?(file, lock_entry:)
29
- disk_sha(file, kind: lock_entry.kind) == lock_entry.source_sha
24
+ disk_sha(file) == lock_entry.source_sha
30
25
  end
31
26
 
32
27
  # gem_sha nil means the bundle no longer offers this destination.
33
- def verdict(file:, kind:, lock_entry:, gem_sha:)
28
+ def verdict(file:, lock_entry:, gem_sha:)
34
29
  return File.exist?(file) ? :orphaned : :missing if gem_sha.nil?
35
30
  return :missing unless File.exist?(file)
36
31
  return :edited if lock_entry.nil?
37
- return :edited if disk_sha(file, kind: kind) != lock_entry.source_sha
32
+ return :edited if disk_sha(file) != lock_entry.source_sha
38
33
  lock_entry.source_sha == gem_sha ? :current : :outdated
39
34
  end
40
35
  end
@@ -20,6 +20,10 @@ module Rails
20
20
  end
21
21
  end
22
22
 
23
+ def sidecar_path(dest)
24
+ "#{dest}.new"
25
+ end
26
+
23
27
  def installed_name(type, dest)
24
28
  case type
25
29
  when :skill then File.basename(File.dirname(dest))
@@ -1,8 +1,9 @@
1
1
  require "time"
2
- require "rails/hyperdrive/audit_header"
2
+ require "rails/hyperdrive/ancestor_locator"
3
3
  require "rails/hyperdrive/bundler_artifact_discovery"
4
4
  require "rails/hyperdrive/claude_md_import"
5
5
  require "rails/hyperdrive/drift_verdict"
6
+ require "rails/hyperdrive/three_way_merge"
6
7
  require "rails/hyperdrive/eager_footprint"
7
8
  require "rails/hyperdrive/index_document"
8
9
  require "rails/hyperdrive/install_layout"
@@ -20,9 +21,10 @@ module Rails
20
21
  WARN_LINES = 150
21
22
  WARN_TOKENS = 1_500
22
23
 
23
- MODES = %i[preserve overwrite additive].freeze
24
+ MODES = %i[preserve overwrite additive sidecar merge].freeze
24
25
 
25
- Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, keyword_init: true)
26
+ Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, :merged, :sidecars,
27
+ keyword_init: true)
26
28
 
27
29
  def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [])
28
30
  raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
@@ -32,7 +34,9 @@ module Rails
32
34
  @artifacts = artifacts
33
35
  @mode = mode
34
36
  @warnings = warnings
35
- @result = Result.new(installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [])
37
+ @result = Result.new(
38
+ installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [], merged: [], sidecars: []
39
+ )
36
40
  end
37
41
 
38
42
  def call
@@ -81,6 +85,14 @@ module Rails
81
85
  @mode == :overwrite
82
86
  end
83
87
 
88
+ def merge_mode?
89
+ @mode == :merge
90
+ end
91
+
92
+ def reconcile_mode?
93
+ @mode == :sidecar || @mode == :merge
94
+ end
95
+
84
96
  def abs(path)
85
97
  File.join(@root, path)
86
98
  end
@@ -102,7 +114,9 @@ module Rails
102
114
 
103
115
  def install_skills
104
116
  @plan.select { |e| e.type == :skill }.each do |entry|
105
- install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body)
117
+ skill_relpath = source_relpath_for(entry.artifact)
118
+ install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body,
119
+ source_relpath: skill_relpath, final_name: entry.final_name)
106
120
  entry.support_files.each do |file|
107
121
  install_file(
108
122
  dest: file[:dest],
@@ -110,7 +124,8 @@ module Rails
110
124
  install_ready_body: file[:body],
111
125
  source_gem: entry.source_gem,
112
126
  version: entry.version,
113
- artifact_kind: "skill_support"
127
+ artifact_kind: "skill_support",
128
+ source_relpath: skill_relpath && File.join(File.dirname(skill_relpath), file[:path])
114
129
  )
115
130
  end
116
131
  end
@@ -120,12 +135,28 @@ module Rails
120
135
  @plan.select { |e| e.type == :guideline }.map do |entry|
121
136
  body = entry.install_ready_body
122
137
  warn_if_oversize(entry.dest, body)
123
- install_file(entry: entry, type: :guideline, install_ready_body: body)
138
+ install_file(entry: entry, type: :guideline, install_ready_body: body,
139
+ source_relpath: source_relpath_for(entry.artifact), final_name: entry.final_name)
124
140
  { base: "#{entry.final_name}.md", dest: entry.dest, body: body }
125
141
  end
126
142
  end
127
143
 
128
- def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil, artifact_kind: nil)
144
+ # The shipped file's path relative to its gem root, normalized to the
145
+ # install-target form (.md.erb -> .md).
146
+ def source_relpath_for(artifact)
147
+ root = artifact.source_root
148
+ return nil if root.nil? || root.to_s.empty?
149
+
150
+ prefix = "#{File.expand_path(root.to_s)}/"
151
+ expanded = File.expand_path(artifact.path.to_s)
152
+ return nil unless expanded.start_with?(prefix)
153
+
154
+ rel = expanded.delete_prefix(prefix)
155
+ rel.end_with?(".md.erb") ? rel.delete_suffix(".erb") : rel
156
+ end
157
+
158
+ def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil,
159
+ artifact_kind: nil, source_relpath: nil, final_name: nil)
129
160
  write = {
130
161
  dest: dest || entry.dest,
131
162
  type: type,
@@ -140,25 +171,152 @@ module Rails
140
171
  return additive_install(write, old) if additive?
141
172
 
142
173
  file = abs(write[:dest])
143
- case DriftVerdict.verdict(file: file, kind: write[:artifact_kind], lock_entry: old, gem_sha: write[:gem_sha])
174
+ case DriftVerdict.verdict(file: file, lock_entry: old, gem_sha: write[:gem_sha])
144
175
  when :missing
145
176
  @shell.say_status(:reinstall, "#{write[:dest]} (was missing)", :yellow) if old
146
177
  write_artifact(**write)
178
+ sweep_sidecar(write, old)
147
179
  when :current
148
180
  @new_lock.carry(old)
149
181
  @result.unchanged << write[:dest]
150
182
  @shell.say_status :unchanged, write[:dest], :blue
183
+ sweep_sidecar(write, old)
151
184
  when :outdated
152
185
  write_artifact(**write)
186
+ sweep_sidecar(write, old)
153
187
  when :edited
154
188
  if overwrite_mode?
155
189
  write_artifact(**write)
190
+ sweep_sidecar(write, old)
191
+ elsif reconcile_mode?
192
+ reconcile_edited(write, old, source_relpath: source_relpath, final_name: final_name)
193
+ else
194
+ skip_edited(write, old)
195
+ end
196
+ end
197
+ end
198
+
199
+ def skip_edited(write, old)
200
+ @result.skipped << write[:dest]
201
+ @shell.say_status :skip,
202
+ "#{write[:dest]} (locally modified; run hyperdrive:sync with --merge, --sidecar, or --overwrite to reconcile)",
203
+ :yellow
204
+ @new_lock.carry(old) if old
205
+ end
206
+
207
+ # Reconciliation for a locally-modified destination in :sidecar/:merge
208
+ # mode. The lock records the newest upstream *delivered* — installed
209
+ # live, merged in, or written as a sidecar — never the live file's own
210
+ # hash, so the same upstream version is offered exactly once.
211
+ def reconcile_edited(write, old, source_relpath:, final_name:)
212
+ dest = write[:dest]
213
+ sidecar = InstallLayout.sidecar_path(dest)
214
+
215
+ if old && old.source_sha == write[:gem_sha]
216
+ skip_edited(write, old)
217
+ if File.exist?(abs(sidecar))
218
+ @shell.say_status :warn, "#{sidecar} (unresolved sidecar; reconcile it with #{dest}, then delete it)", :yellow
219
+ end
220
+ return
221
+ end
222
+
223
+ # An edited sidecar is user work: leave it, and leave the lock at the
224
+ # old upstream so this delivery is re-offered once the user clears it.
225
+ if File.exist?(abs(sidecar)) && !sidecar_pristine?(sidecar, write, old)
226
+ @result.skipped << dest
227
+ @shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
228
+ @new_lock.carry(old) if old
229
+ return
230
+ end
231
+
232
+ reason = nil
233
+ if merge_mode?
234
+ if File.exist?(abs(sidecar))
235
+ # A pending sidecar means the last delivered upstream never reached
236
+ # the live file, so the reconstructable ancestor is stale; merging
237
+ # over it would silently revert that delivery.
238
+ reason = "previous delivery still unresolved"
156
239
  else
157
- @result.skipped << write[:dest]
158
- @shell.say_status :skip, "#{write[:dest]} (locally modified; run hyperdrive:sync --overwrite to overwrite)", :yellow
159
- @new_lock.carry(old) if old
240
+ merged, reason = attempt_merge(write, old, source_relpath: source_relpath, final_name: final_name)
241
+ if merged
242
+ write_merged(write, merged)
243
+ sweep_sidecar(write, old)
244
+ return
245
+ end
160
246
  end
161
247
  end
248
+
249
+ write_sidecar(write, reason)
250
+ end
251
+
252
+ def attempt_merge(write, old, source_relpath:, final_name:)
253
+ return [nil, "no previous install recorded"] unless old
254
+
255
+ ancestor = AncestorLocator.locate(
256
+ kind: write[:artifact_kind], relpath: source_relpath, lock_entry: old, final_name: final_name
257
+ )
258
+ return [nil, "#{old.source_label} not found in installed gems"] unless ancestor
259
+
260
+ ours = live_body(write)
261
+ if ours.nil? || [ours, ancestor, write[:body]].any? { |b| ThreeWayMerge.binary?(b) }
262
+ return [nil, "binary content"]
263
+ end
264
+
265
+ outcome = ThreeWayMerge.merge(ours: ours, base: ancestor, theirs: write[:body])
266
+ case outcome.status
267
+ when :clean then [outcome.body, nil]
268
+ when :conflicted then [nil, "conflicting edits"]
269
+ else [nil, "git merge-file unavailable"]
270
+ end
271
+ end
272
+
273
+ def live_body(write)
274
+ file = abs(write[:dest])
275
+ return File.binread(file) if write[:type] == :skill_support
276
+ File.read(file)
277
+ rescue StandardError
278
+ nil
279
+ end
280
+
281
+ def write_merged(write, merged_body)
282
+ @shell.create_file write[:dest], merged_body
283
+ @result.merged << write[:dest]
284
+ @shell.say_status :merged, "#{write[:dest]} (local edits merged with #{write[:source_gem]}@#{write[:version]})", :green
285
+ upsert_lock(write, Time.now.utc)
286
+ end
287
+
288
+ # The sidecar is byte-identical to what a live install of the new
289
+ # upstream would write, so `mv <dest>.new <dest>` accepts it wholesale
290
+ # and the lock already verifies it.
291
+ def write_sidecar(write, reason)
292
+ sidecar = InstallLayout.sidecar_path(write[:dest])
293
+ @shell.create_file sidecar, write[:body]
294
+ @result.sidecars << write[:dest]
295
+ message = "#{write[:dest]} (locally modified; new upstream delivered to #{sidecar}"
296
+ message += "; #{reason}" if reason
297
+ @shell.say_status :sidecar, "#{message})", :yellow
298
+ upsert_lock(write, Time.now.utc)
299
+ end
300
+
301
+ # Only a machine-pristine sidecar — one still hashing to a delivered
302
+ # upstream — may be refreshed or removed; anything else is user work.
303
+ def sidecar_pristine?(sidecar, write, old)
304
+ sha = DriftVerdict.disk_sha(abs(sidecar))
305
+ [old&.source_sha, write[:gem_sha]].compact.include?(sha)
306
+ rescue StandardError
307
+ false
308
+ end
309
+
310
+ def sweep_sidecar(write, old)
311
+ sidecar = InstallLayout.sidecar_path(write[:dest])
312
+ return unless File.exist?(abs(sidecar))
313
+
314
+ if sidecar_pristine?(sidecar, write, old)
315
+ @shell.remove_file sidecar
316
+ @result.removed << sidecar
317
+ else
318
+ @shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
319
+ end
162
320
  end
163
321
 
164
322
  # Additive installs can create a file and never overwrite one, so an
@@ -174,24 +332,11 @@ module Rails
174
332
  end
175
333
  end
176
334
 
177
- # Supporting files land byte-identical to what the gem ships — no audit
178
- # header, since they may be non-markdown or binary; provenance and sha
179
- # live in the lock alone.
335
+ # Every artifact lands byte-identical to its install-ready body;
336
+ # provenance and sha live in the lock alone.
180
337
  def write_artifact(dest:, type:, body:, source_gem:, version:, gem_sha:, artifact_kind:)
181
- installed_at = Time.now.utc
182
- header_args = { source_gem: source_gem, version: version, sha256: gem_sha, installed_at: installed_at }
183
- on_disk =
184
- case type
185
- when :skill
186
- AuditHeader.inject_into_frontmatter(body, AuditHeader.build(**header_args))
187
- when :skill_support
188
- body
189
- else
190
- AuditHeader.prepend_html(body, AuditHeader.build_html(**header_args))
191
- end
192
-
193
338
  existed = old_lock.entry(dest)
194
- @shell.create_file dest, on_disk
339
+ @shell.create_file dest, body
195
340
  (existed ? @result.updated : @result.installed) << dest
196
341
  @new_lock.upsert(
197
342
  path: dest,
@@ -199,6 +344,17 @@ module Rails
199
344
  source_gem: source_gem,
200
345
  source_version: version,
201
346
  source_sha: gem_sha,
347
+ installed_at: Time.now.utc.iso8601
348
+ )
349
+ end
350
+
351
+ def upsert_lock(write, installed_at)
352
+ @new_lock.upsert(
353
+ path: write[:dest],
354
+ kind: write[:artifact_kind],
355
+ source_gem: write[:source_gem],
356
+ source_version: write[:version],
357
+ source_sha: write[:gem_sha],
202
358
  installed_at: installed_at.iso8601
203
359
  )
204
360
  end
@@ -284,11 +440,11 @@ module Rails
284
440
  old_lock.each_entry do |entry|
285
441
  next if planned.include?(entry.path)
286
442
  next if @new_lock.entry(entry.path)
287
- next unless DriftVerdict.verdict(file: abs(entry.path), kind: entry.kind, lock_entry: entry, gem_sha: nil) == :orphaned
443
+ next unless DriftVerdict.verdict(file: abs(entry.path), lock_entry: entry, gem_sha: nil) == :orphaned
288
444
 
289
445
  @result.orphaned << entry.path
290
446
  @shell.say_status :orphan,
291
- "#{entry.path} (source #{entry.source_label} no longer in bundle; left in place)", :yellow
447
+ "#{entry.path} (no longer shipped by #{entry.source_label}; left in place)", :yellow
292
448
  @new_lock.carry(entry)
293
449
  end
294
450
  end
@@ -14,7 +14,7 @@ module Rails
14
14
  ::MCP::Resource.new(
15
15
  uri: URI,
16
16
  name: "Stack Profile",
17
- description: "Parsed Gemfile.lock + categorization (rails, ruby, database, test, jobs, frontend, auth, authz, db_gems, gem_skills).",
17
+ description: "Parsed Gemfile.lock (rails, ruby, database, direct_dependencies, gem_skills).",
18
18
  mime_type: "application/json"
19
19
  )
20
20
  end
@@ -5,15 +5,7 @@ require "digest"
5
5
  module Rails
6
6
  module Hyperdrive
7
7
  class StackProfile
8
- CATEGORIES_PATH = File.expand_path("data/gem_categories.yml", __dir__)
9
-
10
8
  class << self
11
- def categories
12
- @categories ||= YAML.load_file(CATEGORIES_PATH).each_with_object({}) do |(cat, gems), acc|
13
- gems.each { |g| acc[g] = cat.to_sym }
14
- end
15
- end
16
-
17
9
  def current
18
10
  @current ||= from_lockfile(default_lockfile_path)
19
11
  end
@@ -54,16 +46,11 @@ module Rails
54
46
  specs = parser.specs.each_with_object({}) { |s, h| h[s.name] = s.version.to_s }
55
47
 
56
48
  @data = {
57
- rails: rails_info(specs),
58
- ruby: ruby_info(parser),
59
- database: database_info(specs),
60
- test: bucket(specs, :test),
61
- jobs: bucket(specs, :jobs),
62
- frontend: frontend_info(specs),
63
- auth: bucket(specs, :auth),
64
- authz: bucket(specs, :authz),
65
- db_gems: bucket(specs, :db),
66
- gem_skills: gem_skills_info
49
+ rails: rails_info(specs),
50
+ ruby: ruby_info(parser),
51
+ database: database_info(specs),
52
+ direct_dependencies: direct_dependencies(parser, specs),
53
+ gem_skills: gem_skills_info
67
54
  }
68
55
  self
69
56
  end
@@ -77,8 +64,7 @@ module Rails
77
64
  def empty_profile
78
65
  {
79
66
  rails: {}, ruby: {}, database: {},
80
- test: [], jobs: [], frontend: [],
81
- auth: [], authz: [], db_gems: [], gem_skills: []
67
+ direct_dependencies: [], gem_skills: []
82
68
  }
83
69
  end
84
70
 
@@ -132,28 +118,14 @@ module Rails
132
118
  ::Rails.root.join("config", "database.yml").to_s
133
119
  end
134
120
 
135
- def bucket(specs, category)
136
- gem_names = self.class.categories.select { |_, c| c == category }.keys
137
- gem_names.filter_map do |name|
138
- version = specs[name]
139
- next unless version
140
- { key: name.to_sym, version: version }
121
+ # Presence in the resolved specs does not mean the app chose a gem —
122
+ # minitest resolves under every Rails app. Transitive gems stay out.
123
+ def direct_dependencies(parser, specs)
124
+ parser.dependencies.keys.sort.map do |name|
125
+ { name: name, version: specs[name] }
141
126
  end
142
127
  end
143
128
 
144
- # Frontend gets a special shape because Hotwire is actually two gems.
145
- def frontend_info(specs)
146
- bucket(specs, :frontend).map do |entry|
147
- if entry[:key] == :"turbo-rails" || entry[:key] == :"stimulus-rails"
148
- turbo = specs["turbo-rails"]
149
- stim = specs["stimulus-rails"]
150
- { key: :hotwire, versions: { turbo: turbo, stimulus: stim }.compact }
151
- else
152
- entry
153
- end
154
- end.uniq
155
- end
156
-
157
129
  # Loaded lazily and rescued broadly so StackProfile stays usable where
158
130
  # Bundler is absent or refuses to resolve.
159
131
  def gem_skills_info
@@ -0,0 +1,47 @@
1
+ require "open3"
2
+ require "tmpdir"
3
+
4
+ module Rails
5
+ module Hyperdrive
6
+ # Tempfiles live in the system tmpdir, never in the app, so a dry run
7
+ # stays write-free. Fail-open: a missing or erroring git reads as
8
+ # :unavailable, never an exception.
9
+ module ThreeWayMerge
10
+ Outcome = Struct.new(:status, :body, keyword_init: true) do
11
+ def clean?
12
+ status == :clean
13
+ end
14
+ end
15
+
16
+ module_function
17
+
18
+ def merge(ours:, base:, theirs:)
19
+ return Outcome.new(status: :unavailable) if [ours, base, theirs].any? { |b| binary?(b) }
20
+
21
+ Dir.mktmpdir("hyperdrive-merge") do |dir|
22
+ paths = { "ours" => ours, "base" => base, "theirs" => theirs }.map do |name, content|
23
+ File.join(dir, name).tap { |p| File.binwrite(p, content) }
24
+ end
25
+
26
+ out, _err, status = Open3.capture3("git", "merge-file", "-p", *paths)
27
+ if status.success?
28
+ Outcome.new(status: :clean, body: out)
29
+ elsif status.exited? && status.exitstatus.between?(1, 127)
30
+ # git merge-file exits with the conflict count.
31
+ Outcome.new(status: :conflicted)
32
+ else
33
+ Outcome.new(status: :unavailable)
34
+ end
35
+ end
36
+ rescue SystemCallError, IOError
37
+ Outcome.new(status: :unavailable)
38
+ end
39
+
40
+ def binary?(content)
41
+ s = content.to_s
42
+ return true if s.include?("\0".b)
43
+ !s.dup.force_encoding(Encoding::UTF_8).valid_encoding?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -6,7 +6,7 @@ module Rails
6
6
  module Tools
7
7
  class DescribeApp < Base
8
8
  tool_name "describe_app"
9
- description "Snapshot of Rails/Ruby/DB versions plus the full StackProfile (test, jobs, frontend, auth, authz, db_gems, gem_skills)."
9
+ description "Snapshot of Rails/Ruby/DB versions plus the app's direct gem dependencies and installed gem skills."
10
10
 
11
11
  input_schema(properties: {})
12
12
 
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
@@ -6,7 +6,7 @@ namespace :hyperdrive do
6
6
  Rails::Generators::Hyperdrive::InstallGenerator.start(ARGV.drop(1))
7
7
  end
8
8
 
9
- desc "Sync Rails Hyperdrive content (skills, guidelines, index.md, lockfile); locally-edited files are preserved — pass --overwrite to restore gem-shipped content"
9
+ desc "Sync Rails Hyperdrive content (skills, guidelines, index.md, lockfile); locally-edited files are preserved — pass --merge (three-way merge), --sidecar (deliver upstream to <file>.new), or --overwrite (restore gem-shipped content)"
10
10
  task :sync do
11
11
  require "rails/generators"
12
12
  require "generators/hyperdrive/sync/sync_generator"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-hyperdrive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bakaface
@@ -96,14 +96,13 @@ files:
96
96
  - lib/generators/hyperdrive/sync_runner.rb
97
97
  - lib/rails-hyperdrive.rb
98
98
  - lib/rails/hyperdrive.rb
99
+ - lib/rails/hyperdrive/ancestor_locator.rb
99
100
  - lib/rails/hyperdrive/artifact_status.rb
100
- - lib/rails/hyperdrive/audit_header.rb
101
101
  - lib/rails/hyperdrive/auto_install.rb
102
102
  - lib/rails/hyperdrive/bundler_artifact_discovery.rb
103
103
  - lib/rails/hyperdrive/claude_md_import.rb
104
104
  - lib/rails/hyperdrive/companion_discovery.rb
105
105
  - lib/rails/hyperdrive/console_executor.rb
106
- - lib/rails/hyperdrive/data/gem_categories.yml
107
106
  - lib/rails/hyperdrive/drift_verdict.rb
108
107
  - lib/rails/hyperdrive/eager_footprint.rb
109
108
  - lib/rails/hyperdrive/engine.rb
@@ -120,6 +119,7 @@ files:
120
119
  - lib/rails/hyperdrive/skill_template.rb
121
120
  - lib/rails/hyperdrive/sql_safety.rb
122
121
  - lib/rails/hyperdrive/stack_profile.rb
122
+ - lib/rails/hyperdrive/three_way_merge.rb
123
123
  - lib/rails/hyperdrive/tools/base.rb
124
124
  - lib/rails/hyperdrive/tools/describe_app.rb
125
125
  - lib/rails/hyperdrive/tools/list_models.rb
@@ -1,83 +0,0 @@
1
- require "time"
2
-
3
- module Rails
4
- module Hyperdrive
5
- module AuditHeader
6
- YAML_LINE = /\A#\s*hyperdrive:/.freeze
7
- HTML_LINE = /\A<!--\s*hyperdrive:.*-->\s*\z/.freeze
8
-
9
- module_function
10
-
11
- def fields(source_gem:, version:, sha256:, installed_at: Time.now.utc)
12
- [
13
- "source=#{source_gem}@#{version}",
14
- "sha256=#{sha256}",
15
- "installed_at=#{installed_at.iso8601}"
16
- ]
17
- end
18
-
19
- def build(source_gem:, version:, sha256:, installed_at: Time.now.utc)
20
- fields(source_gem: source_gem, version: version, sha256: sha256, installed_at: installed_at)
21
- .map { |f| "# hyperdrive: #{f}" }
22
- .join("\n")
23
- end
24
-
25
- def build_html(source_gem:, version:, sha256:, installed_at: Time.now.utc)
26
- fields(source_gem: source_gem, version: version, sha256: sha256, installed_at: installed_at)
27
- .map { |f| "<!-- hyperdrive: #{f} -->" }
28
- .join("\n")
29
- end
30
-
31
- def inject_into_frontmatter(body, header_block)
32
- lines = body.lines
33
- unless lines.first&.strip == "---"
34
- return "---\n#{header_block}\n---\n\n#{body}"
35
- end
36
-
37
- closing_index = lines[1..].index { |l| l.strip == "---" }
38
- return body unless closing_index
39
-
40
- absolute_closing = closing_index + 1
41
- lines.insert(absolute_closing, header_block + "\n")
42
- lines.join
43
- end
44
-
45
- def prepend_html(body, header_block)
46
- "#{header_block}\n\n#{body}"
47
- end
48
-
49
- # Strips only the contiguous injected block, never stray "# hyperdrive:"
50
- # or "<!-- -->" lines elsewhere in the body.
51
- def strip(body)
52
- lines = body.lines
53
- if lines.first&.strip == "---"
54
- strip_yaml(lines)
55
- else
56
- strip_html(lines)
57
- end
58
- end
59
-
60
- def strip_yaml(lines)
61
- closing_index = lines[1..].index { |l| l.strip == "---" }
62
- return lines.join unless closing_index
63
-
64
- absolute_closing = closing_index + 1
65
- first = (1...absolute_closing).find { |i| lines[i] =~ YAML_LINE }
66
- return lines.join unless first
67
-
68
- last = first
69
- last += 1 while last < absolute_closing && lines[last] =~ YAML_LINE
70
- (lines[0...first] + lines[last..]).join
71
- end
72
-
73
- def strip_html(lines)
74
- return lines.join unless lines.first =~ HTML_LINE
75
-
76
- i = 0
77
- i += 1 while lines[i] =~ HTML_LINE
78
- i += 1 if lines[i] && lines[i].strip.empty?
79
- lines[i..].join
80
- end
81
- end
82
- end
83
- end