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