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,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
@@ -20,6 +20,10 @@ module Rails
20
20
  end
21
21
  end
22
22
 
23
+ def sidecar_path(dest)
24
+ "#{dest}.new"
25
+ end
26
+
23
27
  def installed_name(type, dest)
24
28
  case type
25
29
  when :skill then File.basename(File.dirname(dest))
@@ -1,8 +1,9 @@
1
1
  require "time"
2
- require "rails/hyperdrive/audit_header"
2
+ require "rails/hyperdrive/ancestor_locator"
3
3
  require "rails/hyperdrive/bundler_artifact_discovery"
4
4
  require "rails/hyperdrive/claude_md_import"
5
5
  require "rails/hyperdrive/drift_verdict"
6
+ require "rails/hyperdrive/three_way_merge"
6
7
  require "rails/hyperdrive/eager_footprint"
7
8
  require "rails/hyperdrive/index_document"
8
9
  require "rails/hyperdrive/install_layout"
@@ -20,11 +21,12 @@ module Rails
20
21
  WARN_LINES = 150
21
22
  WARN_TOKENS = 1_500
22
23
 
23
- MODES = %i[preserve overwrite additive].freeze
24
+ MODES = %i[preserve overwrite additive sidecar merge].freeze
24
25
 
25
- Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, keyword_init: true)
26
+ Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, :merged, :sidecars,
27
+ keyword_init: true)
26
28
 
27
- def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [])
29
+ def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [], notices: [])
28
30
  raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
29
31
 
30
32
  @root = File.expand_path(root.to_s)
@@ -32,7 +34,10 @@ module Rails
32
34
  @artifacts = artifacts
33
35
  @mode = mode
34
36
  @warnings = warnings
35
- @result = Result.new(installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [])
37
+ @notices = notices
38
+ @result = Result.new(
39
+ installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [], merged: [], sidecars: []
40
+ )
36
41
  end
37
42
 
38
43
  def call
@@ -50,6 +55,7 @@ module Rails
50
55
  write_claude_md(guidelines)
51
56
  write_lock
52
57
  print_warnings
58
+ print_notices
53
59
  print_footprint(guidelines: eager_guidelines)
54
60
  warn_if_destinations_gitignored
55
61
  @result
@@ -81,6 +87,14 @@ module Rails
81
87
  @mode == :overwrite
82
88
  end
83
89
 
90
+ def merge_mode?
91
+ @mode == :merge
92
+ end
93
+
94
+ def reconcile_mode?
95
+ @mode == :sidecar || @mode == :merge
96
+ end
97
+
84
98
  def abs(path)
85
99
  File.join(@root, path)
86
100
  end
@@ -102,7 +116,10 @@ module Rails
102
116
 
103
117
  def install_skills
104
118
  @plan.select { |e| e.type == :skill }.each do |entry|
105
- install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body)
119
+ skill_relpath = source_relpath_for(entry.artifact)
120
+ support_base = support_relpath_base(entry.artifact, skill_relpath)
121
+ install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body,
122
+ source_relpath: skill_relpath, final_name: entry.final_name)
106
123
  entry.support_files.each do |file|
107
124
  install_file(
108
125
  dest: file[:dest],
@@ -110,7 +127,8 @@ module Rails
110
127
  install_ready_body: file[:body],
111
128
  source_gem: entry.source_gem,
112
129
  version: entry.version,
113
- artifact_kind: "skill_support"
130
+ artifact_kind: "skill_support",
131
+ source_relpath: support_base && File.join(support_base, file[:path])
114
132
  )
115
133
  end
116
134
  end
@@ -120,12 +138,44 @@ module Rails
120
138
  @plan.select { |e| e.type == :guideline }.map do |entry|
121
139
  body = entry.install_ready_body
122
140
  warn_if_oversize(entry.dest, body)
123
- install_file(entry: entry, type: :guideline, install_ready_body: body)
141
+ install_file(entry: entry, type: :guideline, install_ready_body: body,
142
+ source_relpath: source_relpath_for(entry.artifact), final_name: entry.final_name)
124
143
  { base: "#{entry.final_name}.md", dest: entry.dest, body: body }
125
144
  end
126
145
  end
127
146
 
128
- def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil, artifact_kind: nil)
147
+ # The shipped file's path relative to its gem root, normalized to the
148
+ # install-target form (.md.erb -> .md).
149
+ def source_relpath_for(artifact)
150
+ root = artifact.source_root
151
+ return nil if root.nil? || root.to_s.empty?
152
+
153
+ prefix = "#{File.expand_path(root.to_s)}/"
154
+ expanded = File.expand_path(artifact.path.to_s)
155
+ return nil unless expanded.start_with?(prefix)
156
+
157
+ rel = expanded.delete_prefix(prefix)
158
+ rel.end_with?(".md.erb") ? rel.delete_suffix(".erb") : rel
159
+ end
160
+
161
+ # Supporting files live under the artifact's support root, which for a
162
+ # template-paired skill is not the definition file's directory.
163
+ def support_relpath_base(artifact, skill_relpath)
164
+ support_root = artifact.support_root
165
+ return skill_relpath && File.dirname(skill_relpath) if support_root.nil? || support_root.to_s.empty?
166
+
167
+ root = artifact.source_root
168
+ return nil if root.nil? || root.to_s.empty?
169
+
170
+ prefix = "#{File.expand_path(root.to_s)}/"
171
+ expanded = File.expand_path(support_root.to_s)
172
+ return nil unless expanded.start_with?(prefix)
173
+
174
+ expanded.delete_prefix(prefix)
175
+ end
176
+
177
+ def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil,
178
+ artifact_kind: nil, source_relpath: nil, final_name: nil)
129
179
  write = {
130
180
  dest: dest || entry.dest,
131
181
  type: type,
@@ -140,25 +190,152 @@ module Rails
140
190
  return additive_install(write, old) if additive?
141
191
 
142
192
  file = abs(write[:dest])
143
- case DriftVerdict.verdict(file: file, kind: write[:artifact_kind], lock_entry: old, gem_sha: write[:gem_sha])
193
+ case DriftVerdict.verdict(file: file, lock_entry: old, gem_sha: write[:gem_sha])
144
194
  when :missing
145
195
  @shell.say_status(:reinstall, "#{write[:dest]} (was missing)", :yellow) if old
146
196
  write_artifact(**write)
197
+ sweep_sidecar(write, old)
147
198
  when :current
148
199
  @new_lock.carry(old)
149
200
  @result.unchanged << write[:dest]
150
201
  @shell.say_status :unchanged, write[:dest], :blue
202
+ sweep_sidecar(write, old)
151
203
  when :outdated
152
204
  write_artifact(**write)
205
+ sweep_sidecar(write, old)
153
206
  when :edited
154
207
  if overwrite_mode?
155
208
  write_artifact(**write)
209
+ sweep_sidecar(write, old)
210
+ elsif reconcile_mode?
211
+ reconcile_edited(write, old, source_relpath: source_relpath, final_name: final_name)
212
+ else
213
+ skip_edited(write, old)
214
+ end
215
+ end
216
+ end
217
+
218
+ def skip_edited(write, old)
219
+ @result.skipped << write[:dest]
220
+ @shell.say_status :skip,
221
+ "#{write[:dest]} (locally modified; run hyperdrive:sync with --merge, --sidecar, or --overwrite to reconcile)",
222
+ :yellow
223
+ @new_lock.carry(old) if old
224
+ end
225
+
226
+ # Reconciliation for a locally-modified destination in :sidecar/:merge
227
+ # mode. The lock records the newest upstream *delivered* — installed
228
+ # live, merged in, or written as a sidecar — never the live file's own
229
+ # hash, so the same upstream version is offered exactly once.
230
+ def reconcile_edited(write, old, source_relpath:, final_name:)
231
+ dest = write[:dest]
232
+ sidecar = InstallLayout.sidecar_path(dest)
233
+
234
+ if old && old.source_sha == write[:gem_sha]
235
+ skip_edited(write, old)
236
+ if File.exist?(abs(sidecar))
237
+ @shell.say_status :warn, "#{sidecar} (unresolved sidecar; reconcile it with #{dest}, then delete it)", :yellow
238
+ end
239
+ return
240
+ end
241
+
242
+ # An edited sidecar is user work: leave it, and leave the lock at the
243
+ # old upstream so this delivery is re-offered once the user clears it.
244
+ if File.exist?(abs(sidecar)) && !sidecar_pristine?(sidecar, write, old)
245
+ @result.skipped << dest
246
+ @shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
247
+ @new_lock.carry(old) if old
248
+ return
249
+ end
250
+
251
+ reason = nil
252
+ if merge_mode?
253
+ if File.exist?(abs(sidecar))
254
+ # A pending sidecar means the last delivered upstream never reached
255
+ # the live file, so the reconstructable ancestor is stale; merging
256
+ # over it would silently revert that delivery.
257
+ reason = "previous delivery still unresolved"
156
258
  else
157
- @result.skipped << write[:dest]
158
- @shell.say_status :skip, "#{write[:dest]} (locally modified; run hyperdrive:sync --overwrite to overwrite)", :yellow
159
- @new_lock.carry(old) if old
259
+ merged, reason = attempt_merge(write, old, source_relpath: source_relpath, final_name: final_name)
260
+ if merged
261
+ write_merged(write, merged)
262
+ sweep_sidecar(write, old)
263
+ return
264
+ end
160
265
  end
161
266
  end
267
+
268
+ write_sidecar(write, reason)
269
+ end
270
+
271
+ def attempt_merge(write, old, source_relpath:, final_name:)
272
+ return [nil, "no previous install recorded"] unless old
273
+
274
+ ancestor = AncestorLocator.locate(
275
+ kind: write[:artifact_kind], relpath: source_relpath, lock_entry: old, final_name: final_name
276
+ )
277
+ return [nil, "#{old.source_label} not found in installed gems"] unless ancestor
278
+
279
+ ours = live_body(write)
280
+ if ours.nil? || [ours, ancestor, write[:body]].any? { |b| ThreeWayMerge.binary?(b) }
281
+ return [nil, "binary content"]
282
+ end
283
+
284
+ outcome = ThreeWayMerge.merge(ours: ours, base: ancestor, theirs: write[:body])
285
+ case outcome.status
286
+ when :clean then [outcome.body, nil]
287
+ when :conflicted then [nil, "conflicting edits"]
288
+ else [nil, "git merge-file unavailable"]
289
+ end
290
+ end
291
+
292
+ def live_body(write)
293
+ file = abs(write[:dest])
294
+ return File.binread(file) if write[:type] == :skill_support
295
+ File.read(file)
296
+ rescue StandardError
297
+ nil
298
+ end
299
+
300
+ def write_merged(write, merged_body)
301
+ @shell.create_file write[:dest], merged_body
302
+ @result.merged << write[:dest]
303
+ @shell.say_status :merged, "#{write[:dest]} (local edits merged with #{write[:source_gem]}@#{write[:version]})", :green
304
+ upsert_lock(write, Time.now.utc)
305
+ end
306
+
307
+ # The sidecar is byte-identical to what a live install of the new
308
+ # upstream would write, so `mv <dest>.new <dest>` accepts it wholesale
309
+ # and the lock already verifies it.
310
+ def write_sidecar(write, reason)
311
+ sidecar = InstallLayout.sidecar_path(write[:dest])
312
+ @shell.create_file sidecar, write[:body]
313
+ @result.sidecars << write[:dest]
314
+ message = "#{write[:dest]} (locally modified; new upstream delivered to #{sidecar}"
315
+ message += "; #{reason}" if reason
316
+ @shell.say_status :sidecar, "#{message})", :yellow
317
+ upsert_lock(write, Time.now.utc)
318
+ end
319
+
320
+ # Only a machine-pristine sidecar — one still hashing to a delivered
321
+ # upstream — may be refreshed or removed; anything else is user work.
322
+ def sidecar_pristine?(sidecar, write, old)
323
+ sha = DriftVerdict.disk_sha(abs(sidecar))
324
+ [old&.source_sha, write[:gem_sha]].compact.include?(sha)
325
+ rescue StandardError
326
+ false
327
+ end
328
+
329
+ def sweep_sidecar(write, old)
330
+ sidecar = InstallLayout.sidecar_path(write[:dest])
331
+ return unless File.exist?(abs(sidecar))
332
+
333
+ if sidecar_pristine?(sidecar, write, old)
334
+ @shell.remove_file sidecar
335
+ @result.removed << sidecar
336
+ else
337
+ @shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
338
+ end
162
339
  end
163
340
 
164
341
  # Additive installs can create a file and never overwrite one, so an
@@ -174,24 +351,11 @@ module Rails
174
351
  end
175
352
  end
176
353
 
177
- # Supporting files land byte-identical to what the gem ships — no audit
178
- # header, since they may be non-markdown or binary; provenance and sha
179
- # live in the lock alone.
354
+ # Every artifact lands byte-identical to its install-ready body;
355
+ # provenance and sha live in the lock alone.
180
356
  def write_artifact(dest:, type:, body:, source_gem:, version:, gem_sha:, artifact_kind:)
181
- installed_at = Time.now.utc
182
- header_args = { source_gem: source_gem, version: version, sha256: gem_sha, installed_at: installed_at }
183
- on_disk =
184
- case type
185
- when :skill
186
- AuditHeader.inject_into_frontmatter(body, AuditHeader.build(**header_args))
187
- when :skill_support
188
- body
189
- else
190
- AuditHeader.prepend_html(body, AuditHeader.build_html(**header_args))
191
- end
192
-
193
357
  existed = old_lock.entry(dest)
194
- @shell.create_file dest, on_disk
358
+ @shell.create_file dest, body
195
359
  (existed ? @result.updated : @result.installed) << dest
196
360
  @new_lock.upsert(
197
361
  path: dest,
@@ -199,6 +363,17 @@ module Rails
199
363
  source_gem: source_gem,
200
364
  source_version: version,
201
365
  source_sha: gem_sha,
366
+ installed_at: Time.now.utc.iso8601
367
+ )
368
+ end
369
+
370
+ def upsert_lock(write, installed_at)
371
+ @new_lock.upsert(
372
+ path: write[:dest],
373
+ kind: write[:artifact_kind],
374
+ source_gem: write[:source_gem],
375
+ source_version: write[:version],
376
+ source_sha: write[:gem_sha],
202
377
  installed_at: installed_at.iso8601
203
378
  )
204
379
  end
@@ -284,11 +459,11 @@ module Rails
284
459
  old_lock.each_entry do |entry|
285
460
  next if planned.include?(entry.path)
286
461
  next if @new_lock.entry(entry.path)
287
- next unless DriftVerdict.verdict(file: abs(entry.path), kind: entry.kind, lock_entry: entry, gem_sha: nil) == :orphaned
462
+ next unless DriftVerdict.verdict(file: abs(entry.path), lock_entry: entry, gem_sha: nil) == :orphaned
288
463
 
289
464
  @result.orphaned << entry.path
290
465
  @shell.say_status :orphan,
291
- "#{entry.path} (source #{entry.source_label} no longer in bundle; left in place)", :yellow
466
+ "#{entry.path} (no longer shipped by #{entry.source_label}; left in place)", :yellow
292
467
  @new_lock.carry(entry)
293
468
  end
294
469
  end
@@ -368,6 +543,12 @@ module Rails
368
543
  @warnings.each { |w| @shell.say " - #{w}" }
369
544
  end
370
545
 
546
+ def print_notices
547
+ return if additive? || Array(@notices).empty?
548
+ @shell.say ""
549
+ @notices.each { |n| @shell.say_status :info, n, :blue }
550
+ end
551
+
371
552
  def print_footprint(guidelines:)
372
553
  return if additive?
373
554
 
@@ -33,6 +33,7 @@ module Rails
33
33
  @files = {} # path(String) => Entry
34
34
  @document = {} # raw parsed YAML, kept so unknown keys survive
35
35
  @disabled = empty_disabled
36
+ @enabled = []
36
37
  end
37
38
 
38
39
  def read
@@ -45,6 +46,7 @@ module Rails
45
46
  claude_md = data["claude_md"]
46
47
  @claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
47
48
  @disabled = parse_disabled(data["disabled"])
49
+ @enabled = parse_enabled(data["enabled"])
48
50
  Array(data["files"]).each do |raw|
49
51
  next unless raw.is_a?(Hash)
50
52
  entry = build_entry(raw)
@@ -71,11 +73,17 @@ module Rails
71
73
  Array(@disabled[type.to_sym]).include?(name.to_s)
72
74
  end
73
75
 
76
+ # Hand-editable list of gems the app opts into as companions.
77
+ def enabled_gems
78
+ @enabled
79
+ end
80
+
74
81
  # Adopt the state that is not derived from installed content, so
75
82
  # rewriting the file preserves it.
76
83
  def carry_settings(other)
77
84
  @document = other.document.dup
78
85
  @disabled = other.disabled_lists.dup
86
+ @enabled = other.enabled_gems.dup
79
87
  self
80
88
  end
81
89
 
@@ -103,6 +111,7 @@ module Rails
103
111
  "version" => SCHEMA_VERSION,
104
112
  "claude_md" => carried.merge("state" => @claude_md_state),
105
113
  "disabled" => DISABLED_KEYS.each_with_object({}) { |(type, key), h| h[key] = @disabled[type] },
114
+ "enabled" => @enabled,
106
115
  "files" => @files.values.sort_by(&:path).map { |e| serialize_entry(e) }
107
116
  )
108
117
  # A nil state means no import line is being managed. Recording one anyway
@@ -136,6 +145,12 @@ module Rails
136
145
  end
137
146
  end
138
147
 
148
+ def parse_enabled(raw)
149
+ return [] unless raw.is_a?(Array)
150
+
151
+ raw.map { |name| name.to_s.strip }.reject(&:empty?).uniq
152
+ end
153
+
139
154
  def build_entry(raw)
140
155
  source_gem, source_version = split_source(raw["source"])
141
156
  Entry.new(
@@ -14,7 +14,7 @@ module Rails
14
14
  ::MCP::Resource.new(
15
15
  uri: URI,
16
16
  name: "Stack Profile",
17
- description: "Parsed Gemfile.lock + categorization (rails, ruby, database, test, jobs, frontend, auth, authz, db_gems, gem_skills).",
17
+ description: "Parsed Gemfile.lock (rails, ruby, database, direct_dependencies, gem_skills).",
18
18
  mime_type: "application/json"
19
19
  )
20
20
  end
@@ -0,0 +1,27 @@
1
+ # Rake tasks for companion gem repos: add `require "rails/hyperdrive/skill_tasks"`
2
+ # to the Rakefile. Both tasks take an optional gemspec-path argument, e.g.
3
+ # rake "hyperdrive:skills:render[path/to/name.gemspec]".
4
+ require "rake"
5
+ require "rails/hyperdrive/canonical_skill_render"
6
+
7
+ namespace :hyperdrive do
8
+ namespace :skills do
9
+ desc "Render each SKILL.md.erb template to its canonical static SKILL.md"
10
+ task :render, [:gemspec] do |_t, args|
11
+ written = Rails::Hyperdrive::CanonicalSkillRender.write(gemspec: args[:gemspec])
12
+ written.each { |r| puts "render #{r.dest}" }
13
+ puts "no skill templates found" if written.empty?
14
+ end
15
+
16
+ desc "Fail when a canonical SKILL.md is stale relative to its template"
17
+ task :check, [:gemspec] do |_t, args|
18
+ stale = Rails::Hyperdrive::CanonicalSkillRender.stale(gemspec: args[:gemspec])
19
+ if stale.empty?
20
+ puts "canonical skill files up to date"
21
+ else
22
+ stale.each { |r| warn "stale: #{r.dest}" }
23
+ abort "#{stale.size} canonical skill file(s) stale; run `rake hyperdrive:skills:render`"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -31,6 +31,27 @@ module Rails
31
31
  end
32
32
  end
33
33
 
34
+ # Fail-open binding for the canonical render: every gem reads as present
35
+ # at any requested version, so the output includes every conditional
36
+ # branch. There is no bundle to resolve against, so gem_version is nil.
37
+ class CanonicalContext
38
+ def gem?(_name, _requirement = nil)
39
+ true
40
+ end
41
+
42
+ def any_gem?(*)
43
+ true
44
+ end
45
+
46
+ def gem_version(_name)
47
+ nil
48
+ end
49
+
50
+ def template_binding
51
+ binding
52
+ end
53
+ end
54
+
34
55
  module_function
35
56
 
36
57
  # Errors (ERB SyntaxError included) propagate; the caller decides how to
@@ -39,6 +60,10 @@ module Rails
39
60
  ERB.new(source, trim_mode: "-").result(Context.new(resolved).template_binding)
40
61
  end
41
62
 
63
+ def render_canonical(source)
64
+ ERB.new(source, trim_mode: "-").result(CanonicalContext.new.template_binding)
65
+ end
66
+
42
67
  # Gem::Requirement.new does not parse a single comma-separated string, so
43
68
  # such strings are split into separate constraints first.
44
69
  def version_matches?(requirement_str, version)