rails-hyperdrive 0.5.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -4
  3. data/README.md +71 -36
  4. data/lib/generators/hyperdrive/install/USAGE +11 -3
  5. data/lib/generators/hyperdrive/install/install_generator.rb +25 -10
  6. data/lib/generators/hyperdrive/install_summary.rb +3 -3
  7. data/lib/generators/hyperdrive/sync/USAGE +7 -5
  8. data/lib/generators/hyperdrive/sync/sync_generator.rb +1 -1
  9. data/lib/generators/hyperdrive/sync_runner.rb +20 -10
  10. data/lib/hyperdrive/skill_tasks.rb +39 -0
  11. data/lib/rails/commands/hyperdrive/hyperdrive_command.rb +59 -0
  12. data/lib/rails/hyperdrive/ancestor_locator.rb +10 -13
  13. data/lib/rails/hyperdrive/artifact_status.rb +16 -7
  14. data/lib/rails/hyperdrive/auto_install.rb +25 -7
  15. data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +360 -150
  16. data/lib/rails/hyperdrive/canonical_skill_render.rb +68 -39
  17. data/lib/rails/hyperdrive/companion_discovery.rb +4 -5
  18. data/lib/rails/hyperdrive/gem_manifest.rb +311 -80
  19. data/lib/rails/hyperdrive/gemspec_locator.rb +38 -0
  20. data/lib/rails/hyperdrive/install_layout.rb +148 -12
  21. data/lib/rails/hyperdrive/install_pipeline.rb +142 -78
  22. data/lib/rails/hyperdrive/install_plan.rb +21 -9
  23. data/lib/rails/hyperdrive/lock_file.rb +25 -3
  24. data/lib/rails/hyperdrive/manifest_lint.rb +230 -0
  25. data/lib/rails/hyperdrive/skill_template.rb +7 -5
  26. data/lib/rails/hyperdrive/tools/run_sql.rb +1 -1
  27. data/lib/rails/hyperdrive/version.rb +1 -1
  28. data/lib/rails/hyperdrive.rb +0 -22
  29. metadata +9 -8
  30. data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +0 -3
  31. data/lib/rails/hyperdrive/skill_tasks.rb +0 -27
  32. 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 appended to `warnings` and the artifact is
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: [])
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, notices: notices)
87
+ notice_skills_sh_content(spec, report: report)
49
88
  next
50
89
  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)
58
- candidates << artifact if artifact
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, warnings)
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 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.
75
- def each_artifact_path(spec, warnings: [])
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) }
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 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"
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, warnings: [])
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, warnings)
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, warnings: warnings)
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, warnings)
168
+ def resolve_same_dir_tie(dir, group, report)
119
169
  return group.first unless group.size > 1
120
- warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
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, warnings:)
130
- templates_root = File.join(spec.full_gem_path, skill_templates_dir(spec))
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(templates_root, cand[:rel]))
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, warnings)
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 content dir is the single source of truth for supporting files.
156
- def warn_template_extras(template_dir, warnings)
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 != File.join(template_dir, "SKILL.md.erb") }
209
+ .select { |f| File.file?(f) && !erb_template?(f) }
159
210
  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"
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
- def guideline_paths(spec)
165
- root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "guidelines")
166
- Dir.glob(File.join(root, "*.md"))
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, "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")
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, notices:)
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
- "in .hyperdrive/lock.yml and re-run bin/rails hyperdrive:sync to install them"
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
- def parse(path, source_spec:, type:, resolved:, warnings:, gate:, support_root: nil)
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
- warnings << "skip #{path}: ERB render failed (#{e.message})"
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
- frontmatter, _rest = split_frontmatter(body)
234
- unless frontmatter
235
- warnings << "skip #{path}: missing or malformed frontmatter"
236
- return nil
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
- # 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]) || {}
242
- name = meta["name"]
302
+ name = kind.identity == :frontmatter ? meta["name"] : prefixed_stem(path, kind, manifest)
243
303
  description = meta["description"]
244
304
 
245
- unless name && description
246
- warnings << "skip #{path}: missing a required field (name, description)"
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
- warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(gate.targets, gate.versions, resolved)}"
253
- return nil
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.to_s,
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 type == :skill
270
- conditioned_support_files(
271
- support_root, gate.conditional,
272
- resolved: resolved, warnings: warnings,
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
- warnings << "skip #{path}: malformed YAML frontmatter"
343
+ report.skip "skip #{path}: malformed YAML frontmatter"
281
344
  nil
282
345
  end
283
346
 
284
- # Everything in a skill's directory besides SKILL.md(.erb) ships as raw
285
- # bytes, with no frontmatter contract. ".." segments are rejected to keep
286
- # the tree inside the skill directory.
287
- def support_files_for(skill_dir)
288
- Dir.glob(File.join(skill_dir, "**", "*")).filter_map do |file|
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("#{skill_dir}/")
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
- { path: rel, body: File.binread(file) }
371
+ rel
294
372
  end
295
373
  end
296
374
 
297
- def conditioned_support_files(skill_dir, conditional, resolved:, warnings:, label:)
298
- files = support_files_for(skill_dir)
299
- files = apply_conditional_filter(files, conditional, resolved: resolved, warnings: warnings, label: label)
300
- render_support_templates(files, skill_dir, resolved: resolved, warnings: warnings)
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:, warnings:, label:)
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
- warnings << "#{label}: conditional: must be a map of supporting-file path to {gem:, versions:}; installing all supporting files"
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.map { |f| f[:path] }
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
- warnings << "#{label}: conditional key '#{key}' ignored; the entry's own gem:/versions: gate the whole skill"
452
+ report.warn "#{label}: conditional key '#{key}' ignored; the entry's own gem: gates the whole skill"
318
453
  elsif !shipped.include?(key)
319
- warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
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
- next true unless conditions.key?(file[:path])
325
- conditional_satisfied?(file[:path], conditions[file[:path]], resolved, warnings: warnings, label: label)
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
- def conditional_satisfied?(key, entry, resolved, warnings:, label:)
330
- unless entry.is_a?(Hash)
331
- warnings << "#{label}: conditional entry for '#{key}' must be a map with gem: (and optional versions:); installing the file"
332
- return true
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
- targets = entry["gem"] && GemManifest.parse_targets(entry["gem"])
336
- if targets.nil? || targets.empty?
337
- warnings << "#{label}: conditional entry for '#{key}' needs gem: naming a gem, a comma-separated list, or a YAML list; installing the file"
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
- versions = entry["versions"]
342
- if GemManifest.malformed_requirements?(versions)
343
- warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
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, skill_dir, resolved:, warnings:)
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].delete_suffix(".erb")
507
+ target = target_path(file[:path])
356
508
  if plain_paths.include?(target)
357
- warnings << "skip #{File.join(skill_dir, file[:path])}: #{target} in the same directory takes precedence"
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
- warnings << "skip #{File.join(skill_dir, file[:path])}: ERB render failed (#{e.message})"
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
- return artifact.body if artifact.skill?
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
- def match_targets(targets, versions, resolved)
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.is_a?(Hash) ? versions[target] : versions
587
+ requirement = requirement_for(versions, target)
405
588
  requirement.nil? || SkillTemplate.version_matches?(requirement, version)
406
589
  end
407
590
 
408
- def no_match_reason(targets, versions, resolved)
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| "#{t} #{resolved[t]} does not satisfy '#{versions.is_a?(Hash) ? versions[t] : versions}'" }
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, :skill_paths,
426
- :guideline_paths,
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
- :skills_dir_override, :skill_templates_dir, :parse, :support_files_for,
430
- :conditioned_support_files, :apply_conditional_filter,
431
- :conditional_satisfied?,
432
- :render_support_templates, :erb_template?,
433
- :match_targets,
434
- :version_satisfied?, :no_match_reason, :safe_bundler_specs
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