rails-hyperdrive 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  require "fileutils"
2
2
  require "yaml"
3
3
  require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/gem_manifest"
5
+ require "rails/hyperdrive/gemspec_locator"
4
6
  require "rails/hyperdrive/skill_template"
5
7
 
6
8
  module Rails
@@ -9,75 +11,102 @@ module Rails
9
11
  # SKILL.md it pairs with, using the canonical fail-open binding.
10
12
  # Companion-repo dev tooling: problems raise.
11
13
  module CanonicalSkillRender
12
- Error = Class.new(StandardError)
14
+ Error = GemspecLocator::Error
15
+
16
+ PUBLIC_SKILLS_DIR = "skills".freeze
13
17
 
14
18
  Rendered = Struct.new(:template, :dest, :body, keyword_init: true)
15
19
 
20
+ # `public_skills_root` is where generic skills.sh consumers read whether
21
+ # or not the gem declares a content root; `skills_root` is where this
22
+ # gem's rendered faces belong.
23
+ Roots = Struct.new(:skills_root, :templates_root, :public_skills_root, keyword_init: true)
24
+
16
25
  module_function
17
26
 
18
- def write(gemspec: nil, dir: Dir.pwd)
19
- render_all(gemspec: gemspec, dir: dir).each do |r|
27
+ # Resolved once per task so a check does not load the gemspec twice.
28
+ def resolve_roots(gemspec: nil, dir: Dir.pwd)
29
+ spec, gem_root = GemspecLocator.load_spec(gemspec, dir)
30
+ path = File.join(gem_root, GemManifest.manifest_relpath(spec))
31
+ # Rendering with default roots over a manifest the installer cannot
32
+ # read would mask the bug this surface exists to catch.
33
+ manifest, failure = GemManifest.read_root(path)
34
+ raise Error, "#{path}: #{failure}" if failure
35
+
36
+ Roots.new(
37
+ skills_root: File.join(gem_root, manifest_dir(manifest, "skills_dir", path) || PUBLIC_SKILLS_DIR),
38
+ templates_root: File.join(gem_root, manifest_dir(manifest, "skill_templates_dir", path) ||
39
+ File.join("lib", spec.name, "hyperdrive", "skills")),
40
+ public_skills_root: File.join(gem_root, PUBLIC_SKILLS_DIR)
41
+ )
42
+ end
43
+
44
+ def manifest_dir(manifest, key, path)
45
+ value, failure = GemManifest.read_dir(manifest, key)
46
+ raise Error, "#{path}: #{failure}" if failure
47
+ value
48
+ end
49
+
50
+ def write(gemspec: nil, dir: Dir.pwd, roots: nil)
51
+ render_all(gemspec: gemspec, dir: dir, roots: roots).each do |r|
20
52
  FileUtils.mkdir_p(File.dirname(r.dest))
21
53
  File.write(r.dest, r.body)
22
54
  end
23
55
  end
24
56
 
25
- def stale(gemspec: nil, dir: Dir.pwd)
26
- render_all(gemspec: gemspec, dir: dir).reject do |r|
57
+ def stale(gemspec: nil, dir: Dir.pwd, roots: nil)
58
+ render_all(gemspec: gemspec, dir: dir, roots: roots).reject do |r|
27
59
  File.file?(r.dest) && File.read(r.dest) == r.body
28
60
  end
29
61
  end
30
62
 
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"))
63
+ def render_all(gemspec: nil, dir: Dir.pwd, roots: nil)
64
+ roots ||= resolve_roots(gemspec: gemspec, dir: dir)
65
+ templates_root = roots.templates_root
39
66
 
40
- Dir.glob(File.join(templates_root, "**", "SKILL.md.erb")).sort.map do |template|
67
+ Dir.glob(File.join(templates_root, "**", "SKILL.md.erb")).sort.flat_map do |template|
41
68
  rel = File.dirname(template).delete_prefix(templates_root).delete_prefix("/")
42
- dest_dir = File.join(skills_root, rel)
69
+ dest_dir = File.join(roots.skills_root, rel)
43
70
  # A SKILL.md written beside the SKILL.md.erb would take precedence
44
71
  # over it at discovery time, silently demoting the skill to its
45
72
  # static face.
46
73
  if File.expand_path(dest_dir) == File.expand_path(File.dirname(template))
47
74
  raise Error, "#{template}: content dir equals template dir; set " \
48
- "spec.metadata[\"rails_hyperdrive_skills_dir\"] to a separate root"
75
+ "skills_dir: in hyperdrive.yml to a separate root"
49
76
  end
50
77
 
51
78
  body = render_template(template)
52
79
  validate_output!(body, template)
53
- Rendered.new(template: template, dest: File.join(dest_dir, "SKILL.md"), body: body)
80
+ [Rendered.new(template: template, dest: File.join(dest_dir, "SKILL.md"), body: body)] +
81
+ support_renders(File.dirname(template), dest_dir)
54
82
  end
55
83
  end
56
84
 
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"
85
+ # Supporting files carry no frontmatter contract, so their faces are
86
+ # written unvalidated.
87
+ def support_renders(template_dir, dest_dir)
88
+ Dir.glob(File.join(template_dir, "**", "*.md.erb")).sort.filter_map do |template|
89
+ rel = template.delete_prefix("#{template_dir}/")
90
+ next if File.basename(rel) == "SKILL.md.erb"
91
+ Rendered.new(
92
+ template: template,
93
+ dest: File.join(dest_dir, rel.delete_suffix(".erb")),
94
+ body: render_template(template)
95
+ )
71
96
  end
72
97
  end
73
98
 
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
99
+ # A *.md.erb reachable through a public skills root is copied verbatim by
100
+ # generic skills.sh consumers.
101
+ def public_erb_templates(gemspec: nil, dir: Dir.pwd, roots: nil)
102
+ roots ||= resolve_roots(gemspec: gemspec, dir: dir)
103
+ templates_root = File.expand_path(roots.templates_root)
104
+
105
+ [roots.public_skills_root, roots.skills_root]
106
+ .uniq { |r| File.expand_path(r) }
107
+ .flat_map { |root| Dir.glob(File.join(root, "**", "*.md.erb")) }
108
+ .reject { |path| File.expand_path(path).start_with?("#{templates_root}/") }
109
+ .uniq.sort
81
110
  end
82
111
 
83
112
  def render_template(template)
@@ -99,7 +128,7 @@ module Rails
99
128
  raise Error, "#{template}: rendered frontmatter is not parseable YAML"
100
129
  end
101
130
 
102
- private_class_method :resolve_gemspec_path, :metadata_dir, :render_template, :validate_output!
131
+ private_class_method :manifest_dir, :support_renders, :render_template, :validate_output!
103
132
  end
104
133
  end
105
134
  end
@@ -10,11 +10,10 @@ require "bundler"
10
10
  module Rails
11
11
  module Hyperdrive
12
12
  class CompanionDiscovery
13
- TARGETS_KEY = "rails_hyperdrive_targets".freeze
14
- ARTIFACTS_KEY = "rails_hyperdrive_artifacts".freeze
15
- # Field-scoped metadata search is an undocumented passthrough to the
16
- # rubygems search backend, so it fails open: if it ever stops matching, the
17
- # query returns an empty 200 page and discovery reports no suggestions.
13
+ TARGETS_KEY = "hyperdrive_targets".freeze
14
+ ARTIFACTS_KEY = "hyperdrive_artifacts".freeze
15
+ # An undocumented passthrough to the rubygems search backend: if it stops
16
+ # matching, it degrades to an empty 200 page (no suggestions), never an error.
18
17
  SEARCH_QUERY = "metadata.#{TARGETS_KEY}:*".freeze
19
18
  SEARCH_ENDPOINT = "https://rubygems.org/api/v1/search.json".freeze
20
19
  CACHE_RELATIVE_PATH = ".hyperdrive/discover_cache.json".freeze
@@ -1,26 +1,64 @@
1
1
  require "yaml"
2
+ require "rails/hyperdrive/install_layout"
2
3
 
3
4
  module Rails
4
5
  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.
6
+ # Everything the installer reads from a companion gem: where each artifact
7
+ # kind lives, and which artifacts a given bundle gets.
8
+ # Fail-open at every level malformed input warns and resolves to an
9
+ # ungated install at the default roots; an artifact is never skipped for
10
+ # unreadable config, and nothing raises.
11
11
  class GemManifest
12
12
  FILE_NAME = "hyperdrive.yml"
13
- METADATA_KEY = "rails_hyperdrive_manifest"
13
+ METADATA_KEY = "hyperdrive_manifest"
14
+
15
+ SKILL_TEMPLATES_DIR_KEY = "skill_templates_dir".freeze
16
+ DIR_KEYS = (InstallLayout.dir_keys + [SKILL_TEMPLATES_DIR_KEY]).freeze
14
17
 
15
18
  UNGATED = ["*"].freeze
19
+ MATCH_MODES = %w[any all].freeze
20
+ GEM_FORMS = "a gem, a comma-separated list, a YAML list of names and name: requirement " \
21
+ "pairs, or an any:/all: map (gems: is an alias)".freeze
22
+ VERSIONS_REMOVED = "versions: is no longer supported; version constraints ignored — " \
23
+ "put the requirement on the gem: member, e.g. `- name: \">= 1.0\"`".freeze
24
+
25
+ Gate = Struct.new(:targets, :versions, :hyperdrive_version, :conditional, :match_mode,
26
+ keyword_init: true)
27
+
28
+ # `warning`, when set, is phrased for the caller to prefix with its own
29
+ # context — the parser has no idea which entry it is reading.
30
+ TargetSpec = Struct.new(:targets, :match_mode, :versions, :warning, keyword_init: true)
16
31
 
17
- Gate = Struct.new(:targets, :versions, :conditional, keyword_init: true)
32
+ GemKey = Struct.new(:value, :present, :conflict, :warning, keyword_init: true)
18
33
 
19
34
  class << self
20
35
  def load(spec, warnings: [])
21
36
  new(spec, warnings: warnings)
22
37
  end
23
38
 
39
+ # gems: and gem: are exact aliases. A map carrying both is a stylistic
40
+ # slip, not malformed input: picking a winner keeps the gate intact
41
+ # where failing open would silently widen it.
42
+ def gem_key(map)
43
+ both = map.key?("gem") && map.key?("gems")
44
+ GemKey.new(
45
+ value: map.key?("gems") ? map["gems"] : map["gem"],
46
+ present: map.key?("gem") || map.key?("gems"),
47
+ conflict: both,
48
+ warning: ("gem: and gems: are aliases; reading gems: and ignoring gem:" if both)
49
+ )
50
+ end
51
+
52
+ # ".." segments are rejected (silent fallback to the conventional path)
53
+ # to prevent escaping the gem root.
54
+ def manifest_relpath(spec)
55
+ return FILE_NAME unless spec.respond_to?(:metadata)
56
+ raw = spec.metadata && spec.metadata[METADATA_KEY]
57
+ return FILE_NAME if raw.nil? || raw.to_s.strip.empty?
58
+ return FILE_NAME if raw.to_s.split(%r{[/\\]}).include?("..")
59
+ raw.to_s
60
+ end
61
+
24
62
  # The metadata key counts even when its value is unusable — declaring
25
63
  # it at all signals companion intent.
26
64
  def opt_in?(spec)
@@ -31,52 +69,194 @@ module Rails
31
69
  !raw.to_s.strip.empty?
32
70
  end
33
71
 
72
+ # nil signals malformed; the caller decides how to fail open.
34
73
  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?)
74
+ return parse_target_map(raw) if raw.is_a?(Hash)
75
+ parse_target_names(raw)
38
76
  end
39
77
 
40
- # versions: is optional everywhere; nil means unconstrained.
78
+ # A version requirement is optional everywhere; nil means unconstrained.
41
79
  def malformed_requirements?(versions)
42
80
  requirements = versions.is_a?(Hash) ? versions.values : [versions]
43
81
  requirements.compact.any? do |req|
44
- parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
45
82
  begin
46
- Gem::Requirement.new(*parts)
83
+ Gem::Requirement.new(*requirement_parts(req))
47
84
  false
48
85
  rescue ArgumentError
49
86
  true
50
87
  end
51
88
  end
52
89
  end
90
+
91
+ # The fence names one requirement against one version, so a per-gem
92
+ # requirement map is meaningless here.
93
+ def malformed_fence?(value)
94
+ return true if value.is_a?(Hash)
95
+ malformed_requirements?(value)
96
+ end
97
+
98
+ def requirement_parts(req)
99
+ Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
100
+ end
101
+
102
+ # Returns [relative directory or nil, failure reason or nil]. Absent or
103
+ # blank is no failure — the caller uses its own default root. ".."
104
+ # segments are rejected to prevent escaping the gem root.
105
+ def read_dir(root, key)
106
+ raw = root[key]
107
+ return [nil, nil] if raw.nil?
108
+ return [nil, "#{key}: must be a directory path relative to the gem root"] unless raw.is_a?(String)
109
+ return [nil, nil] if raw.strip.empty?
110
+ return [nil, "#{key}: must not contain '..' segments"] if raw.split(%r{[/\\]}).include?("..")
111
+
112
+ [raw, nil]
113
+ end
114
+
115
+ # Returns [root map or nil, failure reason or nil]. An absent or empty
116
+ # file is a valid empty manifest; the caller phrases the failure and
117
+ # decides whether to fall open on it.
118
+ def read_root(path)
119
+ return [{}, nil] unless File.file?(path)
120
+
121
+ data = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
122
+ return [{}, nil] if data.nil?
123
+ return [data.transform_keys(&:to_s), nil] if data.is_a?(Hash)
124
+
125
+ [nil, "root must be a YAML map"]
126
+ rescue Psych::SyntaxError => e
127
+ [nil, "malformed YAML (#{e.message})"]
128
+ rescue StandardError => e
129
+ [nil, "unreadable (#{e.message})"]
130
+ end
131
+
132
+ private
133
+
134
+ # A list member is either a bare gem name (unconstrained, comma-split
135
+ # for the inline multi-name spelling) or a single-pair map carrying
136
+ # that member's own version requirement.
137
+ def parse_target_names(raw)
138
+ entries = raw.is_a?(Array) ? raw : [raw]
139
+ names = []
140
+ requirements = {}
141
+ dropped_star = false
142
+
143
+ entries.each do |entry|
144
+ case entry
145
+ when Hash
146
+ name, requirement = pair_member(entry)
147
+ return nil unless name
148
+ if name == "*"
149
+ dropped_star = true
150
+ next
151
+ end
152
+ names << name
153
+ requirements[name] = requirement if requirement
154
+ when nil, Array
155
+ return nil
156
+ else
157
+ names.concat(entry.to_s.split(",").map(&:strip).reject(&:empty?))
158
+ end
159
+ end
160
+
161
+ names = UNGATED.dup if names.empty? && dropped_star
162
+ TargetSpec.new(
163
+ targets: names,
164
+ match_mode: :any,
165
+ versions: requirements.empty? ? nil : requirements,
166
+ warning: ("a version requirement on '*' is meaningless; ignoring that member" if dropped_star)
167
+ )
168
+ end
169
+
170
+ # nil signals a malformed member. A "*" key is kept for the caller to
171
+ # drop — its requirement is meaningless, not unreadable.
172
+ def pair_member(entry)
173
+ return nil unless entry.size == 1
174
+
175
+ name, requirement = entry.first
176
+ name = name.to_s.strip
177
+ return nil if name.empty?
178
+ return [name, nil] if name == "*"
179
+ return nil if requirement.is_a?(Hash)
180
+ return [name, nil] if requirement.nil? || requirement == "*"
181
+ return nil if malformed_requirements?(requirement)
182
+
183
+ [name, requirement]
184
+ end
185
+
186
+ def parse_target_map(raw)
187
+ keys = raw.keys.map(&:to_s)
188
+ return nil unless keys.size == 1 && MATCH_MODES.include?(keys.first)
189
+
190
+ # The list is the only container a mode key accepts. Reading a bare
191
+ # map as a one-member pair would make `{sidekiq: ">= 7"}` gate while
192
+ # its two-gem sibling falls open to ungated.
193
+ return nil if raw.values.first.is_a?(Hash)
194
+
195
+ spec = parse_target_names(raw.values.first)
196
+ return nil if spec.nil?
197
+ return spec if keys.first == "any"
198
+
199
+ all_target_spec(spec)
200
+ end
201
+
202
+ # "*" is satisfied by definition, so dropping it leaves the remaining
203
+ # targets gating; a list of nothing else has no targets left to gate on.
204
+ def all_target_spec(spec)
205
+ kept = spec.targets.reject { |n| n == "*" }
206
+ dropped = kept.size != spec.targets.size
207
+
208
+ TargetSpec.new(
209
+ targets: dropped && kept.empty? ? UNGATED : kept,
210
+ match_mode: :all,
211
+ versions: spec.versions,
212
+ warning: [spec.warning, ("'*' in all: is always satisfied; ignoring it" if dropped)]
213
+ .compact.join("; ").then { |w| w.empty? ? nil : w }
214
+ )
215
+ end
53
216
  end
54
217
 
55
218
  def initialize(spec, warnings:)
56
219
  @spec = spec
57
220
  @warnings = warnings
58
221
  root = load_root
59
- @skills = section(root, "skills", "skill relpath")
60
- @guidelines = section(root, "guidelines", "guideline filename")
61
- @default_targets, @default_versions = defaults(root)
222
+ @sections = {}
223
+ @prefixes = {}
224
+ InstallLayout.content_kinds.each do |kind|
225
+ entries, settings = section(root, kind)
226
+ @sections[kind.type] = entries
227
+ @prefixes[kind.type] = read_name_prefix(kind, settings)
228
+ end
229
+ @dirs = DIR_KEYS.each_with_object({}) { |key, h| h[key] = resolve_dir(root, key) }
230
+ @default_spec, @default_fence = defaults(root)
231
+ end
232
+
233
+ def gate(type, key)
234
+ entries = @sections[type] || {}
235
+ return default_gate unless entries.key?(key)
236
+ entry_gate(entries[key], key, with_conditional: InstallLayout.kind(type).dir_shaped?)
237
+ end
238
+
239
+ def section_keys(type)
240
+ (@sections[type] || {}).keys
62
241
  end
63
242
 
64
- def skill_gate(rel)
65
- return default_gate unless @skills.key?(rel)
66
- entry_gate(@skills[rel], rel, with_conditional: true)
243
+ # The section-level scalar a kind prepends to every artifact name it
244
+ # discovers; nil when absent or unusable.
245
+ def name_prefix(type)
246
+ @prefixes[type]
67
247
  end
68
248
 
69
- def guideline_gate(filename)
70
- return default_gate unless @guidelines.key?(filename)
71
- entry_gate(@guidelines[filename], filename, with_conditional: false)
249
+ # nil means the caller's default root.
250
+ def dir(key)
251
+ @dirs[key.to_s]
72
252
  end
73
253
 
74
- def skill_keys
75
- @skills.keys
254
+ def skills_dir
255
+ dir(InstallLayout.kind(:skill).dir_key)
76
256
  end
77
257
 
78
- def guideline_keys
79
- @guidelines.keys
258
+ def skill_templates_dir
259
+ dir(SKILL_TEMPLATES_DIR_KEY)
80
260
  end
81
261
 
82
262
  private
@@ -85,88 +265,139 @@ module Rails
85
265
  @warnings << "#{@spec.name}: #{message}"
86
266
  end
87
267
 
88
- # ".." segments are rejected (silent fallback to the conventional path)
89
- # to prevent escaping the gem root.
90
268
  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
269
+ self.class.manifest_relpath(@spec)
96
270
  end
97
271
 
98
- # An empty file is a valid manifest (nothing gated); only content that
99
- # cannot be read as a YAML map warns.
272
+ # Gating that cannot be read must never keep content out, so a failure
273
+ # here resolves to an absent manifest.
100
274
  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})")
275
+ root, failure = self.class.read_root(File.join(@spec.full_gem_path, manifest_relpath))
276
+ return root if root
277
+
278
+ report("ignoring manifest #{manifest_relpath}: #{failure}")
116
279
  {}
117
280
  end
118
281
 
119
- def section(root, key, key_label)
120
- value = root[key]
121
- return {} if value.nil?
282
+ def resolve_dir(root, key)
283
+ value, failure = self.class.read_dir(root, key)
284
+ report("manifest top-level #{failure}; ignoring it") if failure
285
+ value
286
+ end
287
+
288
+ # Returns [gating entries, the kind's reserved section-level settings].
289
+ def section(root, kind)
290
+ value = root[kind.section]
291
+ return [{}, {}] if value.nil?
122
292
  unless value.is_a?(Hash)
123
- report("manifest #{key}: must be a map of #{key_label} to {gem:, versions:}; ignoring the section")
124
- return {}
293
+ report("manifest #{kind.section}: must be a map of #{kind.key_label} to a gating map; ignoring the section")
294
+ return [{}, {}]
125
295
  end
126
- value.transform_keys(&:to_s)
296
+
297
+ entries = value.transform_keys(&:to_s)
298
+ reserved = Array(kind.prefix_key)
299
+ [entries.reject { |k, _| reserved.include?(k) }, entries.slice(*reserved)]
127
300
  end
128
301
 
302
+ # A prefix becomes part of the installed filename, so anything that could
303
+ # reach outside the kind's directory is refused rather than sanitized.
304
+ def read_name_prefix(kind, settings)
305
+ key = kind.prefix_key
306
+ return nil unless key
307
+
308
+ raw = settings[key]
309
+ return nil if raw.nil?
310
+ unless raw.is_a?(String)
311
+ report("manifest #{kind.section} #{key}: must be a name prefix string; ignoring it")
312
+ return nil
313
+ end
314
+
315
+ prefix = raw.strip
316
+ return nil if prefix.empty?
317
+ return prefix unless prefix.match?(%r{[/\\]}) || prefix.include?("..")
318
+
319
+ report("manifest #{kind.section} #{key}: must not contain path separators or '..' segments; ignoring it")
320
+ nil
321
+ end
322
+
323
+ # Independent axes: an unusable gem: must not silence a parseable fence,
324
+ # the one gate that keeps content out of an installer too old to read it.
129
325
  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]
326
+ report("manifest top-level #{VERSIONS_REMOVED}") if root.key?("versions")
327
+ [default_target_spec(root), default_fence(root)]
328
+ end
329
+
330
+ def default_target_spec(root)
331
+ gem_key = self.class.gem_key(root)
332
+ report("manifest top-level #{gem_key.warning}") if gem_key.warning
333
+ return nil unless gem_key.present
334
+
335
+ parsed = self.class.parse_targets(gem_key.value)
336
+ if parsed.nil? || parsed.targets.empty?
337
+ report("manifest top-level gem: default is unusable; ignoring it")
338
+ return nil
135
339
  end
136
- [targets, root["versions"]]
340
+ report("manifest top-level gem: #{parsed.warning}") if parsed.warning
341
+ parsed
342
+ end
343
+
344
+ def default_fence(root)
345
+ return root["hyperdrive_version"] unless self.class.malformed_fence?(root["hyperdrive_version"])
346
+
347
+ report("manifest top-level hyperdrive_version: default is unusable; ignoring it")
348
+ nil
137
349
  end
138
350
 
139
351
  def default_gate
140
- Gate.new(targets: @default_targets || UNGATED, versions: @default_versions, conditional: nil)
352
+ build_gate(@default_spec, hyperdrive_version: @default_fence)
141
353
  end
142
354
 
143
- def ungated
144
- Gate.new(targets: UNGATED, versions: nil, conditional: nil)
355
+ def ungated(hyperdrive_version: nil)
356
+ build_gate(nil, hyperdrive_version: hyperdrive_version)
145
357
  end
146
358
 
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.
359
+ def build_gate(target_spec, hyperdrive_version: nil, conditional: nil)
360
+ Gate.new(
361
+ targets: target_spec ? target_spec.targets : UNGATED,
362
+ versions: target_spec&.versions,
363
+ hyperdrive_version: hyperdrive_version,
364
+ conditional: conditional,
365
+ match_mode: target_spec ? target_spec.match_mode : :any
366
+ )
367
+ end
368
+
369
+ # Gating that cannot be read must not skip the artifact, so a malformed
370
+ # entry installs ungated, gem-wide gem: default and all. The fence is
371
+ # resolved first and rides those fall-throughs — except where it is
372
+ # itself the unparsable part, since the gem-wide default would then apply
373
+ # a constraint this entry never asked for.
149
374
  def entry_gate(entry, key, with_conditional:)
150
375
  unless entry.is_a?(Hash)
151
- report("manifest entry for '#{key}' must be a map with gem:/versions:; installing ungated")
152
- return ungated
376
+ report("manifest entry for '#{key}' must be a map with gem:; installing ungated unless fenced out")
377
+ return ungated(hyperdrive_version: @default_fence)
153
378
  end
154
379
 
155
380
  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")
381
+ if self.class.malformed_fence?(entry["hyperdrive_version"])
382
+ report("manifest entry for '#{key}' has an unparsable hyperdrive_version: requirement; " \
383
+ "installing ungated and unfenced")
160
384
  return ungated
161
385
  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
386
+ fence = entry.key?("hyperdrive_version") ? entry["hyperdrive_version"] : @default_fence
387
+
388
+ gem_key = self.class.gem_key(entry)
389
+ report("manifest entry for '#{key}': #{gem_key.warning}") if gem_key.warning
390
+ parsed = gem_key.present ? self.class.parse_targets(gem_key.value) : nil
391
+ if gem_key.present && (parsed.nil? || parsed.targets.empty?)
392
+ report("manifest entry for '#{key}': gem: must name #{GEM_FORMS}; installing ungated unless fenced out")
393
+ return ungated(hyperdrive_version: fence)
165
394
  end
395
+ report("manifest entry for '#{key}': #{VERSIONS_REMOVED}") if entry.key?("versions")
396
+ report("manifest entry for '#{key}' gem: #{parsed.warning}") if parsed&.warning
166
397
 
167
- Gate.new(
168
- targets: targets || @default_targets || UNGATED,
169
- versions: entry.key?("versions") ? entry["versions"] : @default_versions,
398
+ build_gate(
399
+ parsed || @default_spec,
400
+ hyperdrive_version: fence,
170
401
  conditional: with_conditional ? entry["conditional"] : nil
171
402
  )
172
403
  end