rails-hyperdrive 0.4.0 → 0.6.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 +102 -1
- data/README.md +60 -30
- data/lib/generators/hyperdrive/install/install_generator.rb +9 -6
- data/lib/generators/hyperdrive/sync/sync_generator.rb +1 -1
- data/lib/generators/hyperdrive/sync_runner.rb +17 -2
- data/lib/rails/hyperdrive/auto_install.rb +3 -1
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +172 -82
- data/lib/rails/hyperdrive/canonical_skill_render.rb +105 -0
- data/lib/rails/hyperdrive/gem_manifest.rb +175 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +27 -2
- data/lib/rails/hyperdrive/lock_file.rb +15 -0
- 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 +20 -1
- data/lib/rails/hyperdrive/tools/run_sql.rb +1 -1
- data/lib/rails/hyperdrive/version.rb +1 -1
- data/lib/rails/hyperdrive.rb +0 -22
- metadata +4 -2
- data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +0 -3
|
@@ -1,17 +1,17 @@
|
|
|
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
|
|
6
7
|
module Hyperdrive
|
|
7
8
|
module BundlerArtifactDiscovery
|
|
8
9
|
SKILL_FILE_NAMES = ["SKILL.md", "SKILL.md.erb"].freeze
|
|
9
|
-
INSTALLER_KEY = /\A(?:gem|versions|conditional):/.freeze
|
|
10
10
|
|
|
11
11
|
Artifact = Struct.new(
|
|
12
12
|
:name, :description, :target_gem, :versions, :artifact_type,
|
|
13
13
|
:source_gem, :path, :body, :spec_version, :support_files,
|
|
14
|
-
:source_root,
|
|
14
|
+
:source_root, :support_root,
|
|
15
15
|
keyword_init: true
|
|
16
16
|
) do
|
|
17
17
|
def skill?
|
|
@@ -34,17 +34,30 @@ module Rails
|
|
|
34
34
|
module_function
|
|
35
35
|
|
|
36
36
|
# Non-fatal problems are appended to `warnings` and the artifact is
|
|
37
|
-
# dropped; discovery never raises.
|
|
38
|
-
|
|
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: [])
|
|
39
41
|
specs ||= safe_bundler_specs
|
|
42
|
+
enabled = Array(enabled_gems).map(&:to_s)
|
|
40
43
|
resolved = specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
|
|
41
44
|
|
|
42
45
|
candidates = []
|
|
43
46
|
specs.each do |spec|
|
|
44
|
-
|
|
45
|
-
|
|
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)
|
|
46
58
|
candidates << artifact if artifact
|
|
47
59
|
end
|
|
60
|
+
warn_unknown_manifest_keys(manifest, spec, seen, warnings)
|
|
48
61
|
end
|
|
49
62
|
|
|
50
63
|
# Collapse same-name variants within one source gem (highest
|
|
@@ -55,29 +68,137 @@ module Rails
|
|
|
55
68
|
end
|
|
56
69
|
end
|
|
57
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.
|
|
58
75
|
def each_artifact_path(spec, warnings: [])
|
|
59
|
-
skill_paths(spec, warnings: warnings).each { |
|
|
60
|
-
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
|
|
61
89
|
end
|
|
62
90
|
|
|
63
91
|
def skill_paths(spec, warnings: [])
|
|
64
|
-
roots = [
|
|
92
|
+
roots = [
|
|
93
|
+
File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills"),
|
|
94
|
+
File.join(spec.full_gem_path, "skills")
|
|
95
|
+
]
|
|
65
96
|
if (override = skills_dir_override(spec))
|
|
66
97
|
roots << File.join(spec.full_gem_path, override)
|
|
67
98
|
end
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
73
152
|
end
|
|
74
153
|
end
|
|
75
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"
|
|
162
|
+
end
|
|
163
|
+
|
|
76
164
|
def guideline_paths(spec)
|
|
77
165
|
root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "guidelines")
|
|
78
166
|
Dir.glob(File.join(root, "*.md"))
|
|
79
167
|
end
|
|
80
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
|
+
|
|
81
202
|
# ".." segments are rejected to prevent escaping the gem root.
|
|
82
203
|
def skills_dir_override(spec)
|
|
83
204
|
return nil unless spec.respond_to?(:metadata)
|
|
@@ -87,7 +208,19 @@ module Rails
|
|
|
87
208
|
raw.to_s
|
|
88
209
|
end
|
|
89
210
|
|
|
90
|
-
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)
|
|
91
224
|
body = File.read(path)
|
|
92
225
|
if erb_template?(path)
|
|
93
226
|
begin
|
|
@@ -103,25 +236,20 @@ module Rails
|
|
|
103
236
|
return nil
|
|
104
237
|
end
|
|
105
238
|
|
|
106
|
-
|
|
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]) || {}
|
|
107
242
|
name = meta["name"]
|
|
108
243
|
description = meta["description"]
|
|
109
|
-
versions = meta["versions"]
|
|
110
|
-
|
|
111
|
-
unless name && description && meta["gem"] && versions
|
|
112
|
-
warnings << "skip #{path}: missing a required field (name, description, gem, versions)"
|
|
113
|
-
return nil
|
|
114
|
-
end
|
|
115
244
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
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)"
|
|
119
247
|
return nil
|
|
120
248
|
end
|
|
121
249
|
|
|
122
|
-
matched = match_targets(targets, versions, resolved)
|
|
250
|
+
matched = match_targets(gate.targets, gate.versions, resolved)
|
|
123
251
|
if matched.empty?
|
|
124
|
-
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)}"
|
|
125
253
|
return nil
|
|
126
254
|
end
|
|
127
255
|
|
|
@@ -129,17 +257,18 @@ module Rails
|
|
|
129
257
|
name: name.to_s,
|
|
130
258
|
description: description.to_s,
|
|
131
259
|
target_gem: matched,
|
|
132
|
-
versions: versions,
|
|
260
|
+
versions: gate.versions,
|
|
133
261
|
artifact_type: type,
|
|
134
262
|
source_gem: source_spec.name.to_s,
|
|
135
263
|
path: path,
|
|
136
264
|
body: body,
|
|
137
265
|
spec_version: source_spec.version.to_s,
|
|
138
266
|
source_root: source_spec.full_gem_path.to_s,
|
|
267
|
+
support_root: support_root,
|
|
139
268
|
support_files:
|
|
140
269
|
if type == :skill
|
|
141
270
|
conditioned_support_files(
|
|
142
|
-
|
|
271
|
+
support_root, gate.conditional,
|
|
143
272
|
resolved: resolved, warnings: warnings,
|
|
144
273
|
label: "#{name} (from #{source_spec.name})"
|
|
145
274
|
)
|
|
@@ -147,7 +276,7 @@ module Rails
|
|
|
147
276
|
[]
|
|
148
277
|
end
|
|
149
278
|
)
|
|
150
|
-
rescue Psych::
|
|
279
|
+
rescue Psych::Exception
|
|
151
280
|
warnings << "skip #{path}: malformed YAML frontmatter"
|
|
152
281
|
nil
|
|
153
282
|
end
|
|
@@ -185,7 +314,7 @@ module Rails
|
|
|
185
314
|
shipped = files.map { |f| f[:path] }
|
|
186
315
|
conditions.each_key do |key|
|
|
187
316
|
if SKILL_FILE_NAMES.include?(key)
|
|
188
|
-
warnings << "#{label}: conditional key '#{key}' ignored; the
|
|
317
|
+
warnings << "#{label}: conditional key '#{key}' ignored; the entry's own gem:/versions: gate the whole skill"
|
|
189
318
|
elsif !shipped.include?(key)
|
|
190
319
|
warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
|
|
191
320
|
end
|
|
@@ -203,14 +332,14 @@ module Rails
|
|
|
203
332
|
return true
|
|
204
333
|
end
|
|
205
334
|
|
|
206
|
-
targets = entry["gem"] && parse_targets(entry["gem"])
|
|
335
|
+
targets = entry["gem"] && GemManifest.parse_targets(entry["gem"])
|
|
207
336
|
if targets.nil? || targets.empty?
|
|
208
337
|
warnings << "#{label}: conditional entry for '#{key}' needs gem: naming a gem, a comma-separated list, or a YAML list; installing the file"
|
|
209
338
|
return true
|
|
210
339
|
end
|
|
211
340
|
|
|
212
341
|
versions = entry["versions"]
|
|
213
|
-
if malformed_requirements?(versions)
|
|
342
|
+
if GemManifest.malformed_requirements?(versions)
|
|
214
343
|
warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
|
|
215
344
|
return true
|
|
216
345
|
end
|
|
@@ -218,20 +347,6 @@ module Rails
|
|
|
218
347
|
match_targets(targets, versions, resolved).any?
|
|
219
348
|
end
|
|
220
349
|
|
|
221
|
-
# versions: is optional in a conditional entry; nil means unconstrained.
|
|
222
|
-
def malformed_requirements?(versions)
|
|
223
|
-
requirements = versions.is_a?(Hash) ? versions.values : [versions]
|
|
224
|
-
requirements.compact.any? do |req|
|
|
225
|
-
parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
226
|
-
begin
|
|
227
|
-
Gem::Requirement.new(*parts)
|
|
228
|
-
false
|
|
229
|
-
rescue ArgumentError
|
|
230
|
-
true
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
end
|
|
234
|
-
|
|
235
350
|
def render_support_templates(files, skill_dir, resolved:, warnings:)
|
|
236
351
|
plain_paths = files.map { |f| f[:path] }
|
|
237
352
|
files.filter_map do |file|
|
|
@@ -256,36 +371,15 @@ module Rails
|
|
|
256
371
|
path.end_with?(".md.erb")
|
|
257
372
|
end
|
|
258
373
|
|
|
374
|
+
# Guideline frontmatter is stripped because the installed file is
|
|
375
|
+
# @-included eagerly into agent context, where it would be inert noise.
|
|
259
376
|
def install_ready_body(artifact)
|
|
260
|
-
return
|
|
377
|
+
return artifact.body if artifact.skill?
|
|
261
378
|
|
|
262
379
|
_frontmatter, rest = split_frontmatter(artifact.body)
|
|
263
380
|
(rest || artifact.body).sub(/\A\n+/, "")
|
|
264
381
|
end
|
|
265
382
|
|
|
266
|
-
# gem:/versions:/conditional: are install-time inputs with no reader
|
|
267
|
-
# after install, and conditional: keys name *shipped* paths that gating
|
|
268
|
-
# and ERB retargeting can leave pointing at files absent from disk — so
|
|
269
|
-
# the installed frontmatter carries neither.
|
|
270
|
-
def strip_installer_keys(body)
|
|
271
|
-
frontmatter, rest = split_frontmatter(body)
|
|
272
|
-
return body unless frontmatter
|
|
273
|
-
|
|
274
|
-
kept = []
|
|
275
|
-
skipping = false
|
|
276
|
-
frontmatter.lines.each do |line|
|
|
277
|
-
if line =~ INSTALLER_KEY
|
|
278
|
-
skipping = true
|
|
279
|
-
elsif skipping && (line.start_with?(" ", "\t") || line.strip.empty?)
|
|
280
|
-
# continuation of a stripped key's block
|
|
281
|
-
else
|
|
282
|
-
skipping = false
|
|
283
|
-
kept << line
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
"---\n#{kept.join}---\n#{rest}"
|
|
287
|
-
end
|
|
288
|
-
|
|
289
383
|
def split_frontmatter(body)
|
|
290
384
|
lines = body.lines
|
|
291
385
|
return [nil, body] unless lines.first&.strip == "---"
|
|
@@ -297,12 +391,6 @@ module Rails
|
|
|
297
391
|
[lines[1...absolute_closing].join, lines[(absolute_closing + 1)..].join]
|
|
298
392
|
end
|
|
299
393
|
|
|
300
|
-
def parse_targets(raw)
|
|
301
|
-
entries = raw.is_a?(Array) ? raw : [raw]
|
|
302
|
-
return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
|
|
303
|
-
entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
304
|
-
end
|
|
305
|
-
|
|
306
394
|
def match_targets(targets, versions, resolved)
|
|
307
395
|
return ["*"] if targets.include?("*")
|
|
308
396
|
|
|
@@ -334,13 +422,15 @@ module Rails
|
|
|
334
422
|
[]
|
|
335
423
|
end
|
|
336
424
|
|
|
337
|
-
private_class_method :each_artifact_path, :
|
|
338
|
-
:
|
|
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,
|
|
339
430
|
:conditioned_support_files, :apply_conditional_filter,
|
|
340
|
-
:conditional_satisfied?,
|
|
431
|
+
:conditional_satisfied?,
|
|
341
432
|
:render_support_templates, :erb_template?,
|
|
342
|
-
:
|
|
343
|
-
:split_frontmatter, :parse_targets, :match_targets,
|
|
433
|
+
:match_targets,
|
|
344
434
|
:version_satisfied?, :no_match_reason, :safe_bundler_specs
|
|
345
435
|
end
|
|
346
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
|
|
@@ -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
|