rails-hyperdrive 0.5.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -4
  3. data/README.md +71 -36
  4. data/lib/generators/hyperdrive/install/USAGE +11 -3
  5. data/lib/generators/hyperdrive/install/install_generator.rb +25 -10
  6. data/lib/generators/hyperdrive/install_summary.rb +3 -3
  7. data/lib/generators/hyperdrive/sync/USAGE +7 -5
  8. data/lib/generators/hyperdrive/sync/sync_generator.rb +1 -1
  9. data/lib/generators/hyperdrive/sync_runner.rb +20 -10
  10. data/lib/hyperdrive/skill_tasks.rb +39 -0
  11. data/lib/rails/commands/hyperdrive/hyperdrive_command.rb +59 -0
  12. data/lib/rails/hyperdrive/ancestor_locator.rb +10 -13
  13. data/lib/rails/hyperdrive/artifact_status.rb +16 -7
  14. data/lib/rails/hyperdrive/auto_install.rb +25 -7
  15. data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +360 -150
  16. data/lib/rails/hyperdrive/canonical_skill_render.rb +68 -39
  17. data/lib/rails/hyperdrive/companion_discovery.rb +4 -5
  18. data/lib/rails/hyperdrive/gem_manifest.rb +311 -80
  19. data/lib/rails/hyperdrive/gemspec_locator.rb +38 -0
  20. data/lib/rails/hyperdrive/install_layout.rb +148 -12
  21. data/lib/rails/hyperdrive/install_pipeline.rb +142 -78
  22. data/lib/rails/hyperdrive/install_plan.rb +21 -9
  23. data/lib/rails/hyperdrive/lock_file.rb +25 -3
  24. data/lib/rails/hyperdrive/manifest_lint.rb +230 -0
  25. data/lib/rails/hyperdrive/skill_template.rb +7 -5
  26. data/lib/rails/hyperdrive/tools/run_sql.rb +1 -1
  27. data/lib/rails/hyperdrive/version.rb +1 -1
  28. data/lib/rails/hyperdrive.rb +0 -22
  29. metadata +9 -8
  30. data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +0 -3
  31. data/lib/rails/hyperdrive/skill_tasks.rb +0 -27
  32. data/lib/tasks/hyperdrive.rake +0 -22
@@ -20,12 +20,15 @@ module Rails
20
20
  MCP_SERVER_KEY = "rails-hyperdrive".freeze
21
21
 
22
22
  GEMFILE = "Gemfile".freeze
23
- BUNDLER_PLUGIN = "bundler-rails-hyperdrive".freeze
23
+ BUNDLER_PLUGIN = "bundler-hyperdrive".freeze
24
24
 
25
+ # No templates are rendered; source_root exists so Rails resolves the
26
+ # sibling USAGE file for `--help`.
25
27
  source_root File.expand_path("templates", __dir__)
26
28
 
27
29
  class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path."
28
- class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, and the lockfile; write only .mcp.json and the mount."
30
+ class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, and the lockfile; leave the .mcp.json, .gitignore, Gemfile, and mount steps."
31
+ class_option :skip_mcp, type: :boolean, default: false, desc: "Skip MCP setup entirely; write no .mcp.json entry and no engine mount."
29
32
  class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
30
33
 
31
34
  def verify_environment
@@ -39,6 +42,8 @@ module Rails
39
42
  # The write is forced: Thor's conflict prompt would otherwise block the
40
43
  # run waiting on stdin.
41
44
  def write_mcp_json
45
+ return if options[:skip_mcp]
46
+
42
47
  existing = mcp_json_on_disk
43
48
  document = existing ? parse_mcp_json(existing) : {}
44
49
  return if document.nil?
@@ -77,12 +82,9 @@ module Rails
77
82
  append_to_file GEMFILE, "#{prefix}plugin #{BUNDLER_PLUGIN.inspect}\n"
78
83
  end
79
84
 
80
- def write_initializer
81
- return if mount_path == DEFAULT_MOUNT_AT
82
- template "initializer.rb.tt", "config/initializers/hyperdrive.rb"
83
- end
84
-
85
85
  def mount_engine
86
+ return if options[:skip_mcp]
87
+
86
88
  routes_file = "config/routes.rb"
87
89
  unless File.exist?(::Rails.root.join(routes_file))
88
90
  say_status :skip, "no #{routes_file} found; skipping engine mount", :yellow
@@ -110,17 +112,30 @@ module Rails
110
112
  def print_summary
111
113
  say ""
112
114
  say_status :done, "hyperdrive initialized", :green
113
- say " Mount: #{mount_path} (in config/routes.rb)"
114
- say " Server: #{::Rails::Hyperdrive::McpServer::TOOLS.size} MCP tools at http://localhost:3000#{mount_path}/mcp"
115
+ if options[:skip_mcp]
116
+ say " MCP: skipped (--skip-mcp)"
117
+ else
118
+ say " Mount: #{mount_path} (in config/routes.rb)"
119
+ say " Server: #{::Rails::Hyperdrive::McpServer::TOOLS.size} MCP tools at http://localhost:3000#{mount_path}/mcp"
120
+ end
115
121
  runner.summary_lines.each { |line| say line } unless options[:skip_content]
122
+ # Every next step is about reaching the MCP endpoint.
123
+ return if options[:skip_mcp]
124
+
116
125
  say ""
117
126
  say " Next steps:"
118
127
  say " 1. bin/rails server"
119
128
  say " 2. Open Claude Code in this directory; it will read .mcp.json"
120
- say " 3. Verify the connection: curl http://localhost:3000#{mount_path}/mcp"
129
+ say " 3. Verify the connection: #{connection_check_command}"
121
130
  end
122
131
 
123
132
  no_tasks do
133
+ def connection_check_command
134
+ "curl -s http://localhost:3000#{mount_path}/mcp " \
135
+ "-H 'Content-Type: application/json' -H 'Accept: application/json' " \
136
+ "-d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'"
137
+ end
138
+
124
139
  def mcp_json_on_disk
125
140
  abs = ::Rails.root.join(MCP_JSON_PATH)
126
141
  File.exist?(abs) ? File.read(abs) : nil
@@ -4,8 +4,8 @@ module Rails
4
4
  module Generators
5
5
  module Hyperdrive
6
6
  module InstallSummary
7
- KIND_WIDTH = "guideline".length
8
- KIND_ORDER = %w[skill guideline].freeze
7
+ KIND_ORDER = ::Rails::Hyperdrive::InstallLayout.content_kinds.map(&:lock_kind).freeze
8
+ KIND_WIDTH = KIND_ORDER.map(&:length).max
9
9
 
10
10
  module_function
11
11
 
@@ -38,7 +38,7 @@ module Rails
38
38
 
39
39
  def installed_counts(entries)
40
40
  counts = entries.group_by { |e| e.kind.to_s }.transform_values(&:size)
41
- "Installed #{quantify(counts["skill"].to_i, "skill")}, #{quantify(counts["guideline"].to_i, "guideline")}"
41
+ "Installed #{KIND_ORDER.map { |kind| quantify(counts[kind].to_i, kind) }.join(", ")}"
42
42
  end
43
43
 
44
44
  def group_by_source(entries)
@@ -1,9 +1,10 @@
1
1
  Description:
2
2
  Syncs Rails Hyperdrive content to match the current bundle (run after
3
3
  `bundle update` or after adding a companion gem):
4
- - discovers companion-gem skills + guidelines in the bundle and installs
5
- them (skills to .claude/skills/, guidelines to
6
- .claude/hyperdrive/guidelines/)
4
+ - discovers companion-gem skills, guidelines, agents, and commands in the
5
+ bundle and installs them (skills to .claude/skills/, guidelines to
6
+ .claude/hyperdrive/guidelines/, agents to .claude/agents/, commands to
7
+ .claude/commands/)
7
8
  - maintains the .claude/hyperdrive/index.md aggregator and the single
8
9
  @-include line in CLAUDE.md — both only while a companion gem ships a
9
10
  guideline, and both removed when the last one goes
@@ -24,8 +25,9 @@ Description:
24
25
  upstream wholesale. Either way the next sync is quiet: the lock already
25
26
  records the delivered upstream, so the same version is never re-offered.
26
27
 
27
- Touches no bootstrap artifact: .mcp.json, the engine mount, the optional
28
- initializer, and .gitignore belong to hyperdrive:init and are left alone.
28
+ Touches no bootstrap artifact: .mcp.json, the engine mount, the Gemfile
29
+ plugin directive, and .gitignore belong to hyperdrive:init and are left
30
+ alone.
29
31
 
30
32
  Examples:
31
33
  bin/rails hyperdrive:sync
@@ -6,7 +6,7 @@ module Rails
6
6
  module Generators
7
7
  module Hyperdrive
8
8
  # Content-only by contract: no step may write a bootstrap artifact
9
- # (.mcp.json, the engine mount, the initializer, the .gitignore rule).
9
+ # (.mcp.json, the engine mount, the .gitignore rule).
10
10
  class SyncGenerator < ::Rails::Generators::Base
11
11
  include ContentSyncSupport
12
12
 
@@ -32,18 +32,18 @@ module Rails
32
32
 
33
33
  def discover_artifacts(skip: false)
34
34
  @artifacts ||= skip ? [] : ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(
35
- warnings: warnings, enabled_gems: enabled_gems, notices: notices
35
+ enabled_gems: enabled_gems, report: report
36
36
  )
37
37
  end
38
38
 
39
39
  def install(mode:)
40
+ verify_lock_schema!
40
41
  @pipeline = ::Rails::Hyperdrive::InstallPipeline.new(
41
42
  root: root,
42
43
  shell: @shell,
43
44
  artifacts: discover_artifacts,
44
45
  mode: mode,
45
- warnings: warnings,
46
- notices: notices
46
+ report: report
47
47
  )
48
48
  @pipeline.call
49
49
  end
@@ -54,24 +54,34 @@ module Rails
54
54
 
55
55
  private
56
56
 
57
+ # Raised from install, so it stops the run before any content write —
58
+ # a dry run included.
59
+ def verify_lock_schema!
60
+ return unless lock.schema_ahead?
61
+
62
+ message = lock.schema_ahead_message(::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
63
+ @shell.say_status :error, message, :red
64
+ raise Thor::Error, "hyperdrive: #{message}"
65
+ end
66
+
57
67
  # Resolved lazily so verify_environment! can report a missing Rails app
58
68
  # before anything dereferences ::Rails.root.
59
69
  def root
60
70
  @root ||= ::Rails.root.to_s
61
71
  end
62
72
 
63
- def warnings
64
- @warnings ||= []
73
+ def report
74
+ @report ||= ::Rails::Hyperdrive::BundlerArtifactDiscovery::Report.new
65
75
  end
66
76
 
67
- def notices
68
- @notices ||= []
77
+ def lock
78
+ @lock ||= ::Rails::Hyperdrive::LockFile.load(
79
+ File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
80
+ )
69
81
  end
70
82
 
71
83
  def enabled_gems
72
- @enabled_gems ||= ::Rails::Hyperdrive::LockFile.load(
73
- File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
74
- ).enabled_gems
84
+ lock.enabled_gems
75
85
  end
76
86
 
77
87
  def lock_entries
@@ -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