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,76 @@
|
|
|
1
|
+
require "bundler"
|
|
2
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
3
|
+
require "rails/hyperdrive/drift_verdict"
|
|
4
|
+
require "rails/hyperdrive/skill_template"
|
|
5
|
+
|
|
6
|
+
module Rails
|
|
7
|
+
module Hyperdrive
|
|
8
|
+
# Best-effort reconstruction of the install-ready body an artifact had at
|
|
9
|
+
# the gem version a lock entry records, read from installed gem
|
|
10
|
+
# directories. The result is sha-gated against the lock entry: anything
|
|
11
|
+
# that does not rebuild to exactly the recorded bytes counts as
|
|
12
|
+
# unavailable.
|
|
13
|
+
module AncestorLocator
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Never raises; any failure returns nil (ancestor unavailable).
|
|
17
|
+
def locate(kind:, relpath:, lock_entry:, final_name: nil, gem_paths: Gem.path, resolved: nil)
|
|
18
|
+
return nil unless lock_entry&.source_gem && lock_entry.source_version && lock_entry.source_sha
|
|
19
|
+
return nil if relpath.nil? || relpath.to_s.empty?
|
|
20
|
+
|
|
21
|
+
Array(gem_paths).each do |home|
|
|
22
|
+
gem_root = File.join(home.to_s, "gems", "#{lock_entry.source_gem}-#{lock_entry.source_version}")
|
|
23
|
+
body = read_candidate(gem_root, relpath.to_s, kind: kind, resolved: resolved)
|
|
24
|
+
next unless body
|
|
25
|
+
|
|
26
|
+
ready = install_ready(body, kind: kind, final_name: final_name)
|
|
27
|
+
return ready if DriftVerdict.body_sha(ready) == lock_entry.source_sha
|
|
28
|
+
rescue StandardError
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
nil
|
|
32
|
+
rescue StandardError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def read_candidate(gem_root, relpath, kind:, resolved:)
|
|
37
|
+
exact = File.join(gem_root, relpath)
|
|
38
|
+
if File.file?(exact)
|
|
39
|
+
return kind.to_s == "skill_support" ? File.binread(exact) : File.read(exact)
|
|
40
|
+
end
|
|
41
|
+
return nil unless relpath.end_with?(".md")
|
|
42
|
+
|
|
43
|
+
twin = "#{exact}.erb"
|
|
44
|
+
return nil unless File.file?(twin)
|
|
45
|
+
|
|
46
|
+
map = resolved || resolved_bundle
|
|
47
|
+
return nil unless map
|
|
48
|
+
SkillTemplate.render(File.read(twin), resolved: map)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def install_ready(body, kind:, final_name:)
|
|
52
|
+
case kind.to_s
|
|
53
|
+
when "skill"
|
|
54
|
+
ready = BundlerArtifactDiscovery.install_ready_body(
|
|
55
|
+
BundlerArtifactDiscovery::Artifact.new(artifact_type: :skill, body: body)
|
|
56
|
+
)
|
|
57
|
+
final_name ? ready.sub(/^name:\s*.+$/, "name: #{final_name}") : ready
|
|
58
|
+
when "guideline"
|
|
59
|
+
BundlerArtifactDiscovery.install_ready_body(
|
|
60
|
+
BundlerArtifactDiscovery::Artifact.new(artifact_type: :guideline, body: body)
|
|
61
|
+
)
|
|
62
|
+
else
|
|
63
|
+
body
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolved_bundle
|
|
68
|
+
::Bundler.load.specs.to_a.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private_class_method :read_candidate, :install_ready, :resolved_bundle
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -15,7 +15,7 @@ module Rails
|
|
|
15
15
|
case state
|
|
16
16
|
when :missing then "#{path} (from #{bundle_source})"
|
|
17
17
|
when :outdated then "#{path} (#{locked_source} → #{bundle_source})"
|
|
18
|
-
when :orphaned then "#{path} (
|
|
18
|
+
when :orphaned then "#{path} (no longer shipped by #{locked_source})"
|
|
19
19
|
else path
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -4,6 +4,7 @@ require "rails/hyperdrive/bundler_artifact_discovery"
|
|
|
4
4
|
require "rails/hyperdrive/install_layout"
|
|
5
5
|
require "rails/hyperdrive/install_pipeline"
|
|
6
6
|
require "rails/hyperdrive/install_shell"
|
|
7
|
+
require "rails/hyperdrive/lock_file"
|
|
7
8
|
|
|
8
9
|
module Rails
|
|
9
10
|
module Hyperdrive
|
|
@@ -46,7 +47,8 @@ module Rails
|
|
|
46
47
|
return skip(:not_development) unless development?(env)
|
|
47
48
|
return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
enabled = LockFile.load(File.join(root, InstallLayout::LOCK_PATH)).enabled_gems
|
|
51
|
+
artifacts = BundlerArtifactDiscovery.discover(enabled_gems: enabled)
|
|
50
52
|
status = ArtifactStatus.compare(root: root, artifacts: artifacts)
|
|
51
53
|
|
|
52
54
|
installed = []
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "yaml"
|
|
2
2
|
require "bundler"
|
|
3
|
+
require "rails/hyperdrive/gem_manifest"
|
|
3
4
|
require "rails/hyperdrive/skill_template"
|
|
4
5
|
|
|
5
6
|
module Rails
|
|
@@ -10,6 +11,7 @@ module Rails
|
|
|
10
11
|
Artifact = Struct.new(
|
|
11
12
|
:name, :description, :target_gem, :versions, :artifact_type,
|
|
12
13
|
:source_gem, :path, :body, :spec_version, :support_files,
|
|
14
|
+
:source_root, :support_root,
|
|
13
15
|
keyword_init: true
|
|
14
16
|
) do
|
|
15
17
|
def skill?
|
|
@@ -32,17 +34,30 @@ module Rails
|
|
|
32
34
|
module_function
|
|
33
35
|
|
|
34
36
|
# Non-fatal problems are appended to `warnings` and the artifact is
|
|
35
|
-
# dropped; discovery never raises.
|
|
36
|
-
|
|
37
|
+
# dropped; discovery never raises. A gem that has not opted in as a
|
|
38
|
+
# companion is never scanned — its skills.sh content is only reported
|
|
39
|
+
# through `notices`.
|
|
40
|
+
def discover(specs: nil, warnings: [], enabled_gems: [], notices: [])
|
|
37
41
|
specs ||= safe_bundler_specs
|
|
42
|
+
enabled = Array(enabled_gems).map(&:to_s)
|
|
38
43
|
resolved = specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
|
|
39
44
|
|
|
40
45
|
candidates = []
|
|
41
46
|
specs.each do |spec|
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
unless opted_in?(spec, enabled_gems: enabled)
|
|
48
|
+
notice_skills_sh_content(spec, notices: notices)
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
manifest = GemManifest.load(spec, warnings: warnings)
|
|
52
|
+
seen = { skill: [], guideline: [] }
|
|
53
|
+
each_artifact_path(spec, warnings: warnings) do |path, type, support_root, key|
|
|
54
|
+
seen[type] << key
|
|
55
|
+
gate = type == :skill ? manifest.skill_gate(key) : manifest.guideline_gate(key)
|
|
56
|
+
artifact = parse(path, source_spec: spec, type: type, resolved: resolved,
|
|
57
|
+
warnings: warnings, support_root: support_root, gate: gate)
|
|
44
58
|
candidates << artifact if artifact
|
|
45
59
|
end
|
|
60
|
+
warn_unknown_manifest_keys(manifest, spec, seen, warnings)
|
|
46
61
|
end
|
|
47
62
|
|
|
48
63
|
# Collapse same-name variants within one source gem (highest
|
|
@@ -53,22 +68,97 @@ module Rails
|
|
|
53
68
|
end
|
|
54
69
|
end
|
|
55
70
|
|
|
71
|
+
# Yields each candidate with its manifest join key: a skill's relpath
|
|
72
|
+
# from its skills root, a guideline's filename. Keys are collected before
|
|
73
|
+
# parsing, so a candidate later dropped (e.g. an ERB render failure)
|
|
74
|
+
# still counts as known to the manifest.
|
|
56
75
|
def each_artifact_path(spec, warnings: [])
|
|
57
|
-
skill_paths(spec, warnings: warnings).each { |
|
|
58
|
-
guideline_paths(spec).each { |p| yield p, :guideline }
|
|
76
|
+
skill_paths(spec, warnings: warnings).each { |path, support_root, rel| yield path, :skill, support_root, rel }
|
|
77
|
+
guideline_paths(spec).each { |p| yield p, :guideline, nil, File.basename(p) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The staleness signal for gating detached from content: an entry
|
|
81
|
+
# matching nothing means a renamed or removed skill dir/guideline.
|
|
82
|
+
def warn_unknown_manifest_keys(manifest, spec, seen, warnings)
|
|
83
|
+
(manifest.skill_keys - seen[:skill]).each do |key|
|
|
84
|
+
warnings << "#{spec.name}: manifest skills entry '#{key}' names no shipped skill directory"
|
|
85
|
+
end
|
|
86
|
+
(manifest.guideline_keys - seen[:guideline]).each do |key|
|
|
87
|
+
warnings << "#{spec.name}: manifest guidelines entry '#{key}' names no shipped guideline"
|
|
88
|
+
end
|
|
59
89
|
end
|
|
60
90
|
|
|
61
91
|
def skill_paths(spec, warnings: [])
|
|
62
|
-
roots = [
|
|
92
|
+
roots = [
|
|
93
|
+
File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills"),
|
|
94
|
+
File.join(spec.full_gem_path, "skills")
|
|
95
|
+
]
|
|
63
96
|
if (override = skills_dir_override(spec))
|
|
64
97
|
roots << File.join(spec.full_gem_path, override)
|
|
65
98
|
end
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
99
|
+
|
|
100
|
+
candidates = []
|
|
101
|
+
seen = {}
|
|
102
|
+
roots.each do |root|
|
|
103
|
+
Dir.glob(File.join(root, "**", "{SKILL.md,SKILL.md.erb}"))
|
|
104
|
+
.group_by { |p| File.dirname(p) }.each do |dir, group|
|
|
105
|
+
next if seen[File.expand_path(dir)]
|
|
106
|
+
seen[File.expand_path(dir)] = true
|
|
107
|
+
candidates << {
|
|
108
|
+
dir: dir,
|
|
109
|
+
rel: dir.delete_prefix(root).delete_prefix("/"),
|
|
110
|
+
path: resolve_same_dir_tie(dir, group, warnings)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
71
113
|
end
|
|
114
|
+
|
|
115
|
+
pair_with_templates(candidates, spec, warnings: warnings)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def resolve_same_dir_tie(dir, group, warnings)
|
|
119
|
+
return group.first unless group.size > 1
|
|
120
|
+
warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
|
|
121
|
+
group.find { |p| File.basename(p) == "SKILL.md" }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A dir holding a static SKILL.md pairs with <templates root>/<same
|
|
125
|
+
# relative path>/SKILL.md.erb in a distinct dir: the rendered template is
|
|
126
|
+
# the definition, the static dir the support root. The static SKILL.md is
|
|
127
|
+
# never parsed — falling back to it when the template fails to render
|
|
128
|
+
# would silently un-condition the skill.
|
|
129
|
+
def pair_with_templates(candidates, spec, warnings:)
|
|
130
|
+
templates_root = File.join(spec.full_gem_path, skill_templates_dir(spec))
|
|
131
|
+
|
|
132
|
+
pairs = {}
|
|
133
|
+
candidates.each do |cand|
|
|
134
|
+
next unless File.basename(cand[:path]) == "SKILL.md"
|
|
135
|
+
template_dir = File.expand_path(File.join(templates_root, cand[:rel]))
|
|
136
|
+
next if template_dir == File.expand_path(cand[:dir])
|
|
137
|
+
template = File.join(template_dir, "SKILL.md.erb")
|
|
138
|
+
next unless File.file?(template)
|
|
139
|
+
warn_template_extras(template_dir, warnings)
|
|
140
|
+
pairs[cand[:dir]] = { template: template, template_dir: template_dir }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
consumed = pairs.values.map { |p| p[:template_dir] }
|
|
144
|
+
candidates.filter_map do |cand|
|
|
145
|
+
if (pair = pairs[cand[:dir]])
|
|
146
|
+
[pair[:template], cand[:dir], cand[:rel]]
|
|
147
|
+
elsif consumed.include?(File.expand_path(cand[:dir]))
|
|
148
|
+
nil
|
|
149
|
+
else
|
|
150
|
+
[cand[:path], File.dirname(cand[:path]), cand[:rel]]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The content dir is the single source of truth for supporting files.
|
|
156
|
+
def warn_template_extras(template_dir, warnings)
|
|
157
|
+
extras = Dir.glob(File.join(template_dir, "**", "*"))
|
|
158
|
+
.select { |f| File.file?(f) && f != File.join(template_dir, "SKILL.md.erb") }
|
|
159
|
+
return if extras.empty?
|
|
160
|
+
warnings << "#{template_dir}: ignoring #{extras.size} file(s) besides SKILL.md.erb; " \
|
|
161
|
+
"supporting files ship in the paired content directory"
|
|
72
162
|
end
|
|
73
163
|
|
|
74
164
|
def guideline_paths(spec)
|
|
@@ -76,6 +166,39 @@ module Rails
|
|
|
76
166
|
Dir.glob(File.join(root, "*.md"))
|
|
77
167
|
end
|
|
78
168
|
|
|
169
|
+
# Many gemspecs package files via `git ls-files`, so a contributor-facing
|
|
170
|
+
# skills/ dir ships by accident — package contents don't signal consumer
|
|
171
|
+
# intent. Only an explicit signal makes a gem's content installable.
|
|
172
|
+
def opted_in?(spec, enabled_gems:)
|
|
173
|
+
return true if enabled_gems.include?(spec.name.to_s)
|
|
174
|
+
return true if metadata_present?(spec, "rails_hyperdrive_skills_dir")
|
|
175
|
+
return true if metadata_present?(spec, "rails_hyperdrive_skill_templates_dir")
|
|
176
|
+
return true if metadata_present?(spec, "rails_hyperdrive_targets")
|
|
177
|
+
return true if GemManifest.opt_in?(spec)
|
|
178
|
+
|
|
179
|
+
convention_root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive")
|
|
180
|
+
Dir.glob(File.join(convention_root, "skills", "**", "{SKILL.md,SKILL.md.erb}")).any? ||
|
|
181
|
+
Dir.glob(File.join(convention_root, "guidelines", "*.md")).any?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def metadata_present?(spec, key)
|
|
185
|
+
return false unless spec.respond_to?(:metadata)
|
|
186
|
+
raw = spec.metadata && spec.metadata[key]
|
|
187
|
+
!raw.to_s.strip.empty?
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Report-only, glob-only (no file reads): SKILL.md presence is the
|
|
191
|
+
# signal, and SKILL.md.erb is excluded — raw ERB is not skills.sh
|
|
192
|
+
# content. Parse problems surface as warnings once the gem is enabled.
|
|
193
|
+
def notice_skills_sh_content(spec, notices:)
|
|
194
|
+
found = Dir.glob(File.join(spec.full_gem_path, "skills", "**", "SKILL.md"))
|
|
195
|
+
return if found.empty?
|
|
196
|
+
|
|
197
|
+
count = found.map { |p| File.dirname(p) }.uniq.size
|
|
198
|
+
notices << "gem '#{spec.name}' ships #{count} skills.sh skill(s); add \"#{spec.name}\" to enabled: " \
|
|
199
|
+
"in .hyperdrive/lock.yml and re-run bin/rails hyperdrive:sync to install them"
|
|
200
|
+
end
|
|
201
|
+
|
|
79
202
|
# ".." segments are rejected to prevent escaping the gem root.
|
|
80
203
|
def skills_dir_override(spec)
|
|
81
204
|
return nil unless spec.respond_to?(:metadata)
|
|
@@ -85,7 +208,19 @@ module Rails
|
|
|
85
208
|
raw.to_s
|
|
86
209
|
end
|
|
87
210
|
|
|
88
|
-
def
|
|
211
|
+
def skill_templates_dir(spec)
|
|
212
|
+
default = File.join("lib", spec.name, "hyperdrive", "skills")
|
|
213
|
+
return default unless spec.respond_to?(:metadata)
|
|
214
|
+
raw = spec.metadata && spec.metadata["rails_hyperdrive_skill_templates_dir"]
|
|
215
|
+
return default if raw.nil? || raw.to_s.strip.empty?
|
|
216
|
+
return default if raw.to_s.split(%r{[/\\]}).include?("..")
|
|
217
|
+
raw.to_s
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Frontmatter's schema is exactly name and description; any other key is
|
|
221
|
+
# unknown to the parser and rides untouched in the installed body.
|
|
222
|
+
def parse(path, source_spec:, type:, resolved:, warnings:, gate:, support_root: nil)
|
|
223
|
+
support_root ||= File.dirname(path)
|
|
89
224
|
body = File.read(path)
|
|
90
225
|
if erb_template?(path)
|
|
91
226
|
begin
|
|
@@ -101,25 +236,20 @@ module Rails
|
|
|
101
236
|
return nil
|
|
102
237
|
end
|
|
103
238
|
|
|
104
|
-
|
|
239
|
+
# Date is permitted because unknown frontmatter keys are user content;
|
|
240
|
+
# a bare date value must not fail the parse.
|
|
241
|
+
meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol, Date]) || {}
|
|
105
242
|
name = meta["name"]
|
|
106
243
|
description = meta["description"]
|
|
107
|
-
versions = meta["versions"]
|
|
108
|
-
|
|
109
|
-
unless name && description && meta["gem"] && versions
|
|
110
|
-
warnings << "skip #{path}: missing a required field (name, description, gem, versions)"
|
|
111
|
-
return nil
|
|
112
|
-
end
|
|
113
244
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
warnings << "skip #{name} (from #{source_spec.name}): gem: must name a gem, a comma-separated list, or a YAML list"
|
|
245
|
+
unless name && description
|
|
246
|
+
warnings << "skip #{path}: missing a required field (name, description)"
|
|
117
247
|
return nil
|
|
118
248
|
end
|
|
119
249
|
|
|
120
|
-
matched = match_targets(targets, versions, resolved)
|
|
250
|
+
matched = match_targets(gate.targets, gate.versions, resolved)
|
|
121
251
|
if matched.empty?
|
|
122
|
-
warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(targets, versions, resolved)}"
|
|
252
|
+
warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(gate.targets, gate.versions, resolved)}"
|
|
123
253
|
return nil
|
|
124
254
|
end
|
|
125
255
|
|
|
@@ -127,16 +257,18 @@ module Rails
|
|
|
127
257
|
name: name.to_s,
|
|
128
258
|
description: description.to_s,
|
|
129
259
|
target_gem: matched,
|
|
130
|
-
versions: versions,
|
|
260
|
+
versions: gate.versions,
|
|
131
261
|
artifact_type: type,
|
|
132
262
|
source_gem: source_spec.name.to_s,
|
|
133
263
|
path: path,
|
|
134
264
|
body: body,
|
|
135
265
|
spec_version: source_spec.version.to_s,
|
|
266
|
+
source_root: source_spec.full_gem_path.to_s,
|
|
267
|
+
support_root: support_root,
|
|
136
268
|
support_files:
|
|
137
269
|
if type == :skill
|
|
138
270
|
conditioned_support_files(
|
|
139
|
-
|
|
271
|
+
support_root, gate.conditional,
|
|
140
272
|
resolved: resolved, warnings: warnings,
|
|
141
273
|
label: "#{name} (from #{source_spec.name})"
|
|
142
274
|
)
|
|
@@ -144,7 +276,7 @@ module Rails
|
|
|
144
276
|
[]
|
|
145
277
|
end
|
|
146
278
|
)
|
|
147
|
-
rescue Psych::
|
|
279
|
+
rescue Psych::Exception
|
|
148
280
|
warnings << "skip #{path}: malformed YAML frontmatter"
|
|
149
281
|
nil
|
|
150
282
|
end
|
|
@@ -182,7 +314,7 @@ module Rails
|
|
|
182
314
|
shipped = files.map { |f| f[:path] }
|
|
183
315
|
conditions.each_key do |key|
|
|
184
316
|
if SKILL_FILE_NAMES.include?(key)
|
|
185
|
-
warnings << "#{label}: conditional key '#{key}' ignored; the
|
|
317
|
+
warnings << "#{label}: conditional key '#{key}' ignored; the entry's own gem:/versions: gate the whole skill"
|
|
186
318
|
elsif !shipped.include?(key)
|
|
187
319
|
warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
|
|
188
320
|
end
|
|
@@ -200,14 +332,14 @@ module Rails
|
|
|
200
332
|
return true
|
|
201
333
|
end
|
|
202
334
|
|
|
203
|
-
targets = entry["gem"] && parse_targets(entry["gem"])
|
|
335
|
+
targets = entry["gem"] && GemManifest.parse_targets(entry["gem"])
|
|
204
336
|
if targets.nil? || targets.empty?
|
|
205
337
|
warnings << "#{label}: conditional entry for '#{key}' needs gem: naming a gem, a comma-separated list, or a YAML list; installing the file"
|
|
206
338
|
return true
|
|
207
339
|
end
|
|
208
340
|
|
|
209
341
|
versions = entry["versions"]
|
|
210
|
-
if malformed_requirements?(versions)
|
|
342
|
+
if GemManifest.malformed_requirements?(versions)
|
|
211
343
|
warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
|
|
212
344
|
return true
|
|
213
345
|
end
|
|
@@ -215,20 +347,6 @@ module Rails
|
|
|
215
347
|
match_targets(targets, versions, resolved).any?
|
|
216
348
|
end
|
|
217
349
|
|
|
218
|
-
# versions: is optional in a conditional entry; nil means unconstrained.
|
|
219
|
-
def malformed_requirements?(versions)
|
|
220
|
-
requirements = versions.is_a?(Hash) ? versions.values : [versions]
|
|
221
|
-
requirements.compact.any? do |req|
|
|
222
|
-
parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
223
|
-
begin
|
|
224
|
-
Gem::Requirement.new(*parts)
|
|
225
|
-
false
|
|
226
|
-
rescue ArgumentError
|
|
227
|
-
true
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
|
|
232
350
|
def render_support_templates(files, skill_dir, resolved:, warnings:)
|
|
233
351
|
plain_paths = files.map { |f| f[:path] }
|
|
234
352
|
files.filter_map do |file|
|
|
@@ -253,6 +371,8 @@ module Rails
|
|
|
253
371
|
path.end_with?(".md.erb")
|
|
254
372
|
end
|
|
255
373
|
|
|
374
|
+
# Guideline frontmatter is stripped because the installed file is
|
|
375
|
+
# @-included eagerly into agent context, where it would be inert noise.
|
|
256
376
|
def install_ready_body(artifact)
|
|
257
377
|
return artifact.body if artifact.skill?
|
|
258
378
|
|
|
@@ -271,12 +391,6 @@ module Rails
|
|
|
271
391
|
[lines[1...absolute_closing].join, lines[(absolute_closing + 1)..].join]
|
|
272
392
|
end
|
|
273
393
|
|
|
274
|
-
def parse_targets(raw)
|
|
275
|
-
entries = raw.is_a?(Array) ? raw : [raw]
|
|
276
|
-
return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
|
|
277
|
-
entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
278
|
-
end
|
|
279
|
-
|
|
280
394
|
def match_targets(targets, versions, resolved)
|
|
281
395
|
return ["*"] if targets.include?("*")
|
|
282
396
|
|
|
@@ -308,12 +422,15 @@ module Rails
|
|
|
308
422
|
[]
|
|
309
423
|
end
|
|
310
424
|
|
|
311
|
-
private_class_method :each_artifact_path, :
|
|
312
|
-
:
|
|
425
|
+
private_class_method :each_artifact_path, :warn_unknown_manifest_keys, :skill_paths,
|
|
426
|
+
:guideline_paths,
|
|
427
|
+
:resolve_same_dir_tie, :pair_with_templates, :warn_template_extras,
|
|
428
|
+
:opted_in?, :metadata_present?, :notice_skills_sh_content,
|
|
429
|
+
:skills_dir_override, :skill_templates_dir, :parse, :support_files_for,
|
|
313
430
|
:conditioned_support_files, :apply_conditional_filter,
|
|
314
|
-
:conditional_satisfied?,
|
|
431
|
+
:conditional_satisfied?,
|
|
315
432
|
:render_support_templates, :erb_template?,
|
|
316
|
-
:
|
|
433
|
+
:match_targets,
|
|
317
434
|
:version_satisfied?, :no_match_reason, :safe_bundler_specs
|
|
318
435
|
end
|
|
319
436
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/skill_template"
|
|
5
|
+
|
|
6
|
+
module Rails
|
|
7
|
+
module Hyperdrive
|
|
8
|
+
# Renders each SKILL.md.erb template in a companion gem repo to the static
|
|
9
|
+
# SKILL.md it pairs with, using the canonical fail-open binding.
|
|
10
|
+
# Companion-repo dev tooling: problems raise.
|
|
11
|
+
module CanonicalSkillRender
|
|
12
|
+
Error = Class.new(StandardError)
|
|
13
|
+
|
|
14
|
+
Rendered = Struct.new(:template, :dest, :body, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def write(gemspec: nil, dir: Dir.pwd)
|
|
19
|
+
render_all(gemspec: gemspec, dir: dir).each do |r|
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(r.dest))
|
|
21
|
+
File.write(r.dest, r.body)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def stale(gemspec: nil, dir: Dir.pwd)
|
|
26
|
+
render_all(gemspec: gemspec, dir: dir).reject do |r|
|
|
27
|
+
File.file?(r.dest) && File.read(r.dest) == r.body
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_all(gemspec: nil, dir: Dir.pwd)
|
|
32
|
+
spec_path = resolve_gemspec_path(gemspec, dir)
|
|
33
|
+
spec = Gem::Specification.load(spec_path)
|
|
34
|
+
raise Error, "could not load gemspec #{spec_path}" unless spec
|
|
35
|
+
|
|
36
|
+
gem_root = File.dirname(File.expand_path(spec_path))
|
|
37
|
+
skills_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skills_dir"))
|
|
38
|
+
templates_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skill_templates_dir"))
|
|
39
|
+
|
|
40
|
+
Dir.glob(File.join(templates_root, "**", "SKILL.md.erb")).sort.map do |template|
|
|
41
|
+
rel = File.dirname(template).delete_prefix(templates_root).delete_prefix("/")
|
|
42
|
+
dest_dir = File.join(skills_root, rel)
|
|
43
|
+
# A SKILL.md written beside the SKILL.md.erb would take precedence
|
|
44
|
+
# over it at discovery time, silently demoting the skill to its
|
|
45
|
+
# static face.
|
|
46
|
+
if File.expand_path(dest_dir) == File.expand_path(File.dirname(template))
|
|
47
|
+
raise Error, "#{template}: content dir equals template dir; set " \
|
|
48
|
+
"spec.metadata[\"rails_hyperdrive_skills_dir\"] to a separate root"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
body = render_template(template)
|
|
52
|
+
validate_output!(body, template)
|
|
53
|
+
Rendered.new(template: template, dest: File.join(dest_dir, "SKILL.md"), body: body)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_gemspec_path(explicit, dir)
|
|
58
|
+
if explicit && !explicit.to_s.strip.empty?
|
|
59
|
+
path = File.expand_path(explicit.to_s, dir)
|
|
60
|
+
raise Error, "gemspec not found: #{path}" unless File.file?(path)
|
|
61
|
+
return path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
found = Dir.glob(File.join(dir, "*.gemspec"))
|
|
65
|
+
case found.size
|
|
66
|
+
when 1 then File.expand_path(found.first)
|
|
67
|
+
when 0 then raise Error, "no .gemspec found in #{dir}; pass an explicit path, " \
|
|
68
|
+
"e.g. rake \"hyperdrive:skills:render[path/to/name.gemspec]\""
|
|
69
|
+
else raise Error, "multiple gemspecs found in #{dir} " \
|
|
70
|
+
"(#{found.map { |f| File.basename(f) }.join(", ")}); pass an explicit path"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def metadata_dir(spec, key)
|
|
75
|
+
default = File.join("lib", spec.name, "hyperdrive", "skills")
|
|
76
|
+
raw = spec.metadata && spec.metadata[key]
|
|
77
|
+
return default if raw.nil? || raw.to_s.strip.empty?
|
|
78
|
+
raise Error, "gemspec metadata #{key} must not contain '..' segments" if
|
|
79
|
+
raw.to_s.split(%r{[/\\]}).include?("..")
|
|
80
|
+
raw.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_template(template)
|
|
84
|
+
SkillTemplate.render_canonical(File.read(template))
|
|
85
|
+
rescue SyntaxError, StandardError => e
|
|
86
|
+
raise Error, "#{template}: canonical render failed (#{e.message})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A static face with unusable frontmatter would ship a broken skill to
|
|
90
|
+
# consumers that read it directly.
|
|
91
|
+
def validate_output!(body, template)
|
|
92
|
+
frontmatter, = BundlerArtifactDiscovery.split_frontmatter(body)
|
|
93
|
+
raise Error, "#{template}: rendered output has no YAML frontmatter" unless frontmatter
|
|
94
|
+
|
|
95
|
+
meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
|
|
96
|
+
return if meta["name"] && meta["description"]
|
|
97
|
+
raise Error, "#{template}: rendered frontmatter lacks name: or description:"
|
|
98
|
+
rescue Psych::SyntaxError
|
|
99
|
+
raise Error, "#{template}: rendered frontmatter is not parseable YAML"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private_class_method :resolve_gemspec_path, :metadata_dir, :render_template, :validate_output!
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -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
|