rails-hyperdrive 0.3.0 → 0.5.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 +137 -1
- data/README.md +96 -102
- 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/generators/hyperdrive/sync_runner.rb +17 -2
- data/lib/rails/hyperdrive/ancestor_locator.rb +76 -0
- data/lib/rails/hyperdrive/artifact_status.rb +1 -1
- data/lib/rails/hyperdrive/auto_install.rb +3 -1
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +172 -55
- data/lib/rails/hyperdrive/canonical_skill_render.rb +105 -0
- data/lib/rails/hyperdrive/drift_verdict.rb +9 -14
- data/lib/rails/hyperdrive/gem_manifest.rb +175 -0
- data/lib/rails/hyperdrive/install_layout.rb +4 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +212 -31
- data/lib/rails/hyperdrive/lock_file.rb +15 -0
- data/lib/rails/hyperdrive/resources/stack_profile.rb +1 -1
- data/lib/rails/hyperdrive/skill_tasks.rb +27 -0
- data/lib/rails/hyperdrive/skill_template.rb +25 -0
- data/lib/rails/hyperdrive/stack_profile.rb +31 -40
- 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 +6 -3
- data/lib/rails/hyperdrive/audit_header.rb +0 -83
- data/lib/rails/hyperdrive/data/gem_categories.yml +0 -52
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
# A companion gem's root manifest (hyperdrive.yml) declares artifact
|
|
6
|
+
# gating: top-level gem:/versions: defaults for the whole gem,
|
|
7
|
+
# per-skill entries keyed by the skill dir's relative path from its skills
|
|
8
|
+
# root, per-guideline entries keyed by filename. Fail-open at every level:
|
|
9
|
+
# malformed input warns and resolves to an ungated install — an artifact is
|
|
10
|
+
# never skipped because its gating could not be read, and nothing raises.
|
|
11
|
+
class GemManifest
|
|
12
|
+
FILE_NAME = "hyperdrive.yml"
|
|
13
|
+
METADATA_KEY = "rails_hyperdrive_manifest"
|
|
14
|
+
|
|
15
|
+
UNGATED = ["*"].freeze
|
|
16
|
+
|
|
17
|
+
Gate = Struct.new(:targets, :versions, :conditional, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def load(spec, warnings: [])
|
|
21
|
+
new(spec, warnings: warnings)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The metadata key counts even when its value is unusable — declaring
|
|
25
|
+
# it at all signals companion intent.
|
|
26
|
+
def opt_in?(spec)
|
|
27
|
+
return true if File.file?(File.join(spec.full_gem_path, FILE_NAME))
|
|
28
|
+
return false unless spec.respond_to?(:metadata)
|
|
29
|
+
|
|
30
|
+
raw = spec.metadata && spec.metadata[METADATA_KEY]
|
|
31
|
+
!raw.to_s.strip.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_targets(raw)
|
|
35
|
+
entries = raw.is_a?(Array) ? raw : [raw]
|
|
36
|
+
return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
|
|
37
|
+
entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# versions: is optional everywhere; nil means unconstrained.
|
|
41
|
+
def malformed_requirements?(versions)
|
|
42
|
+
requirements = versions.is_a?(Hash) ? versions.values : [versions]
|
|
43
|
+
requirements.compact.any? do |req|
|
|
44
|
+
parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
45
|
+
begin
|
|
46
|
+
Gem::Requirement.new(*parts)
|
|
47
|
+
false
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(spec, warnings:)
|
|
56
|
+
@spec = spec
|
|
57
|
+
@warnings = warnings
|
|
58
|
+
root = load_root
|
|
59
|
+
@skills = section(root, "skills", "skill relpath")
|
|
60
|
+
@guidelines = section(root, "guidelines", "guideline filename")
|
|
61
|
+
@default_targets, @default_versions = defaults(root)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def skill_gate(rel)
|
|
65
|
+
return default_gate unless @skills.key?(rel)
|
|
66
|
+
entry_gate(@skills[rel], rel, with_conditional: true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def guideline_gate(filename)
|
|
70
|
+
return default_gate unless @guidelines.key?(filename)
|
|
71
|
+
entry_gate(@guidelines[filename], filename, with_conditional: false)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def skill_keys
|
|
75
|
+
@skills.keys
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def guideline_keys
|
|
79
|
+
@guidelines.keys
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def report(message)
|
|
85
|
+
@warnings << "#{@spec.name}: #{message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ".." segments are rejected (silent fallback to the conventional path)
|
|
89
|
+
# to prevent escaping the gem root.
|
|
90
|
+
def manifest_relpath
|
|
91
|
+
return FILE_NAME unless @spec.respond_to?(:metadata)
|
|
92
|
+
raw = @spec.metadata && @spec.metadata[METADATA_KEY]
|
|
93
|
+
return FILE_NAME if raw.nil? || raw.to_s.strip.empty?
|
|
94
|
+
return FILE_NAME if raw.to_s.split(%r{[/\\]}).include?("..")
|
|
95
|
+
raw.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# An empty file is a valid manifest (nothing gated); only content that
|
|
99
|
+
# cannot be read as a YAML map warns.
|
|
100
|
+
def load_root
|
|
101
|
+
path = File.join(@spec.full_gem_path, manifest_relpath)
|
|
102
|
+
return {} unless File.file?(path)
|
|
103
|
+
|
|
104
|
+
data = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
|
|
105
|
+
return {} if data.nil?
|
|
106
|
+
unless data.is_a?(Hash)
|
|
107
|
+
report("ignoring manifest #{manifest_relpath}: root must be a YAML map")
|
|
108
|
+
return {}
|
|
109
|
+
end
|
|
110
|
+
data.transform_keys(&:to_s)
|
|
111
|
+
rescue Psych::SyntaxError
|
|
112
|
+
report("ignoring manifest #{manifest_relpath}: malformed YAML")
|
|
113
|
+
{}
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
report("ignoring manifest #{manifest_relpath}: unreadable (#{e.message})")
|
|
116
|
+
{}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def section(root, key, key_label)
|
|
120
|
+
value = root[key]
|
|
121
|
+
return {} if value.nil?
|
|
122
|
+
unless value.is_a?(Hash)
|
|
123
|
+
report("manifest #{key}: must be a map of #{key_label} to {gem:, versions:}; ignoring the section")
|
|
124
|
+
return {}
|
|
125
|
+
end
|
|
126
|
+
value.transform_keys(&:to_s)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def defaults(root)
|
|
130
|
+
targets = root.key?("gem") ? self.class.parse_targets(root["gem"]) : nil
|
|
131
|
+
bad_targets = root.key?("gem") && (targets.nil? || targets.empty?)
|
|
132
|
+
if bad_targets || self.class.malformed_requirements?(root["versions"])
|
|
133
|
+
report("manifest top-level gem:/versions: defaults are unusable; ignoring them")
|
|
134
|
+
return [nil, nil]
|
|
135
|
+
end
|
|
136
|
+
[targets, root["versions"]]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def default_gate
|
|
140
|
+
Gate.new(targets: @default_targets || UNGATED, versions: @default_versions, conditional: nil)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ungated
|
|
144
|
+
Gate.new(targets: UNGATED, versions: nil, conditional: nil)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A malformed entry drops the gem-wide defaults too: gating that cannot
|
|
148
|
+
# be read must not skip the artifact, so it installs ungated.
|
|
149
|
+
def entry_gate(entry, key, with_conditional:)
|
|
150
|
+
unless entry.is_a?(Hash)
|
|
151
|
+
report("manifest entry for '#{key}' must be a map with gem:/versions:; installing ungated")
|
|
152
|
+
return ungated
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
entry = entry.transform_keys(&:to_s)
|
|
156
|
+
targets = entry.key?("gem") ? self.class.parse_targets(entry["gem"]) : nil
|
|
157
|
+
if entry.key?("gem") && (targets.nil? || targets.empty?)
|
|
158
|
+
report("manifest entry for '#{key}': gem: must name a gem, a comma-separated list, " \
|
|
159
|
+
"or a YAML list; installing ungated")
|
|
160
|
+
return ungated
|
|
161
|
+
end
|
|
162
|
+
if self.class.malformed_requirements?(entry["versions"])
|
|
163
|
+
report("manifest entry for '#{key}' has an unparsable versions: requirement; installing ungated")
|
|
164
|
+
return ungated
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Gate.new(
|
|
168
|
+
targets: targets || @default_targets || UNGATED,
|
|
169
|
+
versions: entry.key?("versions") ? entry["versions"] : @default_versions,
|
|
170
|
+
conditional: with_conditional ? entry["conditional"] : nil
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
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,11 +21,12 @@ 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
|
-
def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [])
|
|
29
|
+
def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [], notices: [])
|
|
28
30
|
raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
|
|
29
31
|
|
|
30
32
|
@root = File.expand_path(root.to_s)
|
|
@@ -32,7 +34,10 @@ module Rails
|
|
|
32
34
|
@artifacts = artifacts
|
|
33
35
|
@mode = mode
|
|
34
36
|
@warnings = warnings
|
|
35
|
-
@
|
|
37
|
+
@notices = notices
|
|
38
|
+
@result = Result.new(
|
|
39
|
+
installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [], merged: [], sidecars: []
|
|
40
|
+
)
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def call
|
|
@@ -50,6 +55,7 @@ module Rails
|
|
|
50
55
|
write_claude_md(guidelines)
|
|
51
56
|
write_lock
|
|
52
57
|
print_warnings
|
|
58
|
+
print_notices
|
|
53
59
|
print_footprint(guidelines: eager_guidelines)
|
|
54
60
|
warn_if_destinations_gitignored
|
|
55
61
|
@result
|
|
@@ -81,6 +87,14 @@ module Rails
|
|
|
81
87
|
@mode == :overwrite
|
|
82
88
|
end
|
|
83
89
|
|
|
90
|
+
def merge_mode?
|
|
91
|
+
@mode == :merge
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reconcile_mode?
|
|
95
|
+
@mode == :sidecar || @mode == :merge
|
|
96
|
+
end
|
|
97
|
+
|
|
84
98
|
def abs(path)
|
|
85
99
|
File.join(@root, path)
|
|
86
100
|
end
|
|
@@ -102,7 +116,10 @@ module Rails
|
|
|
102
116
|
|
|
103
117
|
def install_skills
|
|
104
118
|
@plan.select { |e| e.type == :skill }.each do |entry|
|
|
105
|
-
|
|
119
|
+
skill_relpath = source_relpath_for(entry.artifact)
|
|
120
|
+
support_base = support_relpath_base(entry.artifact, skill_relpath)
|
|
121
|
+
install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body,
|
|
122
|
+
source_relpath: skill_relpath, final_name: entry.final_name)
|
|
106
123
|
entry.support_files.each do |file|
|
|
107
124
|
install_file(
|
|
108
125
|
dest: file[:dest],
|
|
@@ -110,7 +127,8 @@ module Rails
|
|
|
110
127
|
install_ready_body: file[:body],
|
|
111
128
|
source_gem: entry.source_gem,
|
|
112
129
|
version: entry.version,
|
|
113
|
-
artifact_kind: "skill_support"
|
|
130
|
+
artifact_kind: "skill_support",
|
|
131
|
+
source_relpath: support_base && File.join(support_base, file[:path])
|
|
114
132
|
)
|
|
115
133
|
end
|
|
116
134
|
end
|
|
@@ -120,12 +138,44 @@ module Rails
|
|
|
120
138
|
@plan.select { |e| e.type == :guideline }.map do |entry|
|
|
121
139
|
body = entry.install_ready_body
|
|
122
140
|
warn_if_oversize(entry.dest, body)
|
|
123
|
-
install_file(entry: entry, type: :guideline, install_ready_body: body
|
|
141
|
+
install_file(entry: entry, type: :guideline, install_ready_body: body,
|
|
142
|
+
source_relpath: source_relpath_for(entry.artifact), final_name: entry.final_name)
|
|
124
143
|
{ base: "#{entry.final_name}.md", dest: entry.dest, body: body }
|
|
125
144
|
end
|
|
126
145
|
end
|
|
127
146
|
|
|
128
|
-
|
|
147
|
+
# The shipped file's path relative to its gem root, normalized to the
|
|
148
|
+
# install-target form (.md.erb -> .md).
|
|
149
|
+
def source_relpath_for(artifact)
|
|
150
|
+
root = artifact.source_root
|
|
151
|
+
return nil if root.nil? || root.to_s.empty?
|
|
152
|
+
|
|
153
|
+
prefix = "#{File.expand_path(root.to_s)}/"
|
|
154
|
+
expanded = File.expand_path(artifact.path.to_s)
|
|
155
|
+
return nil unless expanded.start_with?(prefix)
|
|
156
|
+
|
|
157
|
+
rel = expanded.delete_prefix(prefix)
|
|
158
|
+
rel.end_with?(".md.erb") ? rel.delete_suffix(".erb") : rel
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Supporting files live under the artifact's support root, which for a
|
|
162
|
+
# template-paired skill is not the definition file's directory.
|
|
163
|
+
def support_relpath_base(artifact, skill_relpath)
|
|
164
|
+
support_root = artifact.support_root
|
|
165
|
+
return skill_relpath && File.dirname(skill_relpath) if support_root.nil? || support_root.to_s.empty?
|
|
166
|
+
|
|
167
|
+
root = artifact.source_root
|
|
168
|
+
return nil if root.nil? || root.to_s.empty?
|
|
169
|
+
|
|
170
|
+
prefix = "#{File.expand_path(root.to_s)}/"
|
|
171
|
+
expanded = File.expand_path(support_root.to_s)
|
|
172
|
+
return nil unless expanded.start_with?(prefix)
|
|
173
|
+
|
|
174
|
+
expanded.delete_prefix(prefix)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil,
|
|
178
|
+
artifact_kind: nil, source_relpath: nil, final_name: nil)
|
|
129
179
|
write = {
|
|
130
180
|
dest: dest || entry.dest,
|
|
131
181
|
type: type,
|
|
@@ -140,25 +190,152 @@ module Rails
|
|
|
140
190
|
return additive_install(write, old) if additive?
|
|
141
191
|
|
|
142
192
|
file = abs(write[:dest])
|
|
143
|
-
case DriftVerdict.verdict(file: file,
|
|
193
|
+
case DriftVerdict.verdict(file: file, lock_entry: old, gem_sha: write[:gem_sha])
|
|
144
194
|
when :missing
|
|
145
195
|
@shell.say_status(:reinstall, "#{write[:dest]} (was missing)", :yellow) if old
|
|
146
196
|
write_artifact(**write)
|
|
197
|
+
sweep_sidecar(write, old)
|
|
147
198
|
when :current
|
|
148
199
|
@new_lock.carry(old)
|
|
149
200
|
@result.unchanged << write[:dest]
|
|
150
201
|
@shell.say_status :unchanged, write[:dest], :blue
|
|
202
|
+
sweep_sidecar(write, old)
|
|
151
203
|
when :outdated
|
|
152
204
|
write_artifact(**write)
|
|
205
|
+
sweep_sidecar(write, old)
|
|
153
206
|
when :edited
|
|
154
207
|
if overwrite_mode?
|
|
155
208
|
write_artifact(**write)
|
|
209
|
+
sweep_sidecar(write, old)
|
|
210
|
+
elsif reconcile_mode?
|
|
211
|
+
reconcile_edited(write, old, source_relpath: source_relpath, final_name: final_name)
|
|
212
|
+
else
|
|
213
|
+
skip_edited(write, old)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def skip_edited(write, old)
|
|
219
|
+
@result.skipped << write[:dest]
|
|
220
|
+
@shell.say_status :skip,
|
|
221
|
+
"#{write[:dest]} (locally modified; run hyperdrive:sync with --merge, --sidecar, or --overwrite to reconcile)",
|
|
222
|
+
:yellow
|
|
223
|
+
@new_lock.carry(old) if old
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Reconciliation for a locally-modified destination in :sidecar/:merge
|
|
227
|
+
# mode. The lock records the newest upstream *delivered* — installed
|
|
228
|
+
# live, merged in, or written as a sidecar — never the live file's own
|
|
229
|
+
# hash, so the same upstream version is offered exactly once.
|
|
230
|
+
def reconcile_edited(write, old, source_relpath:, final_name:)
|
|
231
|
+
dest = write[:dest]
|
|
232
|
+
sidecar = InstallLayout.sidecar_path(dest)
|
|
233
|
+
|
|
234
|
+
if old && old.source_sha == write[:gem_sha]
|
|
235
|
+
skip_edited(write, old)
|
|
236
|
+
if File.exist?(abs(sidecar))
|
|
237
|
+
@shell.say_status :warn, "#{sidecar} (unresolved sidecar; reconcile it with #{dest}, then delete it)", :yellow
|
|
238
|
+
end
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# An edited sidecar is user work: leave it, and leave the lock at the
|
|
243
|
+
# old upstream so this delivery is re-offered once the user clears it.
|
|
244
|
+
if File.exist?(abs(sidecar)) && !sidecar_pristine?(sidecar, write, old)
|
|
245
|
+
@result.skipped << dest
|
|
246
|
+
@shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
|
|
247
|
+
@new_lock.carry(old) if old
|
|
248
|
+
return
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
reason = nil
|
|
252
|
+
if merge_mode?
|
|
253
|
+
if File.exist?(abs(sidecar))
|
|
254
|
+
# A pending sidecar means the last delivered upstream never reached
|
|
255
|
+
# the live file, so the reconstructable ancestor is stale; merging
|
|
256
|
+
# over it would silently revert that delivery.
|
|
257
|
+
reason = "previous delivery still unresolved"
|
|
156
258
|
else
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
259
|
+
merged, reason = attempt_merge(write, old, source_relpath: source_relpath, final_name: final_name)
|
|
260
|
+
if merged
|
|
261
|
+
write_merged(write, merged)
|
|
262
|
+
sweep_sidecar(write, old)
|
|
263
|
+
return
|
|
264
|
+
end
|
|
160
265
|
end
|
|
161
266
|
end
|
|
267
|
+
|
|
268
|
+
write_sidecar(write, reason)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def attempt_merge(write, old, source_relpath:, final_name:)
|
|
272
|
+
return [nil, "no previous install recorded"] unless old
|
|
273
|
+
|
|
274
|
+
ancestor = AncestorLocator.locate(
|
|
275
|
+
kind: write[:artifact_kind], relpath: source_relpath, lock_entry: old, final_name: final_name
|
|
276
|
+
)
|
|
277
|
+
return [nil, "#{old.source_label} not found in installed gems"] unless ancestor
|
|
278
|
+
|
|
279
|
+
ours = live_body(write)
|
|
280
|
+
if ours.nil? || [ours, ancestor, write[:body]].any? { |b| ThreeWayMerge.binary?(b) }
|
|
281
|
+
return [nil, "binary content"]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
outcome = ThreeWayMerge.merge(ours: ours, base: ancestor, theirs: write[:body])
|
|
285
|
+
case outcome.status
|
|
286
|
+
when :clean then [outcome.body, nil]
|
|
287
|
+
when :conflicted then [nil, "conflicting edits"]
|
|
288
|
+
else [nil, "git merge-file unavailable"]
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def live_body(write)
|
|
293
|
+
file = abs(write[:dest])
|
|
294
|
+
return File.binread(file) if write[:type] == :skill_support
|
|
295
|
+
File.read(file)
|
|
296
|
+
rescue StandardError
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def write_merged(write, merged_body)
|
|
301
|
+
@shell.create_file write[:dest], merged_body
|
|
302
|
+
@result.merged << write[:dest]
|
|
303
|
+
@shell.say_status :merged, "#{write[:dest]} (local edits merged with #{write[:source_gem]}@#{write[:version]})", :green
|
|
304
|
+
upsert_lock(write, Time.now.utc)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The sidecar is byte-identical to what a live install of the new
|
|
308
|
+
# upstream would write, so `mv <dest>.new <dest>` accepts it wholesale
|
|
309
|
+
# and the lock already verifies it.
|
|
310
|
+
def write_sidecar(write, reason)
|
|
311
|
+
sidecar = InstallLayout.sidecar_path(write[:dest])
|
|
312
|
+
@shell.create_file sidecar, write[:body]
|
|
313
|
+
@result.sidecars << write[:dest]
|
|
314
|
+
message = "#{write[:dest]} (locally modified; new upstream delivered to #{sidecar}"
|
|
315
|
+
message += "; #{reason}" if reason
|
|
316
|
+
@shell.say_status :sidecar, "#{message})", :yellow
|
|
317
|
+
upsert_lock(write, Time.now.utc)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Only a machine-pristine sidecar — one still hashing to a delivered
|
|
321
|
+
# upstream — may be refreshed or removed; anything else is user work.
|
|
322
|
+
def sidecar_pristine?(sidecar, write, old)
|
|
323
|
+
sha = DriftVerdict.disk_sha(abs(sidecar))
|
|
324
|
+
[old&.source_sha, write[:gem_sha]].compact.include?(sha)
|
|
325
|
+
rescue StandardError
|
|
326
|
+
false
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def sweep_sidecar(write, old)
|
|
330
|
+
sidecar = InstallLayout.sidecar_path(write[:dest])
|
|
331
|
+
return unless File.exist?(abs(sidecar))
|
|
332
|
+
|
|
333
|
+
if sidecar_pristine?(sidecar, write, old)
|
|
334
|
+
@shell.remove_file sidecar
|
|
335
|
+
@result.removed << sidecar
|
|
336
|
+
else
|
|
337
|
+
@shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
|
|
338
|
+
end
|
|
162
339
|
end
|
|
163
340
|
|
|
164
341
|
# Additive installs can create a file and never overwrite one, so an
|
|
@@ -174,24 +351,11 @@ module Rails
|
|
|
174
351
|
end
|
|
175
352
|
end
|
|
176
353
|
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
# live in the lock alone.
|
|
354
|
+
# Every artifact lands byte-identical to its install-ready body;
|
|
355
|
+
# provenance and sha live in the lock alone.
|
|
180
356
|
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
357
|
existed = old_lock.entry(dest)
|
|
194
|
-
@shell.create_file dest,
|
|
358
|
+
@shell.create_file dest, body
|
|
195
359
|
(existed ? @result.updated : @result.installed) << dest
|
|
196
360
|
@new_lock.upsert(
|
|
197
361
|
path: dest,
|
|
@@ -199,6 +363,17 @@ module Rails
|
|
|
199
363
|
source_gem: source_gem,
|
|
200
364
|
source_version: version,
|
|
201
365
|
source_sha: gem_sha,
|
|
366
|
+
installed_at: Time.now.utc.iso8601
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def upsert_lock(write, installed_at)
|
|
371
|
+
@new_lock.upsert(
|
|
372
|
+
path: write[:dest],
|
|
373
|
+
kind: write[:artifact_kind],
|
|
374
|
+
source_gem: write[:source_gem],
|
|
375
|
+
source_version: write[:version],
|
|
376
|
+
source_sha: write[:gem_sha],
|
|
202
377
|
installed_at: installed_at.iso8601
|
|
203
378
|
)
|
|
204
379
|
end
|
|
@@ -284,11 +459,11 @@ module Rails
|
|
|
284
459
|
old_lock.each_entry do |entry|
|
|
285
460
|
next if planned.include?(entry.path)
|
|
286
461
|
next if @new_lock.entry(entry.path)
|
|
287
|
-
next unless DriftVerdict.verdict(file: abs(entry.path),
|
|
462
|
+
next unless DriftVerdict.verdict(file: abs(entry.path), lock_entry: entry, gem_sha: nil) == :orphaned
|
|
288
463
|
|
|
289
464
|
@result.orphaned << entry.path
|
|
290
465
|
@shell.say_status :orphan,
|
|
291
|
-
"#{entry.path} (
|
|
466
|
+
"#{entry.path} (no longer shipped by #{entry.source_label}; left in place)", :yellow
|
|
292
467
|
@new_lock.carry(entry)
|
|
293
468
|
end
|
|
294
469
|
end
|
|
@@ -368,6 +543,12 @@ module Rails
|
|
|
368
543
|
@warnings.each { |w| @shell.say " - #{w}" }
|
|
369
544
|
end
|
|
370
545
|
|
|
546
|
+
def print_notices
|
|
547
|
+
return if additive? || Array(@notices).empty?
|
|
548
|
+
@shell.say ""
|
|
549
|
+
@notices.each { |n| @shell.say_status :info, n, :blue }
|
|
550
|
+
end
|
|
551
|
+
|
|
371
552
|
def print_footprint(guidelines:)
|
|
372
553
|
return if additive?
|
|
373
554
|
|
|
@@ -33,6 +33,7 @@ module Rails
|
|
|
33
33
|
@files = {} # path(String) => Entry
|
|
34
34
|
@document = {} # raw parsed YAML, kept so unknown keys survive
|
|
35
35
|
@disabled = empty_disabled
|
|
36
|
+
@enabled = []
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
def read
|
|
@@ -45,6 +46,7 @@ module Rails
|
|
|
45
46
|
claude_md = data["claude_md"]
|
|
46
47
|
@claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
|
|
47
48
|
@disabled = parse_disabled(data["disabled"])
|
|
49
|
+
@enabled = parse_enabled(data["enabled"])
|
|
48
50
|
Array(data["files"]).each do |raw|
|
|
49
51
|
next unless raw.is_a?(Hash)
|
|
50
52
|
entry = build_entry(raw)
|
|
@@ -71,11 +73,17 @@ module Rails
|
|
|
71
73
|
Array(@disabled[type.to_sym]).include?(name.to_s)
|
|
72
74
|
end
|
|
73
75
|
|
|
76
|
+
# Hand-editable list of gems the app opts into as companions.
|
|
77
|
+
def enabled_gems
|
|
78
|
+
@enabled
|
|
79
|
+
end
|
|
80
|
+
|
|
74
81
|
# Adopt the state that is not derived from installed content, so
|
|
75
82
|
# rewriting the file preserves it.
|
|
76
83
|
def carry_settings(other)
|
|
77
84
|
@document = other.document.dup
|
|
78
85
|
@disabled = other.disabled_lists.dup
|
|
86
|
+
@enabled = other.enabled_gems.dup
|
|
79
87
|
self
|
|
80
88
|
end
|
|
81
89
|
|
|
@@ -103,6 +111,7 @@ module Rails
|
|
|
103
111
|
"version" => SCHEMA_VERSION,
|
|
104
112
|
"claude_md" => carried.merge("state" => @claude_md_state),
|
|
105
113
|
"disabled" => DISABLED_KEYS.each_with_object({}) { |(type, key), h| h[key] = @disabled[type] },
|
|
114
|
+
"enabled" => @enabled,
|
|
106
115
|
"files" => @files.values.sort_by(&:path).map { |e| serialize_entry(e) }
|
|
107
116
|
)
|
|
108
117
|
# A nil state means no import line is being managed. Recording one anyway
|
|
@@ -136,6 +145,12 @@ module Rails
|
|
|
136
145
|
end
|
|
137
146
|
end
|
|
138
147
|
|
|
148
|
+
def parse_enabled(raw)
|
|
149
|
+
return [] unless raw.is_a?(Array)
|
|
150
|
+
|
|
151
|
+
raw.map { |name| name.to_s.strip }.reject(&:empty?).uniq
|
|
152
|
+
end
|
|
153
|
+
|
|
139
154
|
def build_entry(raw)
|
|
140
155
|
source_gem, source_version = split_source(raw["source"])
|
|
141
156
|
Entry.new(
|
|
@@ -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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Rake tasks for companion gem repos: add `require "rails/hyperdrive/skill_tasks"`
|
|
2
|
+
# to the Rakefile. Both tasks take an optional gemspec-path argument, e.g.
|
|
3
|
+
# rake "hyperdrive:skills:render[path/to/name.gemspec]".
|
|
4
|
+
require "rake"
|
|
5
|
+
require "rails/hyperdrive/canonical_skill_render"
|
|
6
|
+
|
|
7
|
+
namespace :hyperdrive do
|
|
8
|
+
namespace :skills do
|
|
9
|
+
desc "Render each SKILL.md.erb template to its canonical static SKILL.md"
|
|
10
|
+
task :render, [:gemspec] do |_t, args|
|
|
11
|
+
written = Rails::Hyperdrive::CanonicalSkillRender.write(gemspec: args[:gemspec])
|
|
12
|
+
written.each { |r| puts "render #{r.dest}" }
|
|
13
|
+
puts "no skill templates found" if written.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc "Fail when a canonical SKILL.md is stale relative to its template"
|
|
17
|
+
task :check, [:gemspec] do |_t, args|
|
|
18
|
+
stale = Rails::Hyperdrive::CanonicalSkillRender.stale(gemspec: args[:gemspec])
|
|
19
|
+
if stale.empty?
|
|
20
|
+
puts "canonical skill files up to date"
|
|
21
|
+
else
|
|
22
|
+
stale.each { |r| warn "stale: #{r.dest}" }
|
|
23
|
+
abort "#{stale.size} canonical skill file(s) stale; run `rake hyperdrive:skills:render`"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -31,6 +31,27 @@ module Rails
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Fail-open binding for the canonical render: every gem reads as present
|
|
35
|
+
# at any requested version, so the output includes every conditional
|
|
36
|
+
# branch. There is no bundle to resolve against, so gem_version is nil.
|
|
37
|
+
class CanonicalContext
|
|
38
|
+
def gem?(_name, _requirement = nil)
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def any_gem?(*)
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def gem_version(_name)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def template_binding
|
|
51
|
+
binding
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
34
55
|
module_function
|
|
35
56
|
|
|
36
57
|
# Errors (ERB SyntaxError included) propagate; the caller decides how to
|
|
@@ -39,6 +60,10 @@ module Rails
|
|
|
39
60
|
ERB.new(source, trim_mode: "-").result(Context.new(resolved).template_binding)
|
|
40
61
|
end
|
|
41
62
|
|
|
63
|
+
def render_canonical(source)
|
|
64
|
+
ERB.new(source, trim_mode: "-").result(CanonicalContext.new.template_binding)
|
|
65
|
+
end
|
|
66
|
+
|
|
42
67
|
# Gem::Requirement.new does not parse a single comma-separated string, so
|
|
43
68
|
# such strings are split into separate constraints first.
|
|
44
69
|
def version_matches?(requirement_str, version)
|