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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/README.md +77 -109
- data/lib/generators/hyperdrive/install/USAGE +3 -2
- data/lib/generators/hyperdrive/sync/USAGE +17 -3
- data/lib/generators/hyperdrive/sync/sync_generator.rb +16 -1
- data/lib/rails/hyperdrive/ancestor_locator.rb +76 -0
- data/lib/rails/hyperdrive/artifact_status.rb +1 -1
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +28 -1
- data/lib/rails/hyperdrive/drift_verdict.rb +9 -14
- data/lib/rails/hyperdrive/install_layout.rb +4 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +186 -30
- data/lib/rails/hyperdrive/resources/stack_profile.rb +1 -1
- data/lib/rails/hyperdrive/stack_profile.rb +11 -39
- data/lib/rails/hyperdrive/three_way_merge.rb +47 -0
- data/lib/rails/hyperdrive/tools/describe_app.rb +1 -1
- data/lib/rails/hyperdrive/version.rb +1 -1
- data/lib/tasks/hyperdrive.rake +1 -1
- metadata +3 -3
- data/lib/rails/hyperdrive/audit_header.rb +0 -83
- data/lib/rails/hyperdrive/data/gem_categories.yml +0 -52
|
@@ -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
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
27
|
-
#
|
|
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
|
|
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:,
|
|
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
|
|
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
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
require "time"
|
|
2
|
-
require "rails/hyperdrive/
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
#
|
|
178
|
-
#
|
|
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,
|
|
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),
|
|
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} (
|
|
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
|
|
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:
|
|
58
|
-
ruby:
|
|
59
|
-
database:
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
data/lib/tasks/hyperdrive.rake
CHANGED
|
@@ -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 --
|
|
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.
|
|
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
|