rails-hyperdrive 0.3.0 → 0.5.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.
@@ -0,0 +1,76 @@
1
+ require "bundler"
2
+ require "rails/hyperdrive/bundler_artifact_discovery"
3
+ require "rails/hyperdrive/drift_verdict"
4
+ require "rails/hyperdrive/skill_template"
5
+
6
+ module Rails
7
+ module Hyperdrive
8
+ # Best-effort reconstruction of the install-ready body an artifact had at
9
+ # the gem version a lock entry records, read from installed gem
10
+ # directories. The result is sha-gated against the lock entry: anything
11
+ # that does not rebuild to exactly the recorded bytes counts as
12
+ # unavailable.
13
+ module AncestorLocator
14
+ module_function
15
+
16
+ # Never raises; any failure returns nil (ancestor unavailable).
17
+ def locate(kind:, relpath:, lock_entry:, final_name: nil, gem_paths: Gem.path, resolved: nil)
18
+ return nil unless lock_entry&.source_gem && lock_entry.source_version && lock_entry.source_sha
19
+ return nil if relpath.nil? || relpath.to_s.empty?
20
+
21
+ Array(gem_paths).each do |home|
22
+ gem_root = File.join(home.to_s, "gems", "#{lock_entry.source_gem}-#{lock_entry.source_version}")
23
+ body = read_candidate(gem_root, relpath.to_s, kind: kind, resolved: resolved)
24
+ next unless body
25
+
26
+ ready = install_ready(body, kind: kind, final_name: final_name)
27
+ return ready if DriftVerdict.body_sha(ready) == lock_entry.source_sha
28
+ rescue StandardError
29
+ next
30
+ end
31
+ nil
32
+ rescue StandardError
33
+ nil
34
+ end
35
+
36
+ def read_candidate(gem_root, relpath, kind:, resolved:)
37
+ exact = File.join(gem_root, relpath)
38
+ if File.file?(exact)
39
+ return kind.to_s == "skill_support" ? File.binread(exact) : File.read(exact)
40
+ end
41
+ return nil unless relpath.end_with?(".md")
42
+
43
+ twin = "#{exact}.erb"
44
+ return nil unless File.file?(twin)
45
+
46
+ map = resolved || resolved_bundle
47
+ return nil unless map
48
+ SkillTemplate.render(File.read(twin), resolved: map)
49
+ end
50
+
51
+ def install_ready(body, kind:, final_name:)
52
+ case kind.to_s
53
+ when "skill"
54
+ ready = BundlerArtifactDiscovery.install_ready_body(
55
+ BundlerArtifactDiscovery::Artifact.new(artifact_type: :skill, body: body)
56
+ )
57
+ final_name ? ready.sub(/^name:\s*.+$/, "name: #{final_name}") : ready
58
+ when "guideline"
59
+ BundlerArtifactDiscovery.install_ready_body(
60
+ BundlerArtifactDiscovery::Artifact.new(artifact_type: :guideline, body: body)
61
+ )
62
+ else
63
+ body
64
+ end
65
+ end
66
+
67
+ def resolved_bundle
68
+ ::Bundler.load.specs.to_a.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ private_class_method :read_candidate, :install_ready, :resolved_bundle
74
+ end
75
+ end
76
+ end
@@ -15,7 +15,7 @@ module Rails
15
15
  case state
16
16
  when :missing then "#{path} (from #{bundle_source})"
17
17
  when :outdated then "#{path} (#{locked_source} → #{bundle_source})"
18
- when :orphaned then "#{path} (source #{locked_source} no longer in bundle)"
18
+ when :orphaned then "#{path} (no longer shipped by #{locked_source})"
19
19
  else path
20
20
  end
21
21
  end
@@ -4,6 +4,7 @@ require "rails/hyperdrive/bundler_artifact_discovery"
4
4
  require "rails/hyperdrive/install_layout"
5
5
  require "rails/hyperdrive/install_pipeline"
6
6
  require "rails/hyperdrive/install_shell"
7
+ require "rails/hyperdrive/lock_file"
7
8
 
8
9
  module Rails
9
10
  module Hyperdrive
@@ -46,7 +47,8 @@ module Rails
46
47
  return skip(:not_development) unless development?(env)
47
48
  return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
48
49
 
49
- artifacts = BundlerArtifactDiscovery.discover
50
+ enabled = LockFile.load(File.join(root, InstallLayout::LOCK_PATH)).enabled_gems
51
+ artifacts = BundlerArtifactDiscovery.discover(enabled_gems: enabled)
50
52
  status = ArtifactStatus.compare(root: root, artifacts: artifacts)
51
53
 
52
54
  installed = []
@@ -1,5 +1,6 @@
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
@@ -10,6 +11,7 @@ module Rails
10
11
  Artifact = Struct.new(
11
12
  :name, :description, :target_gem, :versions, :artifact_type,
12
13
  :source_gem, :path, :body, :spec_version, :support_files,
14
+ :source_root, :support_root,
13
15
  keyword_init: true
14
16
  ) do
15
17
  def skill?
@@ -32,17 +34,30 @@ module Rails
32
34
  module_function
33
35
 
34
36
  # Non-fatal problems are appended to `warnings` and the artifact is
35
- # dropped; discovery never raises.
36
- 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: [])
37
41
  specs ||= safe_bundler_specs
42
+ enabled = Array(enabled_gems).map(&:to_s)
38
43
  resolved = specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
39
44
 
40
45
  candidates = []
41
46
  specs.each do |spec|
42
- each_artifact_path(spec, warnings: warnings) do |path, type|
43
- 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)
44
58
  candidates << artifact if artifact
45
59
  end
60
+ warn_unknown_manifest_keys(manifest, spec, seen, warnings)
46
61
  end
47
62
 
48
63
  # Collapse same-name variants within one source gem (highest
@@ -53,22 +68,97 @@ module Rails
53
68
  end
54
69
  end
55
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.
56
75
  def each_artifact_path(spec, warnings: [])
57
- skill_paths(spec, warnings: warnings).each { |p| yield p, :skill }
58
- 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
59
89
  end
60
90
 
61
91
  def skill_paths(spec, warnings: [])
62
- 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
+ ]
63
96
  if (override = skills_dir_override(spec))
64
97
  roots << File.join(spec.full_gem_path, override)
65
98
  end
66
- found = roots.flat_map { |root| Dir.glob(File.join(root, "**", "{SKILL.md,SKILL.md.erb}")) }.uniq
67
- found.group_by { |p| File.dirname(p) }.flat_map do |dir, group|
68
- next group unless group.size > 1
69
- warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
70
- 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
71
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
152
+ end
153
+ end
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"
72
162
  end
73
163
 
74
164
  def guideline_paths(spec)
@@ -76,6 +166,39 @@ module Rails
76
166
  Dir.glob(File.join(root, "*.md"))
77
167
  end
78
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
+
79
202
  # ".." segments are rejected to prevent escaping the gem root.
80
203
  def skills_dir_override(spec)
81
204
  return nil unless spec.respond_to?(:metadata)
@@ -85,7 +208,19 @@ module Rails
85
208
  raw.to_s
86
209
  end
87
210
 
88
- 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)
89
224
  body = File.read(path)
90
225
  if erb_template?(path)
91
226
  begin
@@ -101,25 +236,20 @@ module Rails
101
236
  return nil
102
237
  end
103
238
 
104
- 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]) || {}
105
242
  name = meta["name"]
106
243
  description = meta["description"]
107
- versions = meta["versions"]
108
-
109
- unless name && description && meta["gem"] && versions
110
- warnings << "skip #{path}: missing a required field (name, description, gem, versions)"
111
- return nil
112
- end
113
244
 
114
- targets = parse_targets(meta["gem"])
115
- if targets.nil? || targets.empty?
116
- 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)"
117
247
  return nil
118
248
  end
119
249
 
120
- matched = match_targets(targets, versions, resolved)
250
+ matched = match_targets(gate.targets, gate.versions, resolved)
121
251
  if matched.empty?
122
- 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)}"
123
253
  return nil
124
254
  end
125
255
 
@@ -127,16 +257,18 @@ module Rails
127
257
  name: name.to_s,
128
258
  description: description.to_s,
129
259
  target_gem: matched,
130
- versions: versions,
260
+ versions: gate.versions,
131
261
  artifact_type: type,
132
262
  source_gem: source_spec.name.to_s,
133
263
  path: path,
134
264
  body: body,
135
265
  spec_version: source_spec.version.to_s,
266
+ source_root: source_spec.full_gem_path.to_s,
267
+ support_root: support_root,
136
268
  support_files:
137
269
  if type == :skill
138
270
  conditioned_support_files(
139
- File.dirname(path), meta["conditional"],
271
+ support_root, gate.conditional,
140
272
  resolved: resolved, warnings: warnings,
141
273
  label: "#{name} (from #{source_spec.name})"
142
274
  )
@@ -144,7 +276,7 @@ module Rails
144
276
  []
145
277
  end
146
278
  )
147
- rescue Psych::SyntaxError
279
+ rescue Psych::Exception
148
280
  warnings << "skip #{path}: malformed YAML frontmatter"
149
281
  nil
150
282
  end
@@ -182,7 +314,7 @@ module Rails
182
314
  shipped = files.map { |f| f[:path] }
183
315
  conditions.each_key do |key|
184
316
  if SKILL_FILE_NAMES.include?(key)
185
- 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"
186
318
  elsif !shipped.include?(key)
187
319
  warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
188
320
  end
@@ -200,14 +332,14 @@ module Rails
200
332
  return true
201
333
  end
202
334
 
203
- targets = entry["gem"] && parse_targets(entry["gem"])
335
+ targets = entry["gem"] && GemManifest.parse_targets(entry["gem"])
204
336
  if targets.nil? || targets.empty?
205
337
  warnings << "#{label}: conditional entry for '#{key}' needs gem: naming a gem, a comma-separated list, or a YAML list; installing the file"
206
338
  return true
207
339
  end
208
340
 
209
341
  versions = entry["versions"]
210
- if malformed_requirements?(versions)
342
+ if GemManifest.malformed_requirements?(versions)
211
343
  warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
212
344
  return true
213
345
  end
@@ -215,20 +347,6 @@ module Rails
215
347
  match_targets(targets, versions, resolved).any?
216
348
  end
217
349
 
218
- # versions: is optional in a conditional entry; nil means unconstrained.
219
- def malformed_requirements?(versions)
220
- requirements = versions.is_a?(Hash) ? versions.values : [versions]
221
- requirements.compact.any? do |req|
222
- parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
223
- begin
224
- Gem::Requirement.new(*parts)
225
- false
226
- rescue ArgumentError
227
- true
228
- end
229
- end
230
- end
231
-
232
350
  def render_support_templates(files, skill_dir, resolved:, warnings:)
233
351
  plain_paths = files.map { |f| f[:path] }
234
352
  files.filter_map do |file|
@@ -253,6 +371,8 @@ module Rails
253
371
  path.end_with?(".md.erb")
254
372
  end
255
373
 
374
+ # Guideline frontmatter is stripped because the installed file is
375
+ # @-included eagerly into agent context, where it would be inert noise.
256
376
  def install_ready_body(artifact)
257
377
  return artifact.body if artifact.skill?
258
378
 
@@ -271,12 +391,6 @@ module Rails
271
391
  [lines[1...absolute_closing].join, lines[(absolute_closing + 1)..].join]
272
392
  end
273
393
 
274
- def parse_targets(raw)
275
- entries = raw.is_a?(Array) ? raw : [raw]
276
- return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
277
- entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
278
- end
279
-
280
394
  def match_targets(targets, versions, resolved)
281
395
  return ["*"] if targets.include?("*")
282
396
 
@@ -308,12 +422,15 @@ module Rails
308
422
  []
309
423
  end
310
424
 
311
- private_class_method :each_artifact_path, :skill_paths, :guideline_paths,
312
- :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,
313
430
  :conditioned_support_files, :apply_conditional_filter,
314
- :conditional_satisfied?, :malformed_requirements?,
431
+ :conditional_satisfied?,
315
432
  :render_support_templates, :erb_template?,
316
- :split_frontmatter, :parse_targets, :match_targets,
433
+ :match_targets,
317
434
  :version_satisfied?, :no_match_reason, :safe_bundler_specs
318
435
  end
319
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
@@ -1,11 +1,9 @@
1
1
  require "digest"
2
- require "rails/hyperdrive/audit_header"
3
2
 
4
3
  module Rails
5
4
  module Hyperdrive
6
- # The lock's source_sha is computed over the install-ready body before any
7
- # header injection, so hashing an installed file's comparable bytes
8
- # (disk_sha) reproduces it exactly for an unedited file.
5
+ # Installed files are byte-identical to their install-ready body, so an
6
+ # unedited file's disk hash reproduces the lock's source_sha exactly.
9
7
  module DriftVerdict
10
8
  STATES = %i[current outdated edited missing orphaned].freeze
11
9
 
@@ -16,25 +14,22 @@ module Rails
16
14
  Digest::SHA256.hexdigest(content.to_s)
17
15
  end
18
16
 
19
- # AuditHeader.strip's line matching can raise on binary content, so
20
- # skill_support files compare as raw bytes.
21
- def disk_sha(file, kind:)
22
- return body_sha(File.binread(file)) if kind.to_s == "skill_support"
23
- body_sha(AuditHeader.strip(File.read(file)))
17
+ def disk_sha(file)
18
+ body_sha(File.binread(file))
24
19
  end
25
20
 
26
- # A file counts as unedited when its comparable bytes still hash to what
27
- # the lock recorded at install time, not to what the gem ships now.
21
+ # A file counts as unedited when its bytes still hash to what the lock
22
+ # recorded at install time, not to what the gem ships now.
28
23
  def unedited?(file, lock_entry:)
29
- disk_sha(file, kind: lock_entry.kind) == lock_entry.source_sha
24
+ disk_sha(file) == lock_entry.source_sha
30
25
  end
31
26
 
32
27
  # gem_sha nil means the bundle no longer offers this destination.
33
- def verdict(file:, kind:, lock_entry:, gem_sha:)
28
+ def verdict(file:, lock_entry:, gem_sha:)
34
29
  return File.exist?(file) ? :orphaned : :missing if gem_sha.nil?
35
30
  return :missing unless File.exist?(file)
36
31
  return :edited if lock_entry.nil?
37
- return :edited if disk_sha(file, kind: kind) != lock_entry.source_sha
32
+ return :edited if disk_sha(file) != lock_entry.source_sha
38
33
  lock_entry.source_sha == gem_sha ? :current : :outdated
39
34
  end
40
35
  end