rails-hyperdrive 0.6.0 → 0.7.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 +266 -1
- data/README.md +44 -13
- data/lib/generators/hyperdrive/install/USAGE +11 -3
- data/lib/generators/hyperdrive/install/install_generator.rb +16 -4
- data/lib/generators/hyperdrive/install_summary.rb +3 -3
- data/lib/generators/hyperdrive/sync/USAGE +7 -5
- data/lib/generators/hyperdrive/sync_runner.rb +20 -10
- data/lib/hyperdrive/skill_tasks.rb +39 -0
- data/lib/rails/commands/hyperdrive/hyperdrive_command.rb +59 -0
- data/lib/rails/hyperdrive/ancestor_locator.rb +10 -13
- data/lib/rails/hyperdrive/artifact_status.rb +16 -7
- data/lib/rails/hyperdrive/auto_install.rb +25 -7
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +360 -150
- data/lib/rails/hyperdrive/canonical_skill_render.rb +68 -39
- data/lib/rails/hyperdrive/companion_discovery.rb +4 -5
- data/lib/rails/hyperdrive/gem_manifest.rb +311 -80
- data/lib/rails/hyperdrive/gemspec_locator.rb +38 -0
- data/lib/rails/hyperdrive/install_layout.rb +148 -12
- data/lib/rails/hyperdrive/install_pipeline.rb +142 -78
- data/lib/rails/hyperdrive/install_plan.rb +21 -9
- data/lib/rails/hyperdrive/lock_file.rb +25 -3
- data/lib/rails/hyperdrive/manifest_lint.rb +230 -0
- data/lib/rails/hyperdrive/skill_template.rb +7 -5
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +9 -7
- data/lib/rails/hyperdrive/skill_tasks.rb +0 -27
- data/lib/tasks/hyperdrive.rake +0 -22
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
require "yaml"
|
|
2
2
|
require "bundler"
|
|
3
3
|
require "rails/hyperdrive/gem_manifest"
|
|
4
|
+
require "rails/hyperdrive/install_layout"
|
|
4
5
|
require "rails/hyperdrive/skill_template"
|
|
6
|
+
require "rails/hyperdrive/version"
|
|
5
7
|
|
|
6
8
|
module Rails
|
|
7
9
|
module Hyperdrive
|
|
@@ -31,33 +33,74 @@ module Rails
|
|
|
31
33
|
end
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
# Everything one discovery walk collects, passed in so a caller reads
|
|
37
|
+
# back only what it needs. `skips` is the subset of `warnings` that
|
|
38
|
+
# dropped shipped content; everything else is advisory and installed.
|
|
39
|
+
# `bundled_gems` and `skipped_gems` report the walk itself: every gem
|
|
40
|
+
# seen, and every gem that lost at least one artifact to a hard skip.
|
|
41
|
+
Report = Struct.new(:warnings, :skips, :notices, :fence_warnings, :bundled_gems, :skipped_gems,
|
|
42
|
+
keyword_init: true) do
|
|
43
|
+
def initialize(**kw)
|
|
44
|
+
super(**{ warnings: [], skips: [], notices: [], fence_warnings: [],
|
|
45
|
+
bundled_gems: [], skipped_gems: [] }.merge(kw))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def warn(message)
|
|
49
|
+
warnings << message
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def skip(message)
|
|
53
|
+
warnings << message
|
|
54
|
+
skips << message
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Version-fence skips are also carried on their own, for surfaces that
|
|
58
|
+
# print nothing else about discovery.
|
|
59
|
+
def fence(message)
|
|
60
|
+
skip(message)
|
|
61
|
+
fence_warnings << message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def advisories
|
|
65
|
+
warnings - skips
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def mark_skipped_gem(name)
|
|
69
|
+
skipped_gems << name unless skipped_gems.include?(name)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
34
73
|
module_function
|
|
35
74
|
|
|
36
|
-
# Non-fatal problems are
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
def discover(specs: nil, warnings: [], enabled_gems: [], notices: [])
|
|
75
|
+
# Non-fatal problems are reported and the artifact is dropped; discovery
|
|
76
|
+
# never raises. A gem that has not opted in as a companion is never
|
|
77
|
+
# scanned — its skills.sh content is only reported through `notices`.
|
|
78
|
+
def discover(specs: nil, enabled_gems: [], report: Report.new)
|
|
41
79
|
specs ||= safe_bundler_specs
|
|
42
80
|
enabled = Array(enabled_gems).map(&:to_s)
|
|
43
81
|
resolved = specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
|
|
44
82
|
|
|
45
83
|
candidates = []
|
|
46
84
|
specs.each do |spec|
|
|
85
|
+
report.bundled_gems << spec.name.to_s
|
|
47
86
|
unless opted_in?(spec, enabled_gems: enabled)
|
|
48
|
-
notice_skills_sh_content(spec,
|
|
87
|
+
notice_skills_sh_content(spec, report: report)
|
|
49
88
|
next
|
|
50
89
|
end
|
|
51
|
-
manifest = GemManifest.load(spec, warnings: warnings)
|
|
52
|
-
seen = {
|
|
53
|
-
each_artifact_path(spec,
|
|
54
|
-
seen[type] << key
|
|
55
|
-
gate =
|
|
56
|
-
artifact = parse(path, source_spec: spec,
|
|
57
|
-
|
|
58
|
-
|
|
90
|
+
manifest = GemManifest.load(spec, warnings: report.warnings)
|
|
91
|
+
seen = Hash.new { |h, k| h[k] = [] }
|
|
92
|
+
each_artifact_path(spec, manifest: manifest, report: report) do |path, kind, support_root, key|
|
|
93
|
+
seen[kind.type] << key
|
|
94
|
+
gate = manifest.gate(kind.type, key)
|
|
95
|
+
artifact = parse(path, source_spec: spec, kind: kind, resolved: resolved,
|
|
96
|
+
report: report, support_root: support_root, gate: gate, key: key, manifest: manifest)
|
|
97
|
+
if artifact.is_a?(Artifact)
|
|
98
|
+
candidates << artifact
|
|
99
|
+
elsif artifact != :gate_miss
|
|
100
|
+
report.mark_skipped_gem(spec.name.to_s)
|
|
101
|
+
end
|
|
59
102
|
end
|
|
60
|
-
warn_unknown_manifest_keys(manifest, spec, seen,
|
|
103
|
+
warn_unknown_manifest_keys(manifest, spec, seen, report)
|
|
61
104
|
end
|
|
62
105
|
|
|
63
106
|
# Collapse same-name variants within one source gem (highest
|
|
@@ -68,34 +111,34 @@ module Rails
|
|
|
68
111
|
end
|
|
69
112
|
end
|
|
70
113
|
|
|
71
|
-
# Yields each candidate with its
|
|
72
|
-
# from its
|
|
73
|
-
# parsing, so a candidate later
|
|
74
|
-
# still counts as known to the
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
114
|
+
# Yields each candidate with its kind descriptor and its manifest join
|
|
115
|
+
# key: a directory-shaped kind's relpath from its root, a flat kind's
|
|
116
|
+
# filename. Keys are collected before parsing, so a candidate later
|
|
117
|
+
# dropped (e.g. an ERB render failure) still counts as known to the
|
|
118
|
+
# manifest.
|
|
119
|
+
def each_artifact_path(spec, manifest:, report:)
|
|
120
|
+
InstallLayout.content_kinds.each do |kind|
|
|
121
|
+
if kind.dir_shaped?
|
|
122
|
+
skill_paths(spec, manifest: manifest, report: report)
|
|
123
|
+
.each { |path, support_root, rel| yield path, kind, support_root, rel }
|
|
124
|
+
else
|
|
125
|
+
flat_paths(spec, kind, manifest: manifest).each { |p| yield p, kind, nil, File.basename(p) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
78
128
|
end
|
|
79
129
|
|
|
80
130
|
# The staleness signal for gating detached from content: an entry
|
|
81
|
-
# matching nothing means a renamed or removed
|
|
82
|
-
def warn_unknown_manifest_keys(manifest, spec, seen,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
warnings << "#{spec.name}: manifest guidelines entry '#{key}' names no shipped guideline"
|
|
131
|
+
# matching nothing means a renamed or removed artifact.
|
|
132
|
+
def warn_unknown_manifest_keys(manifest, spec, seen, report)
|
|
133
|
+
InstallLayout.content_kinds.each do |kind|
|
|
134
|
+
(manifest.section_keys(kind.type) - seen[kind.type]).each do |key|
|
|
135
|
+
report.warn "#{spec.name}: manifest #{kind.section} entry '#{key}' names no shipped #{kind.shipped_label}"
|
|
136
|
+
end
|
|
88
137
|
end
|
|
89
138
|
end
|
|
90
139
|
|
|
91
|
-
def skill_paths(spec,
|
|
92
|
-
roots =
|
|
93
|
-
File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills"),
|
|
94
|
-
File.join(spec.full_gem_path, "skills")
|
|
95
|
-
]
|
|
96
|
-
if (override = skills_dir_override(spec))
|
|
97
|
-
roots << File.join(spec.full_gem_path, override)
|
|
98
|
-
end
|
|
140
|
+
def skill_paths(spec, manifest:, report: Report.new)
|
|
141
|
+
roots = artifact_roots(spec, InstallLayout.kind(:skill), manifest)
|
|
99
142
|
|
|
100
143
|
candidates = []
|
|
101
144
|
seen = {}
|
|
@@ -107,17 +150,24 @@ module Rails
|
|
|
107
150
|
candidates << {
|
|
108
151
|
dir: dir,
|
|
109
152
|
rel: dir.delete_prefix(root).delete_prefix("/"),
|
|
110
|
-
path: resolve_same_dir_tie(dir, group,
|
|
153
|
+
path: resolve_same_dir_tie(dir, group, report)
|
|
111
154
|
}
|
|
112
155
|
end
|
|
113
156
|
end
|
|
114
157
|
|
|
115
|
-
pair_with_templates(candidates, spec,
|
|
158
|
+
pair_with_templates(candidates, spec, manifest: manifest, report: report)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def templates_root(spec, manifest)
|
|
162
|
+
File.join(
|
|
163
|
+
spec.full_gem_path,
|
|
164
|
+
manifest.skill_templates_dir || File.join("lib", spec.name, "hyperdrive", "skills")
|
|
165
|
+
)
|
|
116
166
|
end
|
|
117
167
|
|
|
118
|
-
def resolve_same_dir_tie(dir, group,
|
|
168
|
+
def resolve_same_dir_tie(dir, group, report)
|
|
119
169
|
return group.first unless group.size > 1
|
|
120
|
-
|
|
170
|
+
report.skip "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
|
|
121
171
|
group.find { |p| File.basename(p) == "SKILL.md" }
|
|
122
172
|
end
|
|
123
173
|
|
|
@@ -126,17 +176,17 @@ module Rails
|
|
|
126
176
|
# the definition, the static dir the support root. The static SKILL.md is
|
|
127
177
|
# never parsed — falling back to it when the template fails to render
|
|
128
178
|
# would silently un-condition the skill.
|
|
129
|
-
def pair_with_templates(candidates, spec,
|
|
130
|
-
|
|
179
|
+
def pair_with_templates(candidates, spec, manifest:, report:)
|
|
180
|
+
root = templates_root(spec, manifest)
|
|
131
181
|
|
|
132
182
|
pairs = {}
|
|
133
183
|
candidates.each do |cand|
|
|
134
184
|
next unless File.basename(cand[:path]) == "SKILL.md"
|
|
135
|
-
template_dir = File.expand_path(File.join(
|
|
185
|
+
template_dir = File.expand_path(File.join(root, cand[:rel]))
|
|
136
186
|
next if template_dir == File.expand_path(cand[:dir])
|
|
137
187
|
template = File.join(template_dir, "SKILL.md.erb")
|
|
138
188
|
next unless File.file?(template)
|
|
139
|
-
warn_template_extras(template_dir,
|
|
189
|
+
warn_template_extras(template_dir, report)
|
|
140
190
|
pairs[cand[:dir]] = { template: template, template_dir: template_dir }
|
|
141
191
|
end
|
|
142
192
|
|
|
@@ -152,18 +202,31 @@ module Rails
|
|
|
152
202
|
end
|
|
153
203
|
end
|
|
154
204
|
|
|
155
|
-
# The
|
|
156
|
-
|
|
205
|
+
# The template dir holds only templates; static content is the paired
|
|
206
|
+
# content dir's to ship.
|
|
207
|
+
def warn_template_extras(template_dir, report)
|
|
157
208
|
extras = Dir.glob(File.join(template_dir, "**", "*"))
|
|
158
|
-
.select { |f| File.file?(f) && f
|
|
209
|
+
.select { |f| File.file?(f) && !erb_template?(f) }
|
|
159
210
|
return if extras.empty?
|
|
160
|
-
|
|
161
|
-
|
|
211
|
+
report.warn "#{template_dir}: ignoring #{extras.size} file(s) besides SKILL.md.erb and " \
|
|
212
|
+
"supporting *.md.erb templates; static supporting files ship in the paired content directory"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# One .md file per artifact, deduped by expanded path so overlapping roots
|
|
216
|
+
# yield each file once.
|
|
217
|
+
def flat_paths(spec, kind, manifest:)
|
|
218
|
+
artifact_roots(spec, kind, manifest)
|
|
219
|
+
.flat_map { |root| Dir.glob(File.join(root, "*.md")) }
|
|
220
|
+
.uniq { |path| File.expand_path(path) }
|
|
162
221
|
end
|
|
163
222
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
223
|
+
# The manifest's directory override is searched in addition to the kind's
|
|
224
|
+
# convention roots, never instead of them.
|
|
225
|
+
def artifact_roots(spec, kind, manifest)
|
|
226
|
+
roots = kind.roots_for(spec.name.to_s).map { |rel| File.join(spec.full_gem_path, rel) }
|
|
227
|
+
override = kind.dir_key && manifest.dir(kind.dir_key)
|
|
228
|
+
roots << File.join(spec.full_gem_path, override) if override
|
|
229
|
+
roots
|
|
167
230
|
end
|
|
168
231
|
|
|
169
232
|
# Many gemspecs package files via `git ls-files`, so a contributor-facing
|
|
@@ -171,9 +234,7 @@ module Rails
|
|
|
171
234
|
# intent. Only an explicit signal makes a gem's content installable.
|
|
172
235
|
def opted_in?(spec, enabled_gems:)
|
|
173
236
|
return true if enabled_gems.include?(spec.name.to_s)
|
|
174
|
-
return true if metadata_present?(spec, "
|
|
175
|
-
return true if metadata_present?(spec, "rails_hyperdrive_skill_templates_dir")
|
|
176
|
-
return true if metadata_present?(spec, "rails_hyperdrive_targets")
|
|
237
|
+
return true if metadata_present?(spec, "hyperdrive_targets")
|
|
177
238
|
return true if GemManifest.opt_in?(spec)
|
|
178
239
|
|
|
179
240
|
convention_root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive")
|
|
@@ -190,75 +251,77 @@ module Rails
|
|
|
190
251
|
# Report-only, glob-only (no file reads): SKILL.md presence is the
|
|
191
252
|
# signal, and SKILL.md.erb is excluded — raw ERB is not skills.sh
|
|
192
253
|
# content. Parse problems surface as warnings once the gem is enabled.
|
|
193
|
-
def notice_skills_sh_content(spec,
|
|
254
|
+
def notice_skills_sh_content(spec, report:)
|
|
194
255
|
found = Dir.glob(File.join(spec.full_gem_path, "skills", "**", "SKILL.md"))
|
|
195
256
|
return if found.empty?
|
|
196
257
|
|
|
197
258
|
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
|
-
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# ".." segments are rejected to prevent escaping the gem root.
|
|
203
|
-
def skills_dir_override(spec)
|
|
204
|
-
return nil unless spec.respond_to?(:metadata)
|
|
205
|
-
raw = spec.metadata && spec.metadata["rails_hyperdrive_skills_dir"]
|
|
206
|
-
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
207
|
-
return nil if raw.to_s.split(%r{[/\\]}).include?("..")
|
|
208
|
-
raw.to_s
|
|
209
|
-
end
|
|
210
|
-
|
|
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
|
|
259
|
+
report.notices << "gem '#{spec.name}' ships #{count} skills.sh skill(s); add \"#{spec.name}\" to enabled: " \
|
|
260
|
+
"in .hyperdrive/lock.yml and re-run bin/rails hyperdrive:sync to install them"
|
|
218
261
|
end
|
|
219
262
|
|
|
220
263
|
# Frontmatter's schema is exactly name and description; any other key is
|
|
221
264
|
# unknown to the parser and rides untouched in the installed body.
|
|
222
|
-
|
|
265
|
+
# Returns the Artifact, :gate_miss when a well-formed gate simply does
|
|
266
|
+
# not match the bundle, or nil for a hard skip.
|
|
267
|
+
def parse(path, source_spec:, kind:, resolved:, report:, gate:, key:, manifest:, support_root: nil)
|
|
223
268
|
support_root ||= File.dirname(path)
|
|
224
269
|
body = File.read(path)
|
|
225
270
|
if erb_template?(path)
|
|
226
271
|
begin
|
|
227
272
|
body = SkillTemplate.render(body, resolved: resolved)
|
|
228
273
|
rescue SyntaxError, StandardError => e
|
|
229
|
-
|
|
274
|
+
# A template reaching for a helper this installer lacks raises
|
|
275
|
+
# here, and the fence is the only thing the user can act on.
|
|
276
|
+
if (required = unmet_fence(gate))
|
|
277
|
+
report.fence fence_message(kind.type, key, source_spec, required)
|
|
278
|
+
else
|
|
279
|
+
report.skip "skip #{path}: ERB render failed (#{e.message})"
|
|
280
|
+
end
|
|
230
281
|
return nil
|
|
231
282
|
end
|
|
232
283
|
end
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
284
|
+
|
|
285
|
+
meta = {}
|
|
286
|
+
if kind.frontmatter == :required
|
|
287
|
+
frontmatter, _rest = split_frontmatter(body)
|
|
288
|
+
unless frontmatter
|
|
289
|
+
report.skip "skip #{path}: missing or malformed frontmatter"
|
|
290
|
+
return nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Date is permitted because unknown frontmatter keys are user content;
|
|
294
|
+
# a bare date value must not fail the parse.
|
|
295
|
+
meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol, Date]) || {}
|
|
296
|
+
unless meta["name"] && meta["description"]
|
|
297
|
+
report.skip "skip #{path}: missing a required field (name, description)"
|
|
298
|
+
return nil
|
|
299
|
+
end
|
|
237
300
|
end
|
|
238
301
|
|
|
239
|
-
|
|
240
|
-
# a bare date value must not fail the parse.
|
|
241
|
-
meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol, Date]) || {}
|
|
242
|
-
name = meta["name"]
|
|
302
|
+
name = kind.identity == :frontmatter ? meta["name"] : prefixed_stem(path, kind, manifest)
|
|
243
303
|
description = meta["description"]
|
|
244
304
|
|
|
245
|
-
|
|
246
|
-
|
|
305
|
+
# The fence is decided before the bundle gate so a fenced-out artifact
|
|
306
|
+
# reports the one thing the user can act on.
|
|
307
|
+
if (required = unmet_fence(gate))
|
|
308
|
+
report.fence fence_message(kind.type, name, source_spec, required)
|
|
247
309
|
return nil
|
|
248
310
|
end
|
|
249
311
|
|
|
250
|
-
matched = match_targets(gate.targets, gate.versions, resolved)
|
|
312
|
+
matched = match_targets(gate.targets, gate.versions, resolved, mode: gate.match_mode)
|
|
251
313
|
if matched.empty?
|
|
252
|
-
|
|
253
|
-
|
|
314
|
+
reason = no_match_reason(gate.targets, gate.versions, resolved, mode: gate.match_mode)
|
|
315
|
+
report.skip "skip #{name} (from #{source_spec.name}): #{reason}"
|
|
316
|
+
return :gate_miss
|
|
254
317
|
end
|
|
255
318
|
|
|
256
319
|
Artifact.new(
|
|
257
320
|
name: name.to_s,
|
|
258
|
-
description: description
|
|
321
|
+
description: description&.to_s,
|
|
259
322
|
target_gem: matched,
|
|
260
323
|
versions: gate.versions,
|
|
261
|
-
artifact_type: type,
|
|
324
|
+
artifact_type: kind.type,
|
|
262
325
|
source_gem: source_spec.name.to_s,
|
|
263
326
|
path: path,
|
|
264
327
|
body: body,
|
|
@@ -266,10 +329,10 @@ module Rails
|
|
|
266
329
|
source_root: source_spec.full_gem_path.to_s,
|
|
267
330
|
support_root: support_root,
|
|
268
331
|
support_files:
|
|
269
|
-
if
|
|
270
|
-
|
|
271
|
-
support_root, gate.conditional,
|
|
272
|
-
resolved: resolved,
|
|
332
|
+
if kind.dir_shaped?
|
|
333
|
+
skill_support_files(
|
|
334
|
+
path, support_root, gate.conditional,
|
|
335
|
+
source_spec: source_spec, manifest: manifest, resolved: resolved, report: report,
|
|
273
336
|
label: "#{name} (from #{source_spec.name})"
|
|
274
337
|
)
|
|
275
338
|
else
|
|
@@ -277,104 +340,210 @@ module Rails
|
|
|
277
340
|
end
|
|
278
341
|
)
|
|
279
342
|
rescue Psych::Exception
|
|
280
|
-
|
|
343
|
+
report.skip "skip #{path}: malformed YAML frontmatter"
|
|
281
344
|
nil
|
|
282
345
|
end
|
|
283
346
|
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
def
|
|
288
|
-
|
|
347
|
+
# A kind identified by its filename may carry a gem-wide name prefix from
|
|
348
|
+
# the manifest; the manifest still keys its own entries by the shipped
|
|
349
|
+
# filename.
|
|
350
|
+
def prefixed_stem(path, kind, manifest)
|
|
351
|
+
stem = File.basename(path, ".md")
|
|
352
|
+
prefix = manifest.name_prefix(kind.type)
|
|
353
|
+
prefix ? "#{prefix}-#{stem}" : stem
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def fence_message(type, label, source_spec, required)
|
|
357
|
+
"#{type} '#{label}' (from #{source_spec.name}) requires rails-hyperdrive #{required} " \
|
|
358
|
+
"(this is #{Rails::Hyperdrive::VERSION}); upgrade rails-hyperdrive to install it"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Dir-relative paths of everything under `root` besides SKILL.md(.erb).
|
|
362
|
+
# ".." segments are rejected to keep the tree inside that directory.
|
|
363
|
+
def support_relpaths(root, glob: "**/*")
|
|
364
|
+
return [] if root.nil?
|
|
365
|
+
|
|
366
|
+
Dir.glob(File.join(root, glob)).filter_map do |file|
|
|
289
367
|
next unless File.file?(file)
|
|
290
|
-
rel = file.delete_prefix("#{
|
|
368
|
+
rel = file.delete_prefix("#{root}/")
|
|
291
369
|
next if SKILL_FILE_NAMES.include?(File.basename(rel))
|
|
292
370
|
next if rel.split(%r{[/\\]}).include?("..")
|
|
293
|
-
|
|
371
|
+
rel
|
|
294
372
|
end
|
|
295
373
|
end
|
|
296
374
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
375
|
+
# nil when the definition file sits in the support root, i.e. the skill
|
|
376
|
+
# is not template-paired.
|
|
377
|
+
def template_dir_for(path, support_root)
|
|
378
|
+
dir = File.dirname(path)
|
|
379
|
+
dir unless File.expand_path(dir) == File.expand_path(support_root)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Supporting files ship as raw bytes, with no frontmatter contract.
|
|
383
|
+
def support_files_for(skill_dir)
|
|
384
|
+
support_relpaths(skill_dir).map do |rel|
|
|
385
|
+
{ path: rel, body: File.binread(File.join(skill_dir, rel)), root: skill_dir }
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def template_support_files(template_dir)
|
|
390
|
+
support_relpaths(template_dir, glob: "**/*.md.erb").map do |rel|
|
|
391
|
+
{ path: rel, body: File.binread(File.join(template_dir, rel)), root: template_dir, template: true }
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def skill_support_files(path, support_root, conditional, source_spec:, manifest:, resolved:, report:, label:)
|
|
396
|
+
conditioned_support_files(
|
|
397
|
+
support_root, conditional,
|
|
398
|
+
resolved: resolved, report: report, label: label,
|
|
399
|
+
template_dir: template_dir_for(path, support_root), source_root: source_spec.full_gem_path,
|
|
400
|
+
public_root: public_support_root?(source_spec, manifest, support_root)
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def conditioned_support_files(skill_dir, conditional, resolved:, report:, label:,
|
|
405
|
+
template_dir:, source_root:, public_root:)
|
|
406
|
+
content = support_files_for(skill_dir)
|
|
407
|
+
templates = template_support_files(template_dir)
|
|
408
|
+
warn_public_support_templates(content, skill_dir, report) if public_root
|
|
409
|
+
|
|
410
|
+
# A template-side file owns its target path whatever its own outcome:
|
|
411
|
+
# installing the content-side face when the template is gated out or
|
|
412
|
+
# fails to render would silently un-condition the file.
|
|
413
|
+
claimed = templates.map { |f| target_path(f[:path]) }
|
|
414
|
+
files = apply_conditional_filter(content + templates, conditional,
|
|
415
|
+
resolved: resolved, report: report, label: label)
|
|
416
|
+
files = files.reject { |f| !f[:template] && claimed.include?(target_path(f[:path])) }
|
|
417
|
+
|
|
418
|
+
render_support_templates(files, resolved: resolved, report: report, source_root: source_root)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Everything under a public skills root is copied verbatim by generic
|
|
422
|
+
# skills.sh consumers, so a template there hands them raw ERB.
|
|
423
|
+
def warn_public_support_templates(files, skill_dir, report)
|
|
424
|
+
return if files.none? { |f| erb_template?(f[:path]) }
|
|
425
|
+
report.warn "#{skill_dir}: supporting *.md.erb template(s) sit under a public skills root; " \
|
|
426
|
+
"move them to the paired template directory and commit their rendered faces here"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def public_support_root?(spec, manifest, support_root)
|
|
430
|
+
dir = "#{File.expand_path(support_root)}/"
|
|
431
|
+
return false if dir.start_with?("#{File.expand_path(templates_root(spec, manifest))}/")
|
|
432
|
+
|
|
433
|
+
roots = [File.join(spec.full_gem_path, "skills")]
|
|
434
|
+
roots << File.join(spec.full_gem_path, manifest.skills_dir) if manifest.skills_dir
|
|
435
|
+
roots.any? { |root| dir.start_with?("#{File.expand_path(root)}/") }
|
|
301
436
|
end
|
|
302
437
|
|
|
303
438
|
# Fail-open: a malformed condition warns and installs its file
|
|
304
439
|
# unconditionally — a surplus supporting file is harmless, while a
|
|
305
440
|
# missing one breaks links from SKILL.md.
|
|
306
|
-
def apply_conditional_filter(files, conditional, resolved:,
|
|
441
|
+
def apply_conditional_filter(files, conditional, resolved:, report:, label:)
|
|
307
442
|
return files if conditional.nil?
|
|
308
443
|
unless conditional.is_a?(Hash)
|
|
309
|
-
|
|
444
|
+
report.warn "#{label}: conditional: must be a map of supporting-file path to a gating map; installing all supporting files"
|
|
310
445
|
return files
|
|
311
446
|
end
|
|
312
447
|
|
|
313
448
|
conditions = conditional.transform_keys(&:to_s)
|
|
314
|
-
shipped = files.
|
|
449
|
+
shipped = files.flat_map { |f| [f[:path], target_path(f[:path])] }
|
|
315
450
|
conditions.each_key do |key|
|
|
316
451
|
if SKILL_FILE_NAMES.include?(key)
|
|
317
|
-
|
|
452
|
+
report.warn "#{label}: conditional key '#{key}' ignored; the entry's own gem: gates the whole skill"
|
|
318
453
|
elsif !shipped.include?(key)
|
|
319
|
-
|
|
454
|
+
report.warn "#{label}: conditional key '#{key}' names no shipped supporting file"
|
|
320
455
|
end
|
|
321
456
|
end
|
|
322
457
|
|
|
323
458
|
files.select do |file|
|
|
324
|
-
|
|
325
|
-
|
|
459
|
+
key = condition_key_for(file[:path], conditions, report: report, label: label)
|
|
460
|
+
next true unless key
|
|
461
|
+
conditional_satisfied?(key, conditions[key], resolved, report: report, label: label)
|
|
326
462
|
end
|
|
327
463
|
end
|
|
328
464
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
465
|
+
# A template-backed file may be keyed by its shipped *.md.erb name or by
|
|
466
|
+
# the rendered face it installs as; the exact spelling wins when a
|
|
467
|
+
# manifest carries both.
|
|
468
|
+
def condition_key_for(path, conditions, report:, label:)
|
|
469
|
+
face = target_path(path)
|
|
470
|
+
return conditions.key?(path) ? path : nil if face == path
|
|
471
|
+
|
|
472
|
+
if conditions.key?(path)
|
|
473
|
+
if conditions.key?(face)
|
|
474
|
+
report.warn "#{label}: conditional keys '#{path}' and '#{face}' both gate '#{face}'; using '#{path}'"
|
|
475
|
+
end
|
|
476
|
+
return path
|
|
333
477
|
end
|
|
478
|
+
face if conditions.key?(face)
|
|
479
|
+
end
|
|
334
480
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
481
|
+
def conditional_satisfied?(key, entry, resolved, report:, label:)
|
|
482
|
+
unless entry.is_a?(Hash)
|
|
483
|
+
report.warn "#{label}: conditional entry for '#{key}' must be a map with gem:; installing the file"
|
|
338
484
|
return true
|
|
339
485
|
end
|
|
340
486
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
487
|
+
entry = entry.transform_keys(&:to_s)
|
|
488
|
+
gem_key = GemManifest.gem_key(entry)
|
|
489
|
+
report.warn "#{label}: conditional entry for '#{key}': #{gem_key.warning}" if gem_key.warning
|
|
490
|
+
|
|
491
|
+
parsed = gem_key.present ? GemManifest.parse_targets(gem_key.value) : nil
|
|
492
|
+
if parsed.nil? || parsed.targets.empty?
|
|
493
|
+
report.warn "#{label}: conditional entry for '#{key}' needs gem: naming #{GemManifest::GEM_FORMS}; installing the file"
|
|
344
494
|
return true
|
|
345
495
|
end
|
|
496
|
+
report.warn "#{label}: conditional entry for '#{key}': #{GemManifest::VERSIONS_REMOVED}" if entry.key?("versions")
|
|
497
|
+
report.warn "#{label}: conditional entry for '#{key}' gem: #{parsed.warning}" if parsed.warning
|
|
346
498
|
|
|
347
|
-
match_targets(targets, versions, resolved).any?
|
|
499
|
+
match_targets(parsed.targets, parsed.versions, resolved, mode: parsed.match_mode).any?
|
|
348
500
|
end
|
|
349
501
|
|
|
350
|
-
def render_support_templates(files,
|
|
502
|
+
def render_support_templates(files, resolved:, report:, source_root: nil)
|
|
351
503
|
plain_paths = files.map { |f| f[:path] }
|
|
352
504
|
files.filter_map do |file|
|
|
353
|
-
next file unless erb_template?(file[:path])
|
|
505
|
+
next { path: file[:path], body: file[:body] } unless erb_template?(file[:path])
|
|
354
506
|
|
|
355
|
-
target = file[:path]
|
|
507
|
+
target = target_path(file[:path])
|
|
356
508
|
if plain_paths.include?(target)
|
|
357
|
-
|
|
509
|
+
report.skip "skip #{File.join(file[:root], file[:path])}: #{target} in the same directory takes precedence"
|
|
358
510
|
next nil
|
|
359
511
|
end
|
|
360
512
|
|
|
361
513
|
begin
|
|
362
|
-
{ path: target, body: SkillTemplate.render(file[:body], resolved: resolved) }
|
|
514
|
+
rendered = { path: target, body: SkillTemplate.render(file[:body], resolved: resolved) }
|
|
363
515
|
rescue SyntaxError, StandardError => e
|
|
364
|
-
|
|
365
|
-
nil
|
|
516
|
+
report.skip "skip #{File.join(file[:root], file[:path])}: ERB render failed (#{e.message})"
|
|
517
|
+
next nil
|
|
366
518
|
end
|
|
519
|
+
file[:template] ? rendered.merge(source_relpath: template_relpath(file, source_root)) : rendered
|
|
367
520
|
end
|
|
368
521
|
end
|
|
369
522
|
|
|
523
|
+
# The relpath a later merge reconstructs the ancestor from: template-side,
|
|
524
|
+
# and .erb-stripped so AncestorLocator's twin fallback resolves it.
|
|
525
|
+
def template_relpath(file, source_root)
|
|
526
|
+
return nil if source_root.nil? || source_root.to_s.empty?
|
|
527
|
+
|
|
528
|
+
prefix = "#{File.expand_path(source_root.to_s)}/"
|
|
529
|
+
dir = File.expand_path(file[:root].to_s)
|
|
530
|
+
return nil unless dir.start_with?(prefix)
|
|
531
|
+
target_path(File.join(dir.delete_prefix(prefix), file[:path]))
|
|
532
|
+
end
|
|
533
|
+
|
|
370
534
|
def erb_template?(path)
|
|
371
535
|
path.end_with?(".md.erb")
|
|
372
536
|
end
|
|
373
537
|
|
|
538
|
+
def target_path(path)
|
|
539
|
+
erb_template?(path) ? path.delete_suffix(".erb") : path
|
|
540
|
+
end
|
|
541
|
+
|
|
374
542
|
# Guideline frontmatter is stripped because the installed file is
|
|
375
543
|
# @-included eagerly into agent context, where it would be inert noise.
|
|
376
544
|
def install_ready_body(artifact)
|
|
377
|
-
|
|
545
|
+
kind = InstallLayout.kind(artifact.artifact_type)
|
|
546
|
+
return artifact.body unless kind&.install_body == :strip_frontmatter
|
|
378
547
|
|
|
379
548
|
_frontmatter, rest = split_frontmatter(artifact.body)
|
|
380
549
|
(rest || artifact.body).sub(/\A\n+/, "")
|
|
@@ -391,47 +560,88 @@ module Rails
|
|
|
391
560
|
[lines[1...absolute_closing].join, lines[(absolute_closing + 1)..].join]
|
|
392
561
|
end
|
|
393
562
|
|
|
394
|
-
|
|
563
|
+
# Compares against the running installer's own version rather than the
|
|
564
|
+
# bundle, so content depending on an installer feature stays out of apps
|
|
565
|
+
# whose rails-hyperdrive cannot honor it. Returns the unmet requirement
|
|
566
|
+
# for the warning, or nil when the fence is absent or satisfied.
|
|
567
|
+
def unmet_fence(gate)
|
|
568
|
+
return nil if gate.hyperdrive_version.nil?
|
|
569
|
+
|
|
570
|
+
parts = GemManifest.requirement_parts(gate.hyperdrive_version)
|
|
571
|
+
return nil if Gem::Requirement.new(*parts).satisfied_by?(Gem::Version.new(Rails::Hyperdrive::VERSION))
|
|
572
|
+
parts.join(", ")
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def match_targets(targets, versions, resolved, mode: :any)
|
|
395
576
|
return ["*"] if targets.include?("*")
|
|
396
577
|
|
|
397
|
-
targets.select do |t|
|
|
578
|
+
matched = targets.select do |t|
|
|
398
579
|
version = resolved[t]
|
|
399
580
|
version && version_satisfied?(versions, t, version)
|
|
400
581
|
end
|
|
582
|
+
return matched unless mode == :all
|
|
583
|
+
matched.size == targets.size ? matched : []
|
|
401
584
|
end
|
|
402
585
|
|
|
403
586
|
def version_satisfied?(versions, target, version)
|
|
404
|
-
requirement = versions
|
|
587
|
+
requirement = requirement_for(versions, target)
|
|
405
588
|
requirement.nil? || SkillTemplate.version_matches?(requirement, version)
|
|
406
589
|
end
|
|
407
590
|
|
|
408
|
-
def
|
|
591
|
+
def requirement_for(versions, target)
|
|
592
|
+
versions.is_a?(Hash) ? versions[target] : versions
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def no_match_reason(targets, versions, resolved, mode: :any)
|
|
596
|
+
return all_no_match_reason(targets, versions, resolved) if mode == :all
|
|
597
|
+
|
|
409
598
|
present = targets.select { |t| resolved.key?(t) }
|
|
410
599
|
if present.empty?
|
|
411
600
|
label = targets.size == 1 ? "target gem" : "target gems"
|
|
412
601
|
"#{label} '#{targets.join(", ")}' not in bundle"
|
|
413
602
|
else
|
|
414
|
-
present.map { |t|
|
|
415
|
-
.join("; ")
|
|
603
|
+
present.map { |t| unsatisfied_reason(t, versions, resolved) }.join("; ")
|
|
416
604
|
end
|
|
417
605
|
end
|
|
418
606
|
|
|
607
|
+
def all_no_match_reason(targets, versions, resolved)
|
|
608
|
+
present, missing = targets.partition { |t| resolved.key?(t) }
|
|
609
|
+
reasons = []
|
|
610
|
+
unless missing.empty?
|
|
611
|
+
label = missing.size == 1 ? "required target gem" : "required target gems"
|
|
612
|
+
reasons << "#{label} '#{missing.join(", ")}' not in bundle"
|
|
613
|
+
end
|
|
614
|
+
present.each do |t|
|
|
615
|
+
next if version_satisfied?(versions, t, resolved[t])
|
|
616
|
+
reasons << unsatisfied_reason(t, versions, resolved)
|
|
617
|
+
end
|
|
618
|
+
reasons.join("; ")
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def unsatisfied_reason(target, versions, resolved)
|
|
622
|
+
"#{target} #{resolved[target]} does not satisfy '#{requirement_for(versions, target)}'"
|
|
623
|
+
end
|
|
624
|
+
|
|
419
625
|
def safe_bundler_specs
|
|
420
626
|
::Bundler.load.specs.to_a
|
|
421
627
|
rescue ::Bundler::BundlerError
|
|
422
628
|
[]
|
|
423
629
|
end
|
|
424
630
|
|
|
425
|
-
private_class_method :each_artifact_path, :warn_unknown_manifest_keys,
|
|
426
|
-
:
|
|
631
|
+
private_class_method :each_artifact_path, :warn_unknown_manifest_keys,
|
|
632
|
+
:artifact_roots, :prefixed_stem, :templates_root,
|
|
427
633
|
:resolve_same_dir_tie, :pair_with_templates, :warn_template_extras,
|
|
428
634
|
:opted_in?, :metadata_present?, :notice_skills_sh_content,
|
|
429
|
-
:
|
|
430
|
-
:
|
|
431
|
-
:
|
|
432
|
-
:
|
|
433
|
-
:
|
|
434
|
-
:
|
|
635
|
+
:parse, :fence_message,
|
|
636
|
+
:erb_template?, :support_files_for,
|
|
637
|
+
:template_support_files, :skill_support_files,
|
|
638
|
+
:conditioned_support_files, :warn_public_support_templates,
|
|
639
|
+
:public_support_root?, :apply_conditional_filter,
|
|
640
|
+
:condition_key_for, :conditional_satisfied?,
|
|
641
|
+
:render_support_templates, :template_relpath,
|
|
642
|
+
:unmet_fence, :match_targets,
|
|
643
|
+
:version_satisfied?, :requirement_for, :no_match_reason,
|
|
644
|
+
:all_no_match_reason, :unsatisfied_reason, :safe_bundler_specs
|
|
435
645
|
end
|
|
436
646
|
end
|
|
437
647
|
end
|