rails-hyperdrive 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,17 @@
1
1
  require "yaml"
2
2
  require "bundler"
3
+ require "rails/hyperdrive/gem_manifest"
3
4
  require "rails/hyperdrive/skill_template"
4
5
 
5
6
  module Rails
6
7
  module Hyperdrive
7
8
  module BundlerArtifactDiscovery
8
9
  SKILL_FILE_NAMES = ["SKILL.md", "SKILL.md.erb"].freeze
9
- INSTALLER_KEY = /\A(?:gem|versions|conditional):/.freeze
10
10
 
11
11
  Artifact = Struct.new(
12
12
  :name, :description, :target_gem, :versions, :artifact_type,
13
13
  :source_gem, :path, :body, :spec_version, :support_files,
14
- :source_root,
14
+ :source_root, :support_root,
15
15
  keyword_init: true
16
16
  ) do
17
17
  def skill?
@@ -34,17 +34,30 @@ module Rails
34
34
  module_function
35
35
 
36
36
  # Non-fatal problems are appended to `warnings` and the artifact is
37
- # dropped; discovery never raises.
38
- def discover(specs: nil, warnings: [])
37
+ # dropped; discovery never raises. A gem that has not opted in as a
38
+ # companion is never scanned — its skills.sh content is only reported
39
+ # through `notices`.
40
+ def discover(specs: nil, warnings: [], enabled_gems: [], notices: [])
39
41
  specs ||= safe_bundler_specs
42
+ enabled = Array(enabled_gems).map(&:to_s)
40
43
  resolved = specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
41
44
 
42
45
  candidates = []
43
46
  specs.each do |spec|
44
- each_artifact_path(spec, warnings: warnings) do |path, type|
45
- artifact = parse(path, source_spec: spec, type: type, resolved: resolved, warnings: warnings)
47
+ unless opted_in?(spec, enabled_gems: enabled)
48
+ notice_skills_sh_content(spec, notices: notices)
49
+ next
50
+ end
51
+ manifest = GemManifest.load(spec, warnings: warnings)
52
+ seen = { skill: [], guideline: [] }
53
+ each_artifact_path(spec, warnings: warnings) do |path, type, support_root, key|
54
+ seen[type] << key
55
+ gate = type == :skill ? manifest.skill_gate(key) : manifest.guideline_gate(key)
56
+ artifact = parse(path, source_spec: spec, type: type, resolved: resolved,
57
+ warnings: warnings, support_root: support_root, gate: gate)
46
58
  candidates << artifact if artifact
47
59
  end
60
+ warn_unknown_manifest_keys(manifest, spec, seen, warnings)
48
61
  end
49
62
 
50
63
  # Collapse same-name variants within one source gem (highest
@@ -55,29 +68,137 @@ module Rails
55
68
  end
56
69
  end
57
70
 
71
+ # Yields each candidate with its manifest join key: a skill's relpath
72
+ # from its skills root, a guideline's filename. Keys are collected before
73
+ # parsing, so a candidate later dropped (e.g. an ERB render failure)
74
+ # still counts as known to the manifest.
58
75
  def each_artifact_path(spec, warnings: [])
59
- skill_paths(spec, warnings: warnings).each { |p| yield p, :skill }
60
- guideline_paths(spec).each { |p| yield p, :guideline }
76
+ skill_paths(spec, warnings: warnings).each { |path, support_root, rel| yield path, :skill, support_root, rel }
77
+ guideline_paths(spec).each { |p| yield p, :guideline, nil, File.basename(p) }
78
+ end
79
+
80
+ # The staleness signal for gating detached from content: an entry
81
+ # matching nothing means a renamed or removed skill dir/guideline.
82
+ def warn_unknown_manifest_keys(manifest, spec, seen, warnings)
83
+ (manifest.skill_keys - seen[:skill]).each do |key|
84
+ warnings << "#{spec.name}: manifest skills entry '#{key}' names no shipped skill directory"
85
+ end
86
+ (manifest.guideline_keys - seen[:guideline]).each do |key|
87
+ warnings << "#{spec.name}: manifest guidelines entry '#{key}' names no shipped guideline"
88
+ end
61
89
  end
62
90
 
63
91
  def skill_paths(spec, warnings: [])
64
- roots = [File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills")]
92
+ roots = [
93
+ File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills"),
94
+ File.join(spec.full_gem_path, "skills")
95
+ ]
65
96
  if (override = skills_dir_override(spec))
66
97
  roots << File.join(spec.full_gem_path, override)
67
98
  end
68
- found = roots.flat_map { |root| Dir.glob(File.join(root, "**", "{SKILL.md,SKILL.md.erb}")) }.uniq
69
- found.group_by { |p| File.dirname(p) }.flat_map do |dir, group|
70
- next group unless group.size > 1
71
- warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
72
- group.select { |p| File.basename(p) == "SKILL.md" }
99
+
100
+ candidates = []
101
+ seen = {}
102
+ roots.each do |root|
103
+ Dir.glob(File.join(root, "**", "{SKILL.md,SKILL.md.erb}"))
104
+ .group_by { |p| File.dirname(p) }.each do |dir, group|
105
+ next if seen[File.expand_path(dir)]
106
+ seen[File.expand_path(dir)] = true
107
+ candidates << {
108
+ dir: dir,
109
+ rel: dir.delete_prefix(root).delete_prefix("/"),
110
+ path: resolve_same_dir_tie(dir, group, warnings)
111
+ }
112
+ end
113
+ end
114
+
115
+ pair_with_templates(candidates, spec, warnings: warnings)
116
+ end
117
+
118
+ def resolve_same_dir_tie(dir, group, warnings)
119
+ return group.first unless group.size > 1
120
+ warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
121
+ group.find { |p| File.basename(p) == "SKILL.md" }
122
+ end
123
+
124
+ # A dir holding a static SKILL.md pairs with <templates root>/<same
125
+ # relative path>/SKILL.md.erb in a distinct dir: the rendered template is
126
+ # the definition, the static dir the support root. The static SKILL.md is
127
+ # never parsed — falling back to it when the template fails to render
128
+ # would silently un-condition the skill.
129
+ def pair_with_templates(candidates, spec, warnings:)
130
+ templates_root = File.join(spec.full_gem_path, skill_templates_dir(spec))
131
+
132
+ pairs = {}
133
+ candidates.each do |cand|
134
+ next unless File.basename(cand[:path]) == "SKILL.md"
135
+ template_dir = File.expand_path(File.join(templates_root, cand[:rel]))
136
+ next if template_dir == File.expand_path(cand[:dir])
137
+ template = File.join(template_dir, "SKILL.md.erb")
138
+ next unless File.file?(template)
139
+ warn_template_extras(template_dir, warnings)
140
+ pairs[cand[:dir]] = { template: template, template_dir: template_dir }
141
+ end
142
+
143
+ consumed = pairs.values.map { |p| p[:template_dir] }
144
+ candidates.filter_map do |cand|
145
+ if (pair = pairs[cand[:dir]])
146
+ [pair[:template], cand[:dir], cand[:rel]]
147
+ elsif consumed.include?(File.expand_path(cand[:dir]))
148
+ nil
149
+ else
150
+ [cand[:path], File.dirname(cand[:path]), cand[:rel]]
151
+ end
73
152
  end
74
153
  end
75
154
 
155
+ # The content dir is the single source of truth for supporting files.
156
+ def warn_template_extras(template_dir, warnings)
157
+ extras = Dir.glob(File.join(template_dir, "**", "*"))
158
+ .select { |f| File.file?(f) && f != File.join(template_dir, "SKILL.md.erb") }
159
+ return if extras.empty?
160
+ warnings << "#{template_dir}: ignoring #{extras.size} file(s) besides SKILL.md.erb; " \
161
+ "supporting files ship in the paired content directory"
162
+ end
163
+
76
164
  def guideline_paths(spec)
77
165
  root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "guidelines")
78
166
  Dir.glob(File.join(root, "*.md"))
79
167
  end
80
168
 
169
+ # Many gemspecs package files via `git ls-files`, so a contributor-facing
170
+ # skills/ dir ships by accident — package contents don't signal consumer
171
+ # intent. Only an explicit signal makes a gem's content installable.
172
+ def opted_in?(spec, enabled_gems:)
173
+ return true if enabled_gems.include?(spec.name.to_s)
174
+ return true if metadata_present?(spec, "rails_hyperdrive_skills_dir")
175
+ return true if metadata_present?(spec, "rails_hyperdrive_skill_templates_dir")
176
+ return true if metadata_present?(spec, "rails_hyperdrive_targets")
177
+ return true if GemManifest.opt_in?(spec)
178
+
179
+ convention_root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive")
180
+ Dir.glob(File.join(convention_root, "skills", "**", "{SKILL.md,SKILL.md.erb}")).any? ||
181
+ Dir.glob(File.join(convention_root, "guidelines", "*.md")).any?
182
+ end
183
+
184
+ def metadata_present?(spec, key)
185
+ return false unless spec.respond_to?(:metadata)
186
+ raw = spec.metadata && spec.metadata[key]
187
+ !raw.to_s.strip.empty?
188
+ end
189
+
190
+ # Report-only, glob-only (no file reads): SKILL.md presence is the
191
+ # signal, and SKILL.md.erb is excluded — raw ERB is not skills.sh
192
+ # content. Parse problems surface as warnings once the gem is enabled.
193
+ def notice_skills_sh_content(spec, notices:)
194
+ found = Dir.glob(File.join(spec.full_gem_path, "skills", "**", "SKILL.md"))
195
+ return if found.empty?
196
+
197
+ count = found.map { |p| File.dirname(p) }.uniq.size
198
+ notices << "gem '#{spec.name}' ships #{count} skills.sh skill(s); add \"#{spec.name}\" to enabled: " \
199
+ "in .hyperdrive/lock.yml and re-run bin/rails hyperdrive:sync to install them"
200
+ end
201
+
81
202
  # ".." segments are rejected to prevent escaping the gem root.
82
203
  def skills_dir_override(spec)
83
204
  return nil unless spec.respond_to?(:metadata)
@@ -87,7 +208,19 @@ module Rails
87
208
  raw.to_s
88
209
  end
89
210
 
90
- def parse(path, source_spec:, type:, resolved:, warnings:)
211
+ def skill_templates_dir(spec)
212
+ default = File.join("lib", spec.name, "hyperdrive", "skills")
213
+ return default unless spec.respond_to?(:metadata)
214
+ raw = spec.metadata && spec.metadata["rails_hyperdrive_skill_templates_dir"]
215
+ return default if raw.nil? || raw.to_s.strip.empty?
216
+ return default if raw.to_s.split(%r{[/\\]}).include?("..")
217
+ raw.to_s
218
+ end
219
+
220
+ # Frontmatter's schema is exactly name and description; any other key is
221
+ # unknown to the parser and rides untouched in the installed body.
222
+ def parse(path, source_spec:, type:, resolved:, warnings:, gate:, support_root: nil)
223
+ support_root ||= File.dirname(path)
91
224
  body = File.read(path)
92
225
  if erb_template?(path)
93
226
  begin
@@ -103,25 +236,20 @@ module Rails
103
236
  return nil
104
237
  end
105
238
 
106
- meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
239
+ # Date is permitted because unknown frontmatter keys are user content;
240
+ # a bare date value must not fail the parse.
241
+ meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol, Date]) || {}
107
242
  name = meta["name"]
108
243
  description = meta["description"]
109
- versions = meta["versions"]
110
-
111
- unless name && description && meta["gem"] && versions
112
- warnings << "skip #{path}: missing a required field (name, description, gem, versions)"
113
- return nil
114
- end
115
244
 
116
- targets = parse_targets(meta["gem"])
117
- if targets.nil? || targets.empty?
118
- warnings << "skip #{name} (from #{source_spec.name}): gem: must name a gem, a comma-separated list, or a YAML list"
245
+ unless name && description
246
+ warnings << "skip #{path}: missing a required field (name, description)"
119
247
  return nil
120
248
  end
121
249
 
122
- matched = match_targets(targets, versions, resolved)
250
+ matched = match_targets(gate.targets, gate.versions, resolved)
123
251
  if matched.empty?
124
- warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(targets, versions, resolved)}"
252
+ warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(gate.targets, gate.versions, resolved)}"
125
253
  return nil
126
254
  end
127
255
 
@@ -129,17 +257,18 @@ module Rails
129
257
  name: name.to_s,
130
258
  description: description.to_s,
131
259
  target_gem: matched,
132
- versions: versions,
260
+ versions: gate.versions,
133
261
  artifact_type: type,
134
262
  source_gem: source_spec.name.to_s,
135
263
  path: path,
136
264
  body: body,
137
265
  spec_version: source_spec.version.to_s,
138
266
  source_root: source_spec.full_gem_path.to_s,
267
+ support_root: support_root,
139
268
  support_files:
140
269
  if type == :skill
141
270
  conditioned_support_files(
142
- File.dirname(path), meta["conditional"],
271
+ support_root, gate.conditional,
143
272
  resolved: resolved, warnings: warnings,
144
273
  label: "#{name} (from #{source_spec.name})"
145
274
  )
@@ -147,7 +276,7 @@ module Rails
147
276
  []
148
277
  end
149
278
  )
150
- rescue Psych::SyntaxError
279
+ rescue Psych::Exception
151
280
  warnings << "skip #{path}: malformed YAML frontmatter"
152
281
  nil
153
282
  end
@@ -185,7 +314,7 @@ module Rails
185
314
  shipped = files.map { |f| f[:path] }
186
315
  conditions.each_key do |key|
187
316
  if SKILL_FILE_NAMES.include?(key)
188
- warnings << "#{label}: conditional key '#{key}' ignored; the skill's own gem:/versions: gate the whole skill"
317
+ warnings << "#{label}: conditional key '#{key}' ignored; the entry's own gem:/versions: gate the whole skill"
189
318
  elsif !shipped.include?(key)
190
319
  warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
191
320
  end
@@ -203,14 +332,14 @@ module Rails
203
332
  return true
204
333
  end
205
334
 
206
- targets = entry["gem"] && parse_targets(entry["gem"])
335
+ targets = entry["gem"] && GemManifest.parse_targets(entry["gem"])
207
336
  if targets.nil? || targets.empty?
208
337
  warnings << "#{label}: conditional entry for '#{key}' needs gem: naming a gem, a comma-separated list, or a YAML list; installing the file"
209
338
  return true
210
339
  end
211
340
 
212
341
  versions = entry["versions"]
213
- if malformed_requirements?(versions)
342
+ if GemManifest.malformed_requirements?(versions)
214
343
  warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
215
344
  return true
216
345
  end
@@ -218,20 +347,6 @@ module Rails
218
347
  match_targets(targets, versions, resolved).any?
219
348
  end
220
349
 
221
- # versions: is optional in a conditional entry; nil means unconstrained.
222
- def malformed_requirements?(versions)
223
- requirements = versions.is_a?(Hash) ? versions.values : [versions]
224
- requirements.compact.any? do |req|
225
- parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
226
- begin
227
- Gem::Requirement.new(*parts)
228
- false
229
- rescue ArgumentError
230
- true
231
- end
232
- end
233
- end
234
-
235
350
  def render_support_templates(files, skill_dir, resolved:, warnings:)
236
351
  plain_paths = files.map { |f| f[:path] }
237
352
  files.filter_map do |file|
@@ -256,36 +371,15 @@ module Rails
256
371
  path.end_with?(".md.erb")
257
372
  end
258
373
 
374
+ # Guideline frontmatter is stripped because the installed file is
375
+ # @-included eagerly into agent context, where it would be inert noise.
259
376
  def install_ready_body(artifact)
260
- return strip_installer_keys(artifact.body) if artifact.skill?
377
+ return artifact.body if artifact.skill?
261
378
 
262
379
  _frontmatter, rest = split_frontmatter(artifact.body)
263
380
  (rest || artifact.body).sub(/\A\n+/, "")
264
381
  end
265
382
 
266
- # gem:/versions:/conditional: are install-time inputs with no reader
267
- # after install, and conditional: keys name *shipped* paths that gating
268
- # and ERB retargeting can leave pointing at files absent from disk — so
269
- # the installed frontmatter carries neither.
270
- def strip_installer_keys(body)
271
- frontmatter, rest = split_frontmatter(body)
272
- return body unless frontmatter
273
-
274
- kept = []
275
- skipping = false
276
- frontmatter.lines.each do |line|
277
- if line =~ INSTALLER_KEY
278
- skipping = true
279
- elsif skipping && (line.start_with?(" ", "\t") || line.strip.empty?)
280
- # continuation of a stripped key's block
281
- else
282
- skipping = false
283
- kept << line
284
- end
285
- end
286
- "---\n#{kept.join}---\n#{rest}"
287
- end
288
-
289
383
  def split_frontmatter(body)
290
384
  lines = body.lines
291
385
  return [nil, body] unless lines.first&.strip == "---"
@@ -297,12 +391,6 @@ module Rails
297
391
  [lines[1...absolute_closing].join, lines[(absolute_closing + 1)..].join]
298
392
  end
299
393
 
300
- def parse_targets(raw)
301
- entries = raw.is_a?(Array) ? raw : [raw]
302
- return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
303
- entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
304
- end
305
-
306
394
  def match_targets(targets, versions, resolved)
307
395
  return ["*"] if targets.include?("*")
308
396
 
@@ -334,13 +422,15 @@ module Rails
334
422
  []
335
423
  end
336
424
 
337
- private_class_method :each_artifact_path, :skill_paths, :guideline_paths,
338
- :skills_dir_override, :parse, :support_files_for,
425
+ private_class_method :each_artifact_path, :warn_unknown_manifest_keys, :skill_paths,
426
+ :guideline_paths,
427
+ :resolve_same_dir_tie, :pair_with_templates, :warn_template_extras,
428
+ :opted_in?, :metadata_present?, :notice_skills_sh_content,
429
+ :skills_dir_override, :skill_templates_dir, :parse, :support_files_for,
339
430
  :conditioned_support_files, :apply_conditional_filter,
340
- :conditional_satisfied?, :malformed_requirements?,
431
+ :conditional_satisfied?,
341
432
  :render_support_templates, :erb_template?,
342
- :strip_installer_keys,
343
- :split_frontmatter, :parse_targets, :match_targets,
433
+ :match_targets,
344
434
  :version_satisfied?, :no_match_reason, :safe_bundler_specs
345
435
  end
346
436
  end
@@ -0,0 +1,105 @@
1
+ require "fileutils"
2
+ require "yaml"
3
+ require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/skill_template"
5
+
6
+ module Rails
7
+ module Hyperdrive
8
+ # Renders each SKILL.md.erb template in a companion gem repo to the static
9
+ # SKILL.md it pairs with, using the canonical fail-open binding.
10
+ # Companion-repo dev tooling: problems raise.
11
+ module CanonicalSkillRender
12
+ Error = Class.new(StandardError)
13
+
14
+ Rendered = Struct.new(:template, :dest, :body, keyword_init: true)
15
+
16
+ module_function
17
+
18
+ def write(gemspec: nil, dir: Dir.pwd)
19
+ render_all(gemspec: gemspec, dir: dir).each do |r|
20
+ FileUtils.mkdir_p(File.dirname(r.dest))
21
+ File.write(r.dest, r.body)
22
+ end
23
+ end
24
+
25
+ def stale(gemspec: nil, dir: Dir.pwd)
26
+ render_all(gemspec: gemspec, dir: dir).reject do |r|
27
+ File.file?(r.dest) && File.read(r.dest) == r.body
28
+ end
29
+ end
30
+
31
+ def render_all(gemspec: nil, dir: Dir.pwd)
32
+ spec_path = resolve_gemspec_path(gemspec, dir)
33
+ spec = Gem::Specification.load(spec_path)
34
+ raise Error, "could not load gemspec #{spec_path}" unless spec
35
+
36
+ gem_root = File.dirname(File.expand_path(spec_path))
37
+ skills_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skills_dir"))
38
+ templates_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skill_templates_dir"))
39
+
40
+ Dir.glob(File.join(templates_root, "**", "SKILL.md.erb")).sort.map do |template|
41
+ rel = File.dirname(template).delete_prefix(templates_root).delete_prefix("/")
42
+ dest_dir = File.join(skills_root, rel)
43
+ # A SKILL.md written beside the SKILL.md.erb would take precedence
44
+ # over it at discovery time, silently demoting the skill to its
45
+ # static face.
46
+ if File.expand_path(dest_dir) == File.expand_path(File.dirname(template))
47
+ raise Error, "#{template}: content dir equals template dir; set " \
48
+ "spec.metadata[\"rails_hyperdrive_skills_dir\"] to a separate root"
49
+ end
50
+
51
+ body = render_template(template)
52
+ validate_output!(body, template)
53
+ Rendered.new(template: template, dest: File.join(dest_dir, "SKILL.md"), body: body)
54
+ end
55
+ end
56
+
57
+ def resolve_gemspec_path(explicit, dir)
58
+ if explicit && !explicit.to_s.strip.empty?
59
+ path = File.expand_path(explicit.to_s, dir)
60
+ raise Error, "gemspec not found: #{path}" unless File.file?(path)
61
+ return path
62
+ end
63
+
64
+ found = Dir.glob(File.join(dir, "*.gemspec"))
65
+ case found.size
66
+ when 1 then File.expand_path(found.first)
67
+ when 0 then raise Error, "no .gemspec found in #{dir}; pass an explicit path, " \
68
+ "e.g. rake \"hyperdrive:skills:render[path/to/name.gemspec]\""
69
+ else raise Error, "multiple gemspecs found in #{dir} " \
70
+ "(#{found.map { |f| File.basename(f) }.join(", ")}); pass an explicit path"
71
+ end
72
+ end
73
+
74
+ def metadata_dir(spec, key)
75
+ default = File.join("lib", spec.name, "hyperdrive", "skills")
76
+ raw = spec.metadata && spec.metadata[key]
77
+ return default if raw.nil? || raw.to_s.strip.empty?
78
+ raise Error, "gemspec metadata #{key} must not contain '..' segments" if
79
+ raw.to_s.split(%r{[/\\]}).include?("..")
80
+ raw.to_s
81
+ end
82
+
83
+ def render_template(template)
84
+ SkillTemplate.render_canonical(File.read(template))
85
+ rescue SyntaxError, StandardError => e
86
+ raise Error, "#{template}: canonical render failed (#{e.message})"
87
+ end
88
+
89
+ # A static face with unusable frontmatter would ship a broken skill to
90
+ # consumers that read it directly.
91
+ def validate_output!(body, template)
92
+ frontmatter, = BundlerArtifactDiscovery.split_frontmatter(body)
93
+ raise Error, "#{template}: rendered output has no YAML frontmatter" unless frontmatter
94
+
95
+ meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
96
+ return if meta["name"] && meta["description"]
97
+ raise Error, "#{template}: rendered frontmatter lacks name: or description:"
98
+ rescue Psych::SyntaxError
99
+ raise Error, "#{template}: rendered frontmatter is not parseable YAML"
100
+ end
101
+
102
+ private_class_method :resolve_gemspec_path, :metadata_dir, :render_template, :validate_output!
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,175 @@
1
+ require "yaml"
2
+
3
+ module Rails
4
+ module Hyperdrive
5
+ # A companion gem's root manifest (hyperdrive.yml) declares artifact
6
+ # gating: top-level gem:/versions: defaults for the whole gem,
7
+ # per-skill entries keyed by the skill dir's relative path from its skills
8
+ # root, per-guideline entries keyed by filename. Fail-open at every level:
9
+ # malformed input warns and resolves to an ungated install — an artifact is
10
+ # never skipped because its gating could not be read, and nothing raises.
11
+ class GemManifest
12
+ FILE_NAME = "hyperdrive.yml"
13
+ METADATA_KEY = "rails_hyperdrive_manifest"
14
+
15
+ UNGATED = ["*"].freeze
16
+
17
+ Gate = Struct.new(:targets, :versions, :conditional, keyword_init: true)
18
+
19
+ class << self
20
+ def load(spec, warnings: [])
21
+ new(spec, warnings: warnings)
22
+ end
23
+
24
+ # The metadata key counts even when its value is unusable — declaring
25
+ # it at all signals companion intent.
26
+ def opt_in?(spec)
27
+ return true if File.file?(File.join(spec.full_gem_path, FILE_NAME))
28
+ return false unless spec.respond_to?(:metadata)
29
+
30
+ raw = spec.metadata && spec.metadata[METADATA_KEY]
31
+ !raw.to_s.strip.empty?
32
+ end
33
+
34
+ def parse_targets(raw)
35
+ entries = raw.is_a?(Array) ? raw : [raw]
36
+ return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
37
+ entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
38
+ end
39
+
40
+ # versions: is optional everywhere; nil means unconstrained.
41
+ def malformed_requirements?(versions)
42
+ requirements = versions.is_a?(Hash) ? versions.values : [versions]
43
+ requirements.compact.any? do |req|
44
+ parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
45
+ begin
46
+ Gem::Requirement.new(*parts)
47
+ false
48
+ rescue ArgumentError
49
+ true
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def initialize(spec, warnings:)
56
+ @spec = spec
57
+ @warnings = warnings
58
+ root = load_root
59
+ @skills = section(root, "skills", "skill relpath")
60
+ @guidelines = section(root, "guidelines", "guideline filename")
61
+ @default_targets, @default_versions = defaults(root)
62
+ end
63
+
64
+ def skill_gate(rel)
65
+ return default_gate unless @skills.key?(rel)
66
+ entry_gate(@skills[rel], rel, with_conditional: true)
67
+ end
68
+
69
+ def guideline_gate(filename)
70
+ return default_gate unless @guidelines.key?(filename)
71
+ entry_gate(@guidelines[filename], filename, with_conditional: false)
72
+ end
73
+
74
+ def skill_keys
75
+ @skills.keys
76
+ end
77
+
78
+ def guideline_keys
79
+ @guidelines.keys
80
+ end
81
+
82
+ private
83
+
84
+ def report(message)
85
+ @warnings << "#{@spec.name}: #{message}"
86
+ end
87
+
88
+ # ".." segments are rejected (silent fallback to the conventional path)
89
+ # to prevent escaping the gem root.
90
+ def manifest_relpath
91
+ return FILE_NAME unless @spec.respond_to?(:metadata)
92
+ raw = @spec.metadata && @spec.metadata[METADATA_KEY]
93
+ return FILE_NAME if raw.nil? || raw.to_s.strip.empty?
94
+ return FILE_NAME if raw.to_s.split(%r{[/\\]}).include?("..")
95
+ raw.to_s
96
+ end
97
+
98
+ # An empty file is a valid manifest (nothing gated); only content that
99
+ # cannot be read as a YAML map warns.
100
+ def load_root
101
+ path = File.join(@spec.full_gem_path, manifest_relpath)
102
+ return {} unless File.file?(path)
103
+
104
+ data = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
105
+ return {} if data.nil?
106
+ unless data.is_a?(Hash)
107
+ report("ignoring manifest #{manifest_relpath}: root must be a YAML map")
108
+ return {}
109
+ end
110
+ data.transform_keys(&:to_s)
111
+ rescue Psych::SyntaxError
112
+ report("ignoring manifest #{manifest_relpath}: malformed YAML")
113
+ {}
114
+ rescue StandardError => e
115
+ report("ignoring manifest #{manifest_relpath}: unreadable (#{e.message})")
116
+ {}
117
+ end
118
+
119
+ def section(root, key, key_label)
120
+ value = root[key]
121
+ return {} if value.nil?
122
+ unless value.is_a?(Hash)
123
+ report("manifest #{key}: must be a map of #{key_label} to {gem:, versions:}; ignoring the section")
124
+ return {}
125
+ end
126
+ value.transform_keys(&:to_s)
127
+ end
128
+
129
+ def defaults(root)
130
+ targets = root.key?("gem") ? self.class.parse_targets(root["gem"]) : nil
131
+ bad_targets = root.key?("gem") && (targets.nil? || targets.empty?)
132
+ if bad_targets || self.class.malformed_requirements?(root["versions"])
133
+ report("manifest top-level gem:/versions: defaults are unusable; ignoring them")
134
+ return [nil, nil]
135
+ end
136
+ [targets, root["versions"]]
137
+ end
138
+
139
+ def default_gate
140
+ Gate.new(targets: @default_targets || UNGATED, versions: @default_versions, conditional: nil)
141
+ end
142
+
143
+ def ungated
144
+ Gate.new(targets: UNGATED, versions: nil, conditional: nil)
145
+ end
146
+
147
+ # A malformed entry drops the gem-wide defaults too: gating that cannot
148
+ # be read must not skip the artifact, so it installs ungated.
149
+ def entry_gate(entry, key, with_conditional:)
150
+ unless entry.is_a?(Hash)
151
+ report("manifest entry for '#{key}' must be a map with gem:/versions:; installing ungated")
152
+ return ungated
153
+ end
154
+
155
+ entry = entry.transform_keys(&:to_s)
156
+ targets = entry.key?("gem") ? self.class.parse_targets(entry["gem"]) : nil
157
+ if entry.key?("gem") && (targets.nil? || targets.empty?)
158
+ report("manifest entry for '#{key}': gem: must name a gem, a comma-separated list, " \
159
+ "or a YAML list; installing ungated")
160
+ return ungated
161
+ end
162
+ if self.class.malformed_requirements?(entry["versions"])
163
+ report("manifest entry for '#{key}' has an unparsable versions: requirement; installing ungated")
164
+ return ungated
165
+ end
166
+
167
+ Gate.new(
168
+ targets: targets || @default_targets || UNGATED,
169
+ versions: entry.key?("versions") ? entry["versions"] : @default_versions,
170
+ conditional: with_conditional ? entry["conditional"] : nil
171
+ )
172
+ end
173
+ end
174
+ end
175
+ end