rails-hyperdrive 0.7.0 → 0.9.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.
@@ -0,0 +1,215 @@
1
+ require "open3"
2
+ require "shellwords"
3
+ require "tmpdir"
4
+ require "rails/hyperdrive/ancestor_locator"
5
+ require "rails/hyperdrive/drift_verdict"
6
+ require "rails/hyperdrive/install_layout"
7
+ require "rails/hyperdrive/resolve_prompt"
8
+
9
+ module Rails
10
+ module Hyperdrive
11
+ # Hands each unresolved <dest>.new sidecar to the user's resolver command,
12
+ # git-mergetool style, and deletes the sidecar when it exits 0. Nothing here
13
+ # writes the lock: the sidecar's absence is the whole resolution signal.
14
+ class SidecarResolver
15
+ TOKENS = %w[LOCAL REMOTE BASE MERGED SOURCE PREVIOUS_SOURCE KIND PROMPT].freeze
16
+ TOKEN_PATTERN = /\$(#{TOKENS.sort_by { |t| -t.length }.join("|")})\b/
17
+
18
+ Outcome = Struct.new(:resolved, :unresolved, :skipped, keyword_init: true)
19
+
20
+ Candidate = Struct.new(:dest, :sidecar, :kind, :source, :previous_source, :ancestor, :delivered,
21
+ keyword_init: true)
22
+
23
+ def initialize(root:, shell:, command:, lock:, sidecars: [], prompt_path: nil, dry_run: false)
24
+ @root = File.expand_path(root.to_s)
25
+ @shell = shell
26
+ @command = command.to_s
27
+ @lock = lock
28
+ @sidecars = Array(sidecars)
29
+ @prompt_path = prompt_path
30
+ @dry_run = dry_run
31
+ @outcome = Outcome.new(resolved: [], unresolved: [], skipped: [])
32
+ end
33
+
34
+ def call
35
+ candidates.each { |candidate| process(candidate) }
36
+ @outcome
37
+ end
38
+
39
+ private
40
+
41
+ def candidates
42
+ written = @sidecars.to_h { |s| [s.dest.to_s, s] }
43
+ list = []
44
+ @lock.each_entry do |entry|
45
+ sidecar = InstallLayout.sidecar_path(entry.path)
46
+ delivered = written[entry.path.to_s]
47
+ # A dry run wrote no sidecar to disk, so this run's deliveries are
48
+ # only knowable from the pipeline result.
49
+ next unless delivered || File.file?(abs(sidecar))
50
+
51
+ list << Candidate.new(
52
+ dest: entry.path,
53
+ sidecar: sidecar,
54
+ kind: entry.kind.to_s,
55
+ source: entry.source_label.to_s,
56
+ # A sidecar left over from an earlier run has no struct, so the
57
+ # base comes back from the ancestor the lock recorded with it.
58
+ previous_source: delivered&.previous_source || entry.ancestor_label,
59
+ ancestor: delivered&.ancestor || AncestorLocator.locate_recorded_ancestor(entry),
60
+ delivered: !delivered.nil?
61
+ )
62
+ end
63
+ list
64
+ end
65
+
66
+ def process(candidate)
67
+ unless candidate.delivered || pristine?(candidate)
68
+ @outcome.skipped << candidate.dest
69
+ @shell.say_status :warn,
70
+ "#{candidate.sidecar} (sidecar locally modified; resolve or delete it by hand)", :yellow
71
+ return
72
+ end
73
+
74
+ tokens = command_tokens
75
+ return unresolved(candidate, "resolve: command: is not a valid command line") if tokens.nil?
76
+ return unresolved(candidate, "resolve: command: names no program") if tokens.empty?
77
+
78
+ if @dry_run
79
+ @shell.say_status :resolve, "#{candidate.dest} (would run #{tokens.first})", :yellow
80
+ return
81
+ end
82
+
83
+ run(candidate, tokens)
84
+ end
85
+
86
+ def command_tokens
87
+ Shellwords.split(@command)
88
+ rescue ArgumentError
89
+ nil
90
+ end
91
+
92
+ # A sidecar is machine-written only while it still hashes to the upstream
93
+ # the lock records as delivered; anything else is the user's own work.
94
+ def pristine?(candidate)
95
+ entry = @lock.entry(candidate.dest)
96
+ return false unless entry
97
+ DriftVerdict.disk_sha(abs(candidate.sidecar)) == entry.source_sha
98
+ rescue StandardError
99
+ false
100
+ end
101
+
102
+ def run(candidate, tokens)
103
+ Dir.mktmpdir("hyperdrive-resolve") do |dir|
104
+ base = write_base(candidate, dir)
105
+ values = values_for(candidate, base: base)
106
+ argv = substitute(tokens, values, base: base)
107
+ @shell.say_status :resolve, "#{candidate.dest} via #{argv.first}", :blue
108
+ _out, err, status = Open3.capture3(env_for(values), *argv, chdir: @root)
109
+ if status.success?
110
+ # Exit 0 is the tool's assertion that the file is resolved, so the
111
+ # sidecar goes even if the tool never wrote $MERGED.
112
+ resolved(candidate)
113
+ else
114
+ unresolved(candidate, "exit #{status.exitstatus}#{detail(err)}")
115
+ end
116
+ end
117
+ rescue StandardError => e
118
+ unresolved(candidate, first_line(e.message))
119
+ end
120
+
121
+ def resolved(candidate)
122
+ @shell.remove_file candidate.sidecar
123
+ @outcome.resolved << candidate.dest
124
+ @shell.say_status :resolved, candidate.dest, :green
125
+ end
126
+
127
+ def unresolved(candidate, reason)
128
+ @outcome.unresolved << { dest: candidate.dest, reason: reason }
129
+ @shell.say_status :unresolved, "#{candidate.dest} (#{reason})", :yellow
130
+ end
131
+
132
+ def write_base(candidate, dir)
133
+ return nil unless candidate.ancestor
134
+
135
+ File.join(dir, "base-#{File.basename(candidate.dest)}").tap do |file|
136
+ File.binwrite(file, candidate.ancestor)
137
+ end
138
+ end
139
+
140
+ # Substitution runs per token after the split, so a value holding spaces
141
+ # stays one argument.
142
+ def substitute(tokens, values, base:)
143
+ tokens.each_with_object([]) do |token, argv|
144
+ next if base.nil? && token == "$BASE"
145
+ argv << token.gsub(TOKEN_PATTERN) { values[Regexp.last_match(1)].to_s }
146
+ end
147
+ end
148
+
149
+ def env_for(values)
150
+ values.to_h { |name, value| ["HYPERDRIVE_#{name}", value] }
151
+ end
152
+
153
+ def values_for(candidate, base:)
154
+ live = abs(candidate.dest)
155
+ values = {
156
+ "LOCAL" => live,
157
+ "REMOTE" => abs(candidate.sidecar),
158
+ # A nil BASE substitutes empty and unsets HYPERDRIVE_BASE in the
159
+ # child, so an outer HYPERDRIVE_BASE can never leak into a run.
160
+ "BASE" => base,
161
+ "MERGED" => live,
162
+ "SOURCE" => candidate.source,
163
+ "PREVIOUS_SOURCE" => candidate.previous_source.to_s,
164
+ "KIND" => candidate.kind
165
+ }
166
+ values["PROMPT"] = prompt(candidate, base: base, values: values)
167
+ values
168
+ end
169
+
170
+ def prompt(candidate, base:, values:)
171
+ knobs = {
172
+ local: values["LOCAL"], remote: values["REMOTE"], base: base, merged: values["MERGED"],
173
+ source: candidate.source, previous_source: candidate.previous_source, kind: candidate.kind
174
+ }
175
+ template = user_template
176
+ return ResolvePrompt.render(template, **knobs) if template
177
+
178
+ ResolvePrompt.render(ResolvePrompt.default_template, **knobs)
179
+ rescue StandardError => e
180
+ raise unless template
181
+ @shell.say_status :warn,
182
+ "resolve: prompt: #{@prompt_path} could not be rendered (#{first_line(e.message)}); using the default prompt",
183
+ :yellow
184
+ @user_template = false
185
+ ResolvePrompt.render(ResolvePrompt.default_template, **knobs)
186
+ end
187
+
188
+ def user_template
189
+ return nil unless @prompt_path
190
+ return nil if @user_template == false
191
+
192
+ @user_template ||= File.read(abs(@prompt_path))
193
+ rescue StandardError => e
194
+ @shell.say_status :warn,
195
+ "resolve: prompt: #{@prompt_path} could not be read (#{first_line(e.message)}); using the default prompt",
196
+ :yellow
197
+ @user_template = false
198
+ nil
199
+ end
200
+
201
+ def detail(stderr)
202
+ line = first_line(stderr)
203
+ line.empty? ? "" : ": #{line}"
204
+ end
205
+
206
+ def first_line(message)
207
+ message.to_s.lines.first.to_s.strip
208
+ end
209
+
210
+ def abs(path)
211
+ File.join(@root, path)
212
+ end
213
+ end
214
+ end
215
+ end
@@ -3,7 +3,7 @@ require "erb"
3
3
  module Rails
4
4
  module Hyperdrive
5
5
  # Renders a skill's *.md.erb sources against the app's resolved bundle.
6
- # The three helpers are the only supported template API, but the binding is
6
+ # The four helpers are the only supported template API, but the binding is
7
7
  # an ordinary object rather than a sandbox: a template runs arbitrary Ruby
8
8
  # with the privileges of whoever triggered discovery.
9
9
  module SkillTemplate
@@ -27,6 +27,10 @@ module Rails
27
27
  version&.to_s
28
28
  end
29
29
 
30
+ def canonical_render?
31
+ false
32
+ end
33
+
30
34
  def template_binding
31
35
  binding
32
36
  end
@@ -35,7 +39,9 @@ module Rails
35
39
  # Canonical render binding: every gem reads as present at any requested
36
40
  # version, so an if/elsif/else contributes only its first branch and
37
41
  # templates meant for this render must use additive if blocks. There is
38
- # no bundle to resolve against, so gem_version is nil.
42
+ # no bundle to resolve against, so gem_version is nil. canonical_render?
43
+ # is the exception: it is deterministic in both bindings, so branching on
44
+ # it is safe.
39
45
  class CanonicalContext
40
46
  def gem?(_name, _requirement = nil)
41
47
  true
@@ -49,6 +55,10 @@ module Rails
49
55
  nil
50
56
  end
51
57
 
58
+ def canonical_render?
59
+ true
60
+ end
61
+
52
62
  def template_binding
53
63
  binding
54
64
  end
@@ -147,7 +147,7 @@ module Rails
147
147
 
148
148
  def enabled_gems
149
149
  require "rails/hyperdrive/install_layout"
150
- require "rails/hyperdrive/lock_file"
150
+ require "rails/hyperdrive/config_file"
151
151
  root =
152
152
  if @app_root
153
153
  @app_root.to_s
@@ -156,8 +156,8 @@ module Rails
156
156
  end
157
157
  return [] unless root
158
158
 
159
- ::Rails::Hyperdrive::LockFile.load(
160
- File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
159
+ ::Rails::Hyperdrive::ConfigFile.load(
160
+ File.join(root, ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
161
161
  ).enabled_gems
162
162
  rescue StandardError
163
163
  []
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- VERSION = "0.7.0"
3
+ VERSION = "0.9.0"
4
4
  end
5
5
  end
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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bakaface
@@ -104,6 +104,7 @@ files:
104
104
  - lib/rails/hyperdrive/canonical_skill_render.rb
105
105
  - lib/rails/hyperdrive/claude_md_import.rb
106
106
  - lib/rails/hyperdrive/companion_discovery.rb
107
+ - lib/rails/hyperdrive/config_file.rb
107
108
  - lib/rails/hyperdrive/console_executor.rb
108
109
  - lib/rails/hyperdrive/drift_verdict.rb
109
110
  - lib/rails/hyperdrive/eager_footprint.rb
@@ -118,9 +119,12 @@ files:
118
119
  - lib/rails/hyperdrive/lock_file.rb
119
120
  - lib/rails/hyperdrive/manifest_lint.rb
120
121
  - lib/rails/hyperdrive/mcp_server.rb
122
+ - lib/rails/hyperdrive/resolve/prompt.md.erb
123
+ - lib/rails/hyperdrive/resolve_prompt.rb
121
124
  - lib/rails/hyperdrive/resources/skill.rb
122
125
  - lib/rails/hyperdrive/resources/stack_profile.rb
123
126
  - lib/rails/hyperdrive/safety/rack_middleware.rb
127
+ - lib/rails/hyperdrive/sidecar_resolver.rb
124
128
  - lib/rails/hyperdrive/skill_template.rb
125
129
  - lib/rails/hyperdrive/sql_safety.rb
126
130
  - lib/rails/hyperdrive/stack_profile.rb