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.
@@ -0,0 +1,39 @@
1
+ # Rake tasks for companion gem repos: add `require "hyperdrive/skill_tasks"`
2
+ # to the Rakefile. Every task takes 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
+ require "rails/hyperdrive/manifest_lint"
7
+
8
+ namespace :hyperdrive do
9
+ namespace :skills do
10
+ desc "Render each SKILL.md.erb template to its canonical static SKILL.md"
11
+ task :render, [:gemspec] do |_t, args|
12
+ written = Rails::Hyperdrive::CanonicalSkillRender.write(gemspec: args[:gemspec])
13
+ written.each { |r| puts "render #{r.dest}" }
14
+ puts "no skill templates found" if written.empty?
15
+ end
16
+
17
+ desc "Fail when a canonical skill file is stale, or raw ERB sits under a public skills root"
18
+ task :check, [:gemspec] do |_t, args|
19
+ roots = Rails::Hyperdrive::CanonicalSkillRender.resolve_roots(gemspec: args[:gemspec])
20
+ stale = Rails::Hyperdrive::CanonicalSkillRender.stale(roots: roots)
21
+ raw = Rails::Hyperdrive::CanonicalSkillRender.public_erb_templates(roots: roots)
22
+ stale.each { |r| warn "stale: #{r.dest}" }
23
+ raw.each { |p| warn "raw ERB: #{p}" }
24
+ abort "#{stale.size} canonical skill file(s) stale; run `rake hyperdrive:skills:render`" unless stale.empty?
25
+ abort "#{raw.size} ERB template(s) under a public skills root; move them to the template directory" unless raw.empty?
26
+ puts "canonical skill files up to date"
27
+ end
28
+ end
29
+
30
+ namespace :manifest do
31
+ desc "Fail on unknown keys, unparsable gating, or entries naming nothing shipped in hyperdrive.yml"
32
+ task :check, [:gemspec] do |_t, args|
33
+ result = Rails::Hyperdrive::ManifestLint.check(gemspec: args[:gemspec])
34
+ result.problems.each { |p| warn "#{result.manifest}: #{p}" }
35
+ abort "#{result.problems.size} problem(s) in #{result.manifest}" unless result.problems.empty?
36
+ puts result.manifest ? "#{result.manifest} is clean" : "no hyperdrive.yml; nothing to lint"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,59 @@
1
+ require "rails/command"
2
+ require "rails/generators"
3
+ require "generators/hyperdrive/install/install_generator"
4
+ require "generators/hyperdrive/sync/sync_generator"
5
+ require "generators/hyperdrive/discover/discover_generator"
6
+
7
+ module Rails
8
+ module Command
9
+ # The generators are the sole authority on option parsing, defaults, and
10
+ # validation; this class only forwards argv and renders `--help`.
11
+ class HyperdriveCommand < Base
12
+ namespace "hyperdrive"
13
+
14
+ # Mirrors a generator's own flags so the help surface cannot drift from
15
+ # what the generator actually accepts. Thor's runtime options are
16
+ # excluded: they are inherited plumbing, not part of this command.
17
+ def self.mirror_generator_options(klass)
18
+ klass.class_options.each do |name, option|
19
+ next if ::Rails::Generators::Base.class_options.key?(name)
20
+ method_options[name] = option
21
+ end
22
+ end
23
+
24
+ # Thor treats a leading `--` as an options terminator, so with `-- --merge`
25
+ # the flag lands in positional args and `options[:merge]` is false. Both
26
+ # spellings must reach the generator identically.
27
+ def initialize(args = [], local_options = {}, config = {})
28
+ super
29
+ @argv = args + (local_options.is_a?(Array) ? local_options : [])
30
+ @argv = @argv.drop(1) if @argv.first == "--"
31
+ end
32
+
33
+ desc "init", "Install Rails Hyperdrive into this app (writes .mcp.json, CLAUDE.md, companion content, mounts engine)"
34
+ mirror_generator_options ::Rails::Generators::Hyperdrive::InstallGenerator
35
+ def init(*)
36
+ start_generator("install", "InstallGenerator")
37
+ end
38
+
39
+ desc "sync", "Sync Rails Hyperdrive content (skills, guidelines, agents, commands, index.md, lockfile); locally-edited files are preserved"
40
+ mirror_generator_options ::Rails::Generators::Hyperdrive::SyncGenerator
41
+ def sync(*)
42
+ start_generator("sync", "SyncGenerator")
43
+ end
44
+
45
+ desc "discover", "Suggest uninstalled rails-hyperdrive companion gems for this app's stack (networked, cached)"
46
+ mirror_generator_options ::Rails::Generators::Hyperdrive::DiscoverGenerator
47
+ def discover(*)
48
+ start_generator("discover", "DiscoverGenerator")
49
+ end
50
+
51
+ no_commands do
52
+ def start_generator(name, generator)
53
+ require_application!
54
+ ::Rails::Generators::Hyperdrive.const_get(generator).start(@argv)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,6 +1,7 @@
1
1
  require "bundler"
2
2
  require "rails/hyperdrive/bundler_artifact_discovery"
3
3
  require "rails/hyperdrive/drift_verdict"
4
+ require "rails/hyperdrive/install_layout"
4
5
  require "rails/hyperdrive/skill_template"
5
6
 
6
7
  module Rails
@@ -49,19 +50,15 @@ module Rails
49
50
  end
50
51
 
51
52
  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
53
+ type = InstallLayout::ARTIFACT_TYPES[kind.to_s]
54
+ descriptor = type && InstallLayout.kind(type)
55
+ return body unless descriptor
56
+
57
+ ready = BundlerArtifactDiscovery.install_ready_body(
58
+ BundlerArtifactDiscovery::Artifact.new(artifact_type: type, body: body)
59
+ )
60
+ return ready unless final_name && descriptor.collision_rewrites_name
61
+ ready.sub(/^name:\s*.+$/, "name: #{final_name}")
65
62
  end
66
63
 
67
64
  def resolved_bundle
@@ -10,26 +10,34 @@ module Rails
10
10
  class ArtifactStatus
11
11
  STATES = %i[installed missing outdated orphaned].freeze
12
12
 
13
- Entry = Struct.new(:path, :state, :artifact, :locked_source, :bundle_source, keyword_init: true) do
13
+ Entry = Struct.new(:path, :state, :artifact, :locked_source, :bundle_source, :source_gem, :source_bundled,
14
+ keyword_init: true) do
14
15
  def to_s
15
16
  case state
16
17
  when :missing then "#{path} (from #{bundle_source})"
17
18
  when :outdated then "#{path} (#{locked_source} → #{bundle_source})"
18
- when :orphaned then "#{path} (no longer shipped by #{locked_source})"
19
+ when :orphaned then "#{path} (#{orphan_reason})"
19
20
  else path
20
21
  end
21
22
  end
23
+
24
+ private
25
+
26
+ def orphan_reason
27
+ LockFile.orphan_reason(source_label: locked_source, source_gem: source_gem, bundled: source_bundled)
28
+ end
22
29
  end
23
30
 
24
- def self.compare(root:, artifacts:)
25
- new(root: root, artifacts: artifacts).tap(&:compare)
31
+ def self.compare(root:, artifacts:, bundled_gems: [])
32
+ new(root: root, artifacts: artifacts, bundled_gems: bundled_gems).tap(&:compare)
26
33
  end
27
34
 
28
35
  attr_reader :entries
29
36
 
30
- def initialize(root:, artifacts:)
37
+ def initialize(root:, artifacts:, bundled_gems: [])
31
38
  @root = File.expand_path(root.to_s)
32
39
  @artifacts = artifacts
40
+ @bundled_gems = Array(bundled_gems).map(&:to_s)
33
41
  @entries = []
34
42
  end
35
43
 
@@ -63,11 +71,12 @@ module Rails
63
71
  # A disabled artifact left on disk was reported at install time; the
64
72
  # bundle still ships it, so it is not an orphan.
65
73
  type = InstallLayout::ARTIFACT_TYPES[locked.kind]
66
- next if type && InstallPlan.disabled_dest?(lock, type, locked.path)
74
+ next if type && InstallPlan.disabled_dest?(lock, type, locked.path, source_gem: locked.source_gem)
67
75
 
68
76
  @entries << Entry.new(
69
77
  path: locked.path, state: :orphaned, artifact: locked.kind&.to_sym,
70
- locked_source: locked.source_label, bundle_source: nil
78
+ locked_source: locked.source_label, bundle_source: nil,
79
+ source_gem: locked.source_gem, source_bundled: @bundled_gems.include?(locked.source_gem.to_s)
71
80
  )
72
81
  end
73
82
 
@@ -13,7 +13,8 @@ module Rails
13
13
  module AutoInstall
14
14
  DEVELOPMENT = "development".freeze
15
15
 
16
- Result = Struct.new(:installed, :outdated, :orphaned, :unwired, :skipped, :error, keyword_init: true) do
16
+ Result = Struct.new(:installed, :outdated, :orphaned, :unwired, :advisories, :fence_warnings,
17
+ :halted, :skipped, :error, keyword_init: true) do
17
18
  def ran?
18
19
  skipped.nil?
19
20
  end
@@ -24,6 +25,8 @@ module Rails
24
25
 
25
26
  def messages
26
27
  return [] unless ran?
28
+ return [halted] if halted
29
+
27
30
  lines = []
28
31
  unless Array(installed).empty?
29
32
  lines << "installed #{installed.size} artifact(s):"
@@ -35,6 +38,10 @@ module Rails
35
38
  lines << "#{stale.size} artifact(s) need attention — run bin/rails hyperdrive:sync"
36
39
  stale.each { |entry| lines << " #{entry}" }
37
40
  end
41
+ # An advisory names content that installed but not as the manifest
42
+ # asked; ordinary skips are init/sync's to report, not a bundle
43
+ # install's.
44
+ (Array(advisories) | Array(fence_warnings)).each { |warning| lines << warning }
38
45
  lines
39
46
  end
40
47
  end
@@ -47,9 +54,12 @@ module Rails
47
54
  return skip(:not_development) unless development?(env)
48
55
  return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
49
56
 
50
- enabled = LockFile.load(File.join(root, InstallLayout::LOCK_PATH)).enabled_gems
51
- artifacts = BundlerArtifactDiscovery.discover(enabled_gems: enabled)
52
- status = ArtifactStatus.compare(root: root, artifacts: artifacts)
57
+ lock = LockFile.load(File.join(root, InstallLayout::LOCK_PATH))
58
+ return halted(lock.schema_ahead_message(InstallLayout::LOCK_PATH)) if lock.schema_ahead?
59
+
60
+ report = BundlerArtifactDiscovery::Report.new
61
+ artifacts = BundlerArtifactDiscovery.discover(enabled_gems: lock.enabled_gems, report: report)
62
+ status = ArtifactStatus.compare(root: root, artifacts: artifacts, bundled_gems: report.bundled_gems)
53
63
 
54
64
  installed = []
55
65
  unwired = false
@@ -59,7 +69,8 @@ module Rails
59
69
  root: root,
60
70
  shell: InstallShell.new(root: root, io: io),
61
71
  artifacts: artifacts,
62
- mode: :additive
72
+ mode: :additive,
73
+ report: report
63
74
  )
64
75
  installed = pipeline.call.installed
65
76
  # This runs from a bundle install, which must not edit CLAUDE.md, so a
@@ -68,7 +79,8 @@ module Rails
68
79
  unwired = pipeline.lock.claude_md_state.nil? && (installed & pipeline.lock.guideline_paths).any?
69
80
  end
70
81
 
71
- Result.new(installed: installed, outdated: status.outdated, orphaned: status.orphaned, unwired: unwired)
82
+ Result.new(installed: installed, outdated: status.outdated, orphaned: status.orphaned,
83
+ unwired: unwired, advisories: report.advisories, fence_warnings: report.fence_warnings)
72
84
  rescue StandardError => e
73
85
  skip(:error, e)
74
86
  end
@@ -93,7 +105,13 @@ module Rails
93
105
  end
94
106
 
95
107
  def skip(reason, error = nil)
96
- Result.new(installed: [], outdated: [], orphaned: [], skipped: reason, error: error)
108
+ Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
109
+ skipped: reason, error: error)
110
+ end
111
+
112
+ # A completed run that installed nothing and has a reason to print.
113
+ def halted(reason)
114
+ Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [], halted: reason)
97
115
  end
98
116
  end
99
117
  end