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.
@@ -5,15 +5,7 @@ require "digest"
5
5
  module Rails
6
6
  module Hyperdrive
7
7
  class StackProfile
8
- CATEGORIES_PATH = File.expand_path("data/gem_categories.yml", __dir__)
9
-
10
8
  class << self
11
- def categories
12
- @categories ||= YAML.load_file(CATEGORIES_PATH).each_with_object({}) do |(cat, gems), acc|
13
- gems.each { |g| acc[g] = cat.to_sym }
14
- end
15
- end
16
-
17
9
  def current
18
10
  @current ||= from_lockfile(default_lockfile_path)
19
11
  end
@@ -54,16 +46,11 @@ module Rails
54
46
  specs = parser.specs.each_with_object({}) { |s, h| h[s.name] = s.version.to_s }
55
47
 
56
48
  @data = {
57
- rails: rails_info(specs),
58
- ruby: ruby_info(parser),
59
- database: database_info(specs),
60
- test: bucket(specs, :test),
61
- jobs: bucket(specs, :jobs),
62
- frontend: frontend_info(specs),
63
- auth: bucket(specs, :auth),
64
- authz: bucket(specs, :authz),
65
- db_gems: bucket(specs, :db),
66
- gem_skills: gem_skills_info
49
+ rails: rails_info(specs),
50
+ ruby: ruby_info(parser),
51
+ database: database_info(specs),
52
+ direct_dependencies: direct_dependencies(parser, specs),
53
+ gem_skills: gem_skills_info
67
54
  }
68
55
  self
69
56
  end
@@ -77,8 +64,7 @@ module Rails
77
64
  def empty_profile
78
65
  {
79
66
  rails: {}, ruby: {}, database: {},
80
- test: [], jobs: [], frontend: [],
81
- auth: [], authz: [], db_gems: [], gem_skills: []
67
+ direct_dependencies: [], gem_skills: []
82
68
  }
83
69
  end
84
70
 
@@ -132,33 +118,20 @@ module Rails
132
118
  ::Rails.root.join("config", "database.yml").to_s
133
119
  end
134
120
 
135
- def bucket(specs, category)
136
- gem_names = self.class.categories.select { |_, c| c == category }.keys
137
- gem_names.filter_map do |name|
138
- version = specs[name]
139
- next unless version
140
- { key: name.to_sym, version: version }
121
+ # Presence in the resolved specs does not mean the app chose a gem —
122
+ # minitest resolves under every Rails app. Transitive gems stay out.
123
+ def direct_dependencies(parser, specs)
124
+ parser.dependencies.keys.sort.map do |name|
125
+ { name: name, version: specs[name] }
141
126
  end
142
127
  end
143
128
 
144
- # Frontend gets a special shape because Hotwire is actually two gems.
145
- def frontend_info(specs)
146
- bucket(specs, :frontend).map do |entry|
147
- if entry[:key] == :"turbo-rails" || entry[:key] == :"stimulus-rails"
148
- turbo = specs["turbo-rails"]
149
- stim = specs["stimulus-rails"]
150
- { key: :hotwire, versions: { turbo: turbo, stimulus: stim }.compact }
151
- else
152
- entry
153
- end
154
- end.uniq
155
- end
156
-
157
129
  # Loaded lazily and rescued broadly so StackProfile stays usable where
158
130
  # Bundler is absent or refuses to resolve.
159
131
  def gem_skills_info
160
132
  require "rails/hyperdrive/bundler_artifact_discovery"
161
- ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover.select(&:skill?).map do |skill|
133
+ discovered = ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(enabled_gems: enabled_gems)
134
+ discovered.select(&:skill?).map do |skill|
162
135
  {
163
136
  name: skill.name,
164
137
  gem: skill.target_gem,
@@ -171,6 +144,24 @@ module Rails
171
144
  rescue StandardError
172
145
  []
173
146
  end
147
+
148
+ def enabled_gems
149
+ require "rails/hyperdrive/install_layout"
150
+ require "rails/hyperdrive/lock_file"
151
+ root =
152
+ if @app_root
153
+ @app_root.to_s
154
+ elsif defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
155
+ ::Rails.root.to_s
156
+ end
157
+ return [] unless root
158
+
159
+ ::Rails::Hyperdrive::LockFile.load(
160
+ File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
161
+ ).enabled_gems
162
+ rescue StandardError
163
+ []
164
+ end
174
165
  end
175
166
  end
176
167
  end
@@ -0,0 +1,47 @@
1
+ require "open3"
2
+ require "tmpdir"
3
+
4
+ module Rails
5
+ module Hyperdrive
6
+ # Tempfiles live in the system tmpdir, never in the app, so a dry run
7
+ # stays write-free. Fail-open: a missing or erroring git reads as
8
+ # :unavailable, never an exception.
9
+ module ThreeWayMerge
10
+ Outcome = Struct.new(:status, :body, keyword_init: true) do
11
+ def clean?
12
+ status == :clean
13
+ end
14
+ end
15
+
16
+ module_function
17
+
18
+ def merge(ours:, base:, theirs:)
19
+ return Outcome.new(status: :unavailable) if [ours, base, theirs].any? { |b| binary?(b) }
20
+
21
+ Dir.mktmpdir("hyperdrive-merge") do |dir|
22
+ paths = { "ours" => ours, "base" => base, "theirs" => theirs }.map do |name, content|
23
+ File.join(dir, name).tap { |p| File.binwrite(p, content) }
24
+ end
25
+
26
+ out, _err, status = Open3.capture3("git", "merge-file", "-p", *paths)
27
+ if status.success?
28
+ Outcome.new(status: :clean, body: out)
29
+ elsif status.exited? && status.exitstatus.between?(1, 127)
30
+ # git merge-file exits with the conflict count.
31
+ Outcome.new(status: :conflicted)
32
+ else
33
+ Outcome.new(status: :unavailable)
34
+ end
35
+ end
36
+ rescue SystemCallError, IOError
37
+ Outcome.new(status: :unavailable)
38
+ end
39
+
40
+ def binary?(content)
41
+ s = content.to_s
42
+ return true if s.include?("\0".b)
43
+ !s.dup.force_encoding(Encoding::UTF_8).valid_encoding?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -6,7 +6,7 @@ module Rails
6
6
  module Tools
7
7
  class DescribeApp < Base
8
8
  tool_name "describe_app"
9
- description "Snapshot of Rails/Ruby/DB versions plus the full StackProfile (test, jobs, frontend, auth, authz, db_gems, gem_skills)."
9
+ description "Snapshot of Rails/Ruby/DB versions plus the app's direct gem dependencies and installed gem skills."
10
10
 
11
11
  input_schema(properties: {})
12
12
 
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- VERSION = "0.3.0"
3
+ VERSION = "0.5.0"
4
4
  end
5
5
  end
@@ -6,7 +6,7 @@ namespace :hyperdrive do
6
6
  Rails::Generators::Hyperdrive::InstallGenerator.start(ARGV.drop(1))
7
7
  end
8
8
 
9
- desc "Sync Rails Hyperdrive content (skills, guidelines, index.md, lockfile); locally-edited files are preserved — pass --overwrite to restore gem-shipped content"
9
+ desc "Sync Rails Hyperdrive content (skills, guidelines, index.md, lockfile); locally-edited files are preserved — pass --merge (three-way merge), --sidecar (deliver upstream to <file>.new), or --overwrite (restore gem-shipped content)"
10
10
  task :sync do
11
11
  require "rails/generators"
12
12
  require "generators/hyperdrive/sync/sync_generator"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-hyperdrive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bakaface
@@ -96,17 +96,18 @@ files:
96
96
  - lib/generators/hyperdrive/sync_runner.rb
97
97
  - lib/rails-hyperdrive.rb
98
98
  - lib/rails/hyperdrive.rb
99
+ - lib/rails/hyperdrive/ancestor_locator.rb
99
100
  - lib/rails/hyperdrive/artifact_status.rb
100
- - lib/rails/hyperdrive/audit_header.rb
101
101
  - lib/rails/hyperdrive/auto_install.rb
102
102
  - lib/rails/hyperdrive/bundler_artifact_discovery.rb
103
+ - lib/rails/hyperdrive/canonical_skill_render.rb
103
104
  - lib/rails/hyperdrive/claude_md_import.rb
104
105
  - lib/rails/hyperdrive/companion_discovery.rb
105
106
  - lib/rails/hyperdrive/console_executor.rb
106
- - lib/rails/hyperdrive/data/gem_categories.yml
107
107
  - lib/rails/hyperdrive/drift_verdict.rb
108
108
  - lib/rails/hyperdrive/eager_footprint.rb
109
109
  - lib/rails/hyperdrive/engine.rb
110
+ - lib/rails/hyperdrive/gem_manifest.rb
110
111
  - lib/rails/hyperdrive/index_document.rb
111
112
  - lib/rails/hyperdrive/install_layout.rb
112
113
  - lib/rails/hyperdrive/install_pipeline.rb
@@ -117,9 +118,11 @@ files:
117
118
  - lib/rails/hyperdrive/resources/skill.rb
118
119
  - lib/rails/hyperdrive/resources/stack_profile.rb
119
120
  - lib/rails/hyperdrive/safety/rack_middleware.rb
121
+ - lib/rails/hyperdrive/skill_tasks.rb
120
122
  - lib/rails/hyperdrive/skill_template.rb
121
123
  - lib/rails/hyperdrive/sql_safety.rb
122
124
  - lib/rails/hyperdrive/stack_profile.rb
125
+ - lib/rails/hyperdrive/three_way_merge.rb
123
126
  - lib/rails/hyperdrive/tools/base.rb
124
127
  - lib/rails/hyperdrive/tools/describe_app.rb
125
128
  - lib/rails/hyperdrive/tools/list_models.rb
@@ -1,83 +0,0 @@
1
- require "time"
2
-
3
- module Rails
4
- module Hyperdrive
5
- module AuditHeader
6
- YAML_LINE = /\A#\s*hyperdrive:/.freeze
7
- HTML_LINE = /\A<!--\s*hyperdrive:.*-->\s*\z/.freeze
8
-
9
- module_function
10
-
11
- def fields(source_gem:, version:, sha256:, installed_at: Time.now.utc)
12
- [
13
- "source=#{source_gem}@#{version}",
14
- "sha256=#{sha256}",
15
- "installed_at=#{installed_at.iso8601}"
16
- ]
17
- end
18
-
19
- def build(source_gem:, version:, sha256:, installed_at: Time.now.utc)
20
- fields(source_gem: source_gem, version: version, sha256: sha256, installed_at: installed_at)
21
- .map { |f| "# hyperdrive: #{f}" }
22
- .join("\n")
23
- end
24
-
25
- def build_html(source_gem:, version:, sha256:, installed_at: Time.now.utc)
26
- fields(source_gem: source_gem, version: version, sha256: sha256, installed_at: installed_at)
27
- .map { |f| "<!-- hyperdrive: #{f} -->" }
28
- .join("\n")
29
- end
30
-
31
- def inject_into_frontmatter(body, header_block)
32
- lines = body.lines
33
- unless lines.first&.strip == "---"
34
- return "---\n#{header_block}\n---\n\n#{body}"
35
- end
36
-
37
- closing_index = lines[1..].index { |l| l.strip == "---" }
38
- return body unless closing_index
39
-
40
- absolute_closing = closing_index + 1
41
- lines.insert(absolute_closing, header_block + "\n")
42
- lines.join
43
- end
44
-
45
- def prepend_html(body, header_block)
46
- "#{header_block}\n\n#{body}"
47
- end
48
-
49
- # Strips only the contiguous injected block, never stray "# hyperdrive:"
50
- # or "<!-- -->" lines elsewhere in the body.
51
- def strip(body)
52
- lines = body.lines
53
- if lines.first&.strip == "---"
54
- strip_yaml(lines)
55
- else
56
- strip_html(lines)
57
- end
58
- end
59
-
60
- def strip_yaml(lines)
61
- closing_index = lines[1..].index { |l| l.strip == "---" }
62
- return lines.join unless closing_index
63
-
64
- absolute_closing = closing_index + 1
65
- first = (1...absolute_closing).find { |i| lines[i] =~ YAML_LINE }
66
- return lines.join unless first
67
-
68
- last = first
69
- last += 1 while last < absolute_closing && lines[last] =~ YAML_LINE
70
- (lines[0...first] + lines[last..]).join
71
- end
72
-
73
- def strip_html(lines)
74
- return lines.join unless lines.first =~ HTML_LINE
75
-
76
- i = 0
77
- i += 1 while lines[i] =~ HTML_LINE
78
- i += 1 if lines[i] && lines[i].strip.empty?
79
- lines[i..].join
80
- end
81
- end
82
- end
83
- end
@@ -1,52 +0,0 @@
1
- # Category assignment is reporting metadata only; nothing here is executed.
2
-
3
- test:
4
- - rspec
5
- - rspec-rails
6
- - minitest
7
- - minitest-rails
8
- - test-unit
9
- - capybara
10
- - cucumber
11
- - cucumber-rails
12
-
13
- jobs:
14
- - sidekiq
15
- - resque
16
- - delayed_job
17
- - good_job
18
- - solid_queue
19
- - sucker_punch
20
- - shoryuken
21
- - que
22
-
23
- auth:
24
- - devise
25
- - clearance
26
- - sorcery
27
- - authlogic
28
- - rodauth
29
- - rodauth-rails
30
-
31
- authz:
32
- - pundit
33
- - cancancan
34
- - action_policy
35
-
36
- frontend:
37
- - turbo-rails
38
- - stimulus-rails
39
- - importmap-rails
40
- - jsbundling-rails
41
- - cssbundling-rails
42
- - view_component
43
- - phlex
44
- - phlex-rails
45
- - hotwire-rails
46
-
47
- db:
48
- - pg
49
- - mysql2
50
- - sqlite3
51
- - trilogy
52
- - sequel