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.
@@ -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 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 = 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, warnings)
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: 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) }
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 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"
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, 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
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, warnings)
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, warnings: warnings)
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, warnings)
173
+ def resolve_same_dir_tie(dir, group, report)
119
174
  return group.first unless group.size > 1
120
- warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
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, warnings:)
130
- templates_root = File.join(spec.full_gem_path, skill_templates_dir(spec))
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(templates_root, cand[:rel]))
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, warnings)
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 content dir is the single source of truth for supporting files.
156
- def warn_template_extras(template_dir, warnings)
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 != File.join(template_dir, "SKILL.md.erb") }
214
+ .select { |f| File.file?(f) && !erb_template?(f) }
159
215
  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"
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
- def guideline_paths(spec)
165
- root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "guidelines")
166
- Dir.glob(File.join(root, "*.md"))
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, "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")
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, notices:)
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
- "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
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
- def parse(path, source_spec:, type:, resolved:, warnings:, gate:, support_root: nil)
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
- warnings << "skip #{path}: ERB render failed (#{e.message})"
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
- frontmatter, _rest = split_frontmatter(body)
234
- unless frontmatter
235
- warnings << "skip #{path}: missing or malformed frontmatter"
236
- return nil
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
- # 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"]
353
+ name = kind.identity == :frontmatter ? meta["name"] : prefixed_stem(path, kind, manifest)
243
354
  description = meta["description"]
244
355
 
245
- unless name && description
246
- warnings << "skip #{path}: missing a required field (name, description)"
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
- warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(gate.targets, gate.versions, resolved)}"
253
- return nil
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.to_s,
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 type == :skill
270
- conditioned_support_files(
271
- support_root, gate.conditional,
272
- resolved: resolved, warnings: warnings,
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
- warnings << "skip #{path}: malformed YAML frontmatter"
394
+ report.skip "skip #{path}: malformed YAML frontmatter"
281
395
  nil
282
396
  end
283
397
 
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|
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("#{skill_dir}/")
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
- { path: rel, body: File.binread(file) }
422
+ rel
294
423
  end
295
424
  end
296
425
 
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)
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:, warnings:, label:)
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
- warnings << "#{label}: conditional: must be a map of supporting-file path to {gem:, versions:}; installing all supporting files"
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.map { |f| f[:path] }
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
- warnings << "#{label}: conditional key '#{key}' ignored; the entry's own gem:/versions: gate the whole skill"
503
+ report.warn "#{label}: conditional key '#{key}' ignored; the entry's own gem: gates the whole skill"
318
504
  elsif !shipped.include?(key)
319
- warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
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
- next true unless conditions.key?(file[:path])
325
- conditional_satisfied?(file[:path], conditions[file[:path]], resolved, warnings: warnings, label: label)
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
- 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
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
- 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"
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
- versions = entry["versions"]
342
- if GemManifest.malformed_requirements?(versions)
343
- warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
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, skill_dir, resolved:, warnings:)
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].delete_suffix(".erb")
558
+ target = target_path(file[:path])
356
559
  if plain_paths.include?(target)
357
- warnings << "skip #{File.join(skill_dir, file[:path])}: #{target} in the same directory takes precedence"
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
- warnings << "skip #{File.join(skill_dir, file[:path])}: ERB render failed (#{e.message})"
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
- return artifact.body if artifact.skill?
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
- def match_targets(targets, versions, resolved)
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.is_a?(Hash) ? versions[target] : versions
638
+ requirement = requirement_for(versions, target)
405
639
  requirement.nil? || SkillTemplate.version_matches?(requirement, version)
406
640
  end
407
641
 
408
- def no_match_reason(targets, versions, resolved)
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| "#{t} #{resolved[t]} does not satisfy '#{versions.is_a?(Hash) ? versions[t] : versions}'" }
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, :skill_paths,
426
- :guideline_paths,
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
- :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
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