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
@@ -0,0 +1,38 @@
1
+ module Rails
2
+ module Hyperdrive
3
+ # Resolves the gemspec of the companion gem repo a dev task is running in.
4
+ # Companion-repo dev tooling: problems raise rather than fail open.
5
+ module GemspecLocator
6
+ Error = Class.new(StandardError)
7
+
8
+ module_function
9
+
10
+ # Returns the loaded spec and the repo root its relative paths resolve
11
+ # against — the gemspec's own directory, not an installed gem path.
12
+ def load_spec(explicit, dir)
13
+ path = resolve(explicit, dir)
14
+ spec = Gem::Specification.load(path)
15
+ raise Error, "could not load gemspec #{path}" unless spec
16
+
17
+ [spec, File.dirname(File.expand_path(path))]
18
+ end
19
+
20
+ def resolve(explicit, dir)
21
+ if explicit && !explicit.to_s.strip.empty?
22
+ path = File.expand_path(explicit.to_s, dir)
23
+ raise Error, "gemspec not found: #{path}" unless File.file?(path)
24
+ return path
25
+ end
26
+
27
+ found = Dir.glob(File.join(dir, "*.gemspec"))
28
+ case found.size
29
+ when 1 then File.expand_path(found.first)
30
+ when 0 then raise Error, "no .gemspec found in #{dir}; pass an explicit path as the task " \
31
+ "argument, e.g. rake \"<task>[path/to/name.gemspec]\""
32
+ else raise Error, "multiple gemspecs found in #{dir} " \
33
+ "(#{found.map { |f| File.basename(f) }.join(", ")}); pass an explicit path"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,35 +1,171 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- # The one place install paths and artifact names are built and parsed.
3
+ # The registry every artifact kind declares itself in, and the one place
4
+ # install paths and artifact names are built and parsed.
4
5
  # Must stay require-free: the MCP server loads it and must not drag in the
5
6
  # install machinery.
6
7
  module InstallLayout
7
8
  SKILLS_DIR = ".claude/skills".freeze
8
9
  HYPERDRIVE_DIR = ".claude/hyperdrive".freeze
10
+ AGENTS_DIR = ".claude/agents".freeze
11
+ COMMANDS_DIR = ".claude/commands".freeze
9
12
  INDEX_PATH = "#{HYPERDRIVE_DIR}/index.md".freeze
10
13
  LOCK_PATH = ".hyperdrive/lock.yml".freeze
11
14
 
12
- ARTIFACT_TYPES = { "skill" => :skill, "skill_support" => :skill_support, "guideline" => :guideline }.freeze
15
+ DEFAULT_TARGET = :claude
13
16
 
14
- module_function
17
+ # `root` is the directory a target tool reads the kind from; `path` builds
18
+ # one artifact's destination under it.
19
+ Dest = Struct.new(:root, :path, keyword_init: true)
20
+
21
+ # One kind's whole contract: how a companion ships it, how it is
22
+ # identified and validated, and where each target tool installs it.
23
+ Kind = Struct.new(
24
+ :type, :shape, :section, :key_label, :shipped_label, :dir_key, :prefix_key,
25
+ :convention_roots, :identity, :frontmatter, :install_body,
26
+ :collision_rewrites_name, :eager, :name_from_dest, :dests,
27
+ keyword_init: true
28
+ ) do
29
+ def lock_kind
30
+ type.to_s
31
+ end
32
+
33
+ # Only a directory-shaped kind ships supporting files, so it is the only
34
+ # one a manifest entry may gate per file.
35
+ def dir_shaped?
36
+ shape == :dir
37
+ end
38
+
39
+ def dest_for(final_name, target: DEFAULT_TARGET)
40
+ dests[target]&.path&.call(final_name)
41
+ end
42
+
43
+ def installed_name(dest)
44
+ name_from_dest.call(dest)
45
+ end
15
46
 
16
- def dest_for(type, final_name)
17
- case type
18
- when :skill then "#{SKILLS_DIR}/#{final_name}/SKILL.md"
19
- when :guideline then "#{HYPERDRIVE_DIR}/guidelines/#{final_name}.md"
47
+ # Relative to the gem root; searched in the order given.
48
+ def roots_for(gem_name)
49
+ convention_roots ? convention_roots.call(gem_name) : []
20
50
  end
21
51
  end
22
52
 
53
+ KINDS = {
54
+ skill: Kind.new(
55
+ type: :skill,
56
+ shape: :dir,
57
+ section: "skills",
58
+ key_label: "skill relpath",
59
+ shipped_label: "skill directory",
60
+ dir_key: "skills_dir",
61
+ prefix_key: nil,
62
+ convention_roots: ->(gem_name) { [File.join("lib", gem_name, "hyperdrive", "skills"), "skills"] },
63
+ identity: :frontmatter,
64
+ frontmatter: :required,
65
+ install_body: :verbatim,
66
+ collision_rewrites_name: true,
67
+ eager: false,
68
+ name_from_dest: ->(dest) { File.basename(File.dirname(dest)) },
69
+ dests: { claude: Dest.new(root: SKILLS_DIR, path: ->(name) { "#{SKILLS_DIR}/#{name}/SKILL.md" }) }
70
+ ),
71
+ skill_support: Kind.new(
72
+ type: :skill_support,
73
+ shape: nil,
74
+ install_body: :verbatim,
75
+ collision_rewrites_name: false,
76
+ eager: false,
77
+ name_from_dest: ->(dest) { dest.split("/")[2] }, # .claude/skills/<name>/<relpath>
78
+ dests: {}
79
+ ),
80
+ guideline: Kind.new(
81
+ type: :guideline,
82
+ shape: :flat,
83
+ section: "guidelines",
84
+ key_label: "guideline filename",
85
+ shipped_label: "guideline",
86
+ dir_key: nil,
87
+ prefix_key: nil,
88
+ convention_roots: ->(gem_name) { [File.join("lib", gem_name, "hyperdrive", "guidelines")] },
89
+ identity: :frontmatter,
90
+ frontmatter: :required,
91
+ install_body: :strip_frontmatter,
92
+ collision_rewrites_name: false,
93
+ eager: true,
94
+ name_from_dest: ->(dest) { File.basename(dest, ".md") },
95
+ dests: {
96
+ claude: Dest.new(
97
+ root: HYPERDRIVE_DIR,
98
+ path: ->(name) { "#{HYPERDRIVE_DIR}/guidelines/#{name}.md" }
99
+ )
100
+ }
101
+ ),
102
+ agent: Kind.new(
103
+ type: :agent,
104
+ shape: :flat,
105
+ section: "agents",
106
+ key_label: "agent filename",
107
+ shipped_label: "agent",
108
+ dir_key: "agents_dir",
109
+ prefix_key: nil,
110
+ convention_roots: ->(_gem_name) { ["agents"] },
111
+ identity: :frontmatter,
112
+ frontmatter: :required,
113
+ install_body: :verbatim,
114
+ collision_rewrites_name: true,
115
+ eager: false,
116
+ name_from_dest: ->(dest) { File.basename(dest, ".md") },
117
+ dests: { claude: Dest.new(root: AGENTS_DIR, path: ->(name) { "#{AGENTS_DIR}/#{name}.md" }) }
118
+ ),
119
+ command: Kind.new(
120
+ type: :command,
121
+ shape: :flat,
122
+ section: "commands",
123
+ key_label: "command filename",
124
+ shipped_label: "command",
125
+ dir_key: "commands_dir",
126
+ prefix_key: "command_prefix",
127
+ convention_roots: ->(_gem_name) { ["commands"] },
128
+ identity: :filename_stem,
129
+ frontmatter: :optional,
130
+ install_body: :verbatim,
131
+ collision_rewrites_name: false,
132
+ eager: false,
133
+ name_from_dest: ->(dest) { File.basename(dest, ".md") },
134
+ dests: { claude: Dest.new(root: COMMANDS_DIR, path: ->(name) { "#{COMMANDS_DIR}/#{name}.md" }) }
135
+ )
136
+ }.freeze
137
+
138
+ ARTIFACT_TYPES = KINDS.each_with_object({}) { |(type, kind), h| h[kind.lock_kind] = type }.freeze
139
+
140
+ module_function
141
+
142
+ def kind(type)
143
+ KINDS[type]
144
+ end
145
+
146
+ # The kinds a companion gem ships, in install order.
147
+ def content_kinds
148
+ KINDS.values.select(&:section)
149
+ end
150
+
151
+ def dir_keys
152
+ content_kinds.filter_map(&:dir_key)
153
+ end
154
+
155
+ def dest_roots(target: DEFAULT_TARGET)
156
+ content_kinds.filter_map { |kind| kind.dests[target]&.root }.uniq
157
+ end
158
+
159
+ def dest_for(type, final_name, target: DEFAULT_TARGET)
160
+ KINDS[type]&.dest_for(final_name, target: target)
161
+ end
162
+
23
163
  def sidecar_path(dest)
24
164
  "#{dest}.new"
25
165
  end
26
166
 
27
167
  def installed_name(type, dest)
28
- case type
29
- when :skill then File.basename(File.dirname(dest))
30
- when :skill_support then dest.split("/")[2] # .claude/skills/<name>/<relpath>
31
- when :guideline then File.basename(dest, ".md")
32
- end
168
+ KINDS[type]&.installed_name(dest)
33
169
  end
34
170
 
35
171
  def skill_dir_of(dest)
@@ -1,3 +1,4 @@
1
+ require "set"
1
2
  require "time"
2
3
  require "rails/hyperdrive/ancestor_locator"
3
4
  require "rails/hyperdrive/bundler_artifact_discovery"
@@ -15,8 +16,7 @@ module Rails
15
16
  # The application root is explicit and no Rails constant is touched, so any
16
17
  # process that can see the bundle can run this.
17
18
  class InstallPipeline
18
- ARTIFACT_DESTINATIONS =
19
- [InstallLayout::SKILLS_DIR, InstallLayout::HYPERDRIVE_DIR, InstallLayout::LOCK_PATH].freeze
19
+ ARTIFACT_DESTINATIONS = (InstallLayout.dest_roots + [InstallLayout::LOCK_PATH]).freeze
20
20
 
21
21
  WARN_LINES = 150
22
22
  WARN_TOKENS = 1_500
@@ -26,30 +26,33 @@ module Rails
26
26
  Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, :merged, :sidecars,
27
27
  keyword_init: true)
28
28
 
29
- def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [], notices: [])
29
+ def initialize(root:, shell:, artifacts:, mode: :preserve, report: BundlerArtifactDiscovery::Report.new)
30
30
  raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
31
31
 
32
32
  @root = File.expand_path(root.to_s)
33
33
  @shell = shell
34
34
  @artifacts = artifacts
35
35
  @mode = mode
36
- @warnings = warnings
37
- @notices = notices
36
+ @report = report
37
+ @bundled_gems = Array(report.bundled_gems).map(&:to_s)
38
+ @skipped_gems = Array(report.skipped_gems).map(&:to_s)
38
39
  @result = Result.new(
39
40
  installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [], merged: [], sidecars: []
40
41
  )
41
42
  end
42
43
 
43
44
  def call
45
+ return halt_schema_ahead if old_lock.schema_ahead?
46
+
44
47
  @new_lock = LockFile.new(abs(InstallLayout::LOCK_PATH)).carry_settings(old_lock)
45
48
  @plan = plan
46
49
 
47
50
  report_collisions
48
51
  report_disabled
49
- install_skills
50
- guidelines = install_guidelines
52
+ guidelines = install_artifacts
51
53
  remove_disabled
52
54
  remove_stale_support_files
55
+ remove_stale_dests
53
56
  carry_orphans
54
57
  eager_guidelines = write_index_md(guidelines)
55
58
  write_claude_md(guidelines)
@@ -71,6 +74,13 @@ module Rails
71
74
 
72
75
  private
73
76
 
77
+ # The backstop for callers that do not check the lock themselves: nothing
78
+ # is written, in every mode including :additive.
79
+ def halt_schema_ahead
80
+ @shell.say_status :warn, old_lock.schema_ahead_message(InstallLayout::LOCK_PATH), :yellow
81
+ @result
82
+ end
83
+
74
84
  def build_result
75
85
  @build_result ||= InstallPlan.build(@artifacts, lock: old_lock)
76
86
  end
@@ -114,33 +124,36 @@ module Rails
114
124
  end
115
125
  end
116
126
 
117
- def install_skills
118
- @plan.select { |e| e.type == :skill }.each do |entry|
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)
123
- entry.support_files.each do |file|
124
- install_file(
125
- dest: file[:dest],
126
- type: :skill_support,
127
- install_ready_body: file[:body],
128
- source_gem: entry.source_gem,
129
- version: entry.version,
130
- artifact_kind: "skill_support",
131
- source_relpath: support_base && File.join(support_base, file[:path])
132
- )
127
+ # Returns the eager artifacts: the ones the index and the CLAUDE.md
128
+ # import line are built from.
129
+ def install_artifacts
130
+ eager = []
131
+ InstallLayout.content_kinds.each do |kind|
132
+ @plan.select { |e| e.type == kind.type }.each do |entry|
133
+ relpath = source_relpath_for(entry.artifact)
134
+ body = entry.install_ready_body
135
+ warn_if_oversize(entry.dest, body) if kind.eager
136
+ install_file(entry: entry, type: kind.type, install_ready_body: body,
137
+ source_relpath: relpath, final_name: entry.final_name)
138
+ install_support_files(entry, relpath) if kind.dir_shaped?
139
+ eager << { base: "#{entry.final_name}.md", dest: entry.dest, body: body } if kind.eager
133
140
  end
134
141
  end
135
- end
136
-
137
- def install_guidelines
138
- @plan.select { |e| e.type == :guideline }.map do |entry|
139
- body = entry.install_ready_body
140
- warn_if_oversize(entry.dest, 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)
143
- { base: "#{entry.final_name}.md", dest: entry.dest, body: body }
142
+ eager
143
+ end
144
+
145
+ def install_support_files(entry, relpath)
146
+ support_base = support_relpath_base(entry.artifact, relpath)
147
+ entry.support_files.each do |file|
148
+ install_file(
149
+ dest: file[:dest],
150
+ type: :skill_support,
151
+ install_ready_body: file[:body],
152
+ source_gem: entry.source_gem,
153
+ version: entry.version,
154
+ artifact_kind: "skill_support",
155
+ source_relpath: file[:source_relpath] || (support_base && File.join(support_base, file[:path]))
156
+ )
144
157
  end
145
158
  end
146
159
 
@@ -241,7 +254,7 @@ module Rails
241
254
 
242
255
  # An edited sidecar is user work: leave it, and leave the lock at the
243
256
  # old upstream so this delivery is re-offered once the user clears it.
244
- if File.exist?(abs(sidecar)) && !sidecar_pristine?(sidecar, write, old)
257
+ if File.exist?(abs(sidecar)) && !delivered_sidecar?(sidecar, delivered_shas(write, old))
245
258
  @result.skipped << dest
246
259
  @shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
247
260
  @new_lock.carry(old) if old
@@ -319,18 +332,25 @@ module Rails
319
332
 
320
333
  # Only a machine-pristine sidecar — one still hashing to a delivered
321
334
  # 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)
335
+ def delivered_sidecar?(sidecar, shas)
336
+ Array(shas).compact.include?(DriftVerdict.disk_sha(abs(sidecar)))
325
337
  rescue StandardError
326
338
  false
327
339
  end
328
340
 
341
+ def delivered_shas(write, old)
342
+ [old&.source_sha, write[:gem_sha]]
343
+ end
344
+
329
345
  def sweep_sidecar(write, old)
330
- sidecar = InstallLayout.sidecar_path(write[:dest])
346
+ sweep_sidecar_at(write[:dest], delivered_shas(write, old))
347
+ end
348
+
349
+ def sweep_sidecar_at(dest, shas)
350
+ sidecar = InstallLayout.sidecar_path(dest)
331
351
  return unless File.exist?(abs(sidecar))
332
352
 
333
- if sidecar_pristine?(sidecar, write, old)
353
+ if delivered_sidecar?(sidecar, shas)
334
354
  @shell.remove_file sidecar
335
355
  @result.removed << sidecar
336
356
  else
@@ -380,8 +400,25 @@ module Rails
380
400
 
381
401
  # The pipeline's only delete path, so it is gated on the file still
382
402
  # matching the content the lock recorded: hand-edited work is reported and
383
- # left for its owner to remove. Runs before orphans are carried, or a
384
- # removed file would read as one.
403
+ # left for its owner to remove. The dest's sidecar goes with it, since
404
+ # nothing would reference or clean it afterwards.
405
+ def remove_or_carry(entry)
406
+ file = abs(entry.path)
407
+ return unless File.exist?(file)
408
+
409
+ if DriftVerdict.unedited?(file, lock_entry: entry)
410
+ @shell.remove_file entry.path
411
+ @result.removed << entry.path
412
+ sweep_sidecar_at(entry.path, [entry.source_sha])
413
+ prune_empty_dirs(entry.path)
414
+ else
415
+ @result.skipped << entry.path
416
+ @shell.say_status :skip, "#{entry.path} (#{yield entry})", :yellow
417
+ @new_lock.carry(entry)
418
+ end
419
+ end
420
+
421
+ # Runs before orphans are carried, or a removed file would read as one.
385
422
  def remove_disabled
386
423
  return if additive?
387
424
 
@@ -389,54 +426,62 @@ module Rails
389
426
  type = InstallLayout::ARTIFACT_TYPES[entry.kind]
390
427
  next unless type
391
428
  next if @new_lock.entry(entry.path)
429
+ next unless InstallPlan.disabled_dest?(old_lock, type, entry.path, source_gem: entry.source_gem)
392
430
 
393
- next unless InstallPlan.disabled_dest?(old_lock, type, entry.path)
394
-
395
- file = abs(entry.path)
396
- next unless File.exist?(file)
397
-
398
- if DriftVerdict.unedited?(file, lock_entry: entry)
399
- @shell.remove_file entry.path
400
- @result.removed << entry.path
401
- prune_empty_dirs(entry.path)
402
- else
403
- @result.skipped << entry.path
404
- @shell.say_status :skip,
405
- "#{entry.path} (disabled but locally modified; delete it by hand)", :yellow
406
- @new_lock.carry(entry)
407
- end
431
+ remove_or_carry(entry) { "disabled but locally modified; delete it by hand" }
408
432
  end
409
433
  end
410
434
 
411
- # Deletion is gated on the recorded sha, so a user-edited copy is never
412
- # removed.
413
435
  def remove_stale_support_files
414
436
  return if additive?
415
437
 
416
- planned_skill_dirs = @plan.select { |e| e.type == :skill }.map { |e| File.dirname(e.dest) }
438
+ planned_skill_dirs = @plan.select { |e| e.type == :skill }.map { |e| File.dirname(e.dest) }.to_set
417
439
 
418
440
  old_lock.each_entry do |entry|
419
441
  next unless entry.kind == "skill_support"
420
442
  next if @new_lock.entry(entry.path)
421
443
  next unless planned_skill_dirs.include?(InstallLayout.skill_dir_of(entry.path))
422
444
 
423
- file = abs(entry.path)
424
- next unless File.exist?(file)
445
+ remove_or_carry(entry) do |e|
446
+ "no longer shipped by #{e.source_label} but locally modified; delete it by hand"
447
+ end
448
+ end
449
+ end
425
450
 
426
- if DriftVerdict.unedited?(file, lock_entry: entry)
427
- @shell.remove_file entry.path
428
- @result.removed << entry.path
429
- prune_empty_dirs(entry.path)
430
- else
431
- @result.skipped << entry.path
432
- @shell.say_status :skip,
433
- "#{entry.path} (no longer shipped by #{entry.source_label} but locally modified; delete it by hand)",
434
- :yellow
435
- @new_lock.carry(entry)
451
+ # An artifact that moved — renamed, or flipped between its canonical and
452
+ # postfixed destination — leaves a byte-duplicate copy at the old
453
+ # destination. Removal also requires the source gem to be bundled and to
454
+ # have lost nothing to a discovery skip, so a broken companion release or
455
+ # a version fence never removes a good install.
456
+ def remove_stale_dests
457
+ return if additive?
458
+
459
+ planned = @plan.flat_map { |e| [e.dest, *e.support_files.map { |f| f[:dest] }] }.to_set
460
+
461
+ old_lock.each_entry do |entry|
462
+ next unless InstallLayout::ARTIFACT_TYPES[entry.kind]
463
+ next if planned.include?(entry.path)
464
+ next if @new_lock.entry(entry.path)
465
+ next if already_removed?(entry.path)
466
+ next unless source_gem_converged?(entry)
467
+
468
+ remove_or_carry(entry) do |e|
469
+ "#{e.source_gem} no longer installs this path but it is locally modified; delete it by hand"
436
470
  end
437
471
  end
438
472
  end
439
473
 
474
+ def source_gem_converged?(entry)
475
+ name = entry.source_gem.to_s
476
+ @bundled_gems.include?(name) && !@skipped_gems.include?(name)
477
+ end
478
+
479
+ # A dry run deletes nothing, so a path an earlier removal step already
480
+ # claimed is still on disk and must not be reported a second time.
481
+ def already_removed?(path)
482
+ @result.removed.include?(path)
483
+ end
484
+
440
485
  # Skill directories go only once empty; any user file keeps the whole
441
486
  # chain alive. The just-removed entry is subtracted because a dry run
442
487
  # deletes nothing from disk.
@@ -455,19 +500,27 @@ module Rails
455
500
  end
456
501
 
457
502
  def carry_orphans
458
- planned = @plan.map(&:dest)
503
+ planned = @plan.map(&:dest).to_set
459
504
  old_lock.each_entry do |entry|
460
505
  next if planned.include?(entry.path)
461
506
  next if @new_lock.entry(entry.path)
507
+ next if already_removed?(entry.path)
462
508
  next unless DriftVerdict.verdict(file: abs(entry.path), lock_entry: entry, gem_sha: nil) == :orphaned
463
509
 
464
510
  @result.orphaned << entry.path
465
- @shell.say_status :orphan,
466
- "#{entry.path} (no longer shipped by #{entry.source_label}; left in place)", :yellow
511
+ @shell.say_status :orphan, "#{entry.path} (#{orphan_reason(entry)}; left in place)", :yellow
467
512
  @new_lock.carry(entry)
468
513
  end
469
514
  end
470
515
 
516
+ def orphan_reason(entry)
517
+ LockFile.orphan_reason(
518
+ source_label: entry.source_label,
519
+ source_gem: entry.source_gem,
520
+ bundled: @bundled_gems.include?(entry.source_gem.to_s)
521
+ )
522
+ end
523
+
471
524
  # Returns the eager set: the guidelines index.md ends up including.
472
525
  def write_index_md(guidelines)
473
526
  return amend_index_md(guidelines) if additive?
@@ -536,17 +589,28 @@ module Rails
536
589
  @shell.create_file InstallLayout::LOCK_PATH, @new_lock.to_yaml
537
590
  end
538
591
 
592
+ # A skip dropped shipped content — a whole artifact, or one supporting
593
+ # file of one; an advisory names a manifest problem that changed nothing
594
+ # about what installed.
539
595
  def print_warnings
540
- return if Array(@warnings).empty?
596
+ skips = Array(@report.skips)
597
+ advisories = @report.advisories
598
+ print_warning_group skips, "discovery skipped #{skips.size} item(s):"
599
+ print_warning_group advisories, "discovery reported #{advisories.size} advisory warning(s):"
600
+ end
601
+
602
+ def print_warning_group(lines, header)
603
+ return if lines.empty?
541
604
  @shell.say ""
542
- @shell.say_status :warn, "discovery skipped #{@warnings.size} artifact(s):", :yellow
543
- @warnings.each { |w| @shell.say " - #{w}" }
605
+ @shell.say_status :warn, header, :yellow
606
+ lines.each { |line| @shell.say " - #{line}" }
544
607
  end
545
608
 
546
609
  def print_notices
547
- return if additive? || Array(@notices).empty?
610
+ notices = Array(@report.notices)
611
+ return if additive? || notices.empty?
548
612
  @shell.say ""
549
- @notices.each { |n| @shell.say_status :info, n, :blue }
613
+ notices.each { |n| @shell.say_status :info, n, :blue }
550
614
  end
551
615
 
552
616
  def print_footprint(guidelines:)
@@ -7,19 +7,20 @@ module Rails
7
7
  # shipped by several installs every variant, postfixed --<source_gem>.
8
8
  module InstallPlan
9
9
  Entry = Struct.new(:type, :artifact, :final_name, :dest, :collision, keyword_init: true) do
10
- # The renamed name: is part of the hashed body, so a postfixed skill
10
+ # The renamed name: is part of the hashed body, so a postfixed artifact
11
11
  # hashes to a stable value.
12
12
  def install_ready_body
13
13
  body = BundlerArtifactDiscovery.install_ready_body(artifact)
14
- return body unless type == :skill && final_name != artifact.name
14
+ return body unless kind&.collision_rewrites_name && final_name != artifact.name
15
15
  body.sub(/^name:\s*.+$/, "name: #{final_name}")
16
16
  end
17
17
 
18
18
  def support_files
19
- return [] unless type == :skill
19
+ return [] unless kind&.dir_shaped?
20
20
  dir = File.dirname(dest)
21
21
  Array(artifact.support_files).map do |file|
22
- { path: file[:path], body: file[:body], dest: "#{dir}/#{file[:path]}" }
22
+ { path: file[:path], body: file[:body], dest: "#{dir}/#{file[:path]}",
23
+ source_relpath: file[:source_relpath] }
23
24
  end
24
25
  end
25
26
 
@@ -38,6 +39,10 @@ module Rails
38
39
  def artifact_kind
39
40
  type.to_s
40
41
  end
42
+
43
+ def kind
44
+ InstallLayout.kind(type)
45
+ end
41
46
  end
42
47
 
43
48
  Result = Struct.new(:entries, :disabled, keyword_init: true)
@@ -45,7 +50,9 @@ module Rails
45
50
  module_function
46
51
 
47
52
  # A collision installs under a postfixed name, so the shipped name
48
- # disables every variant and a postfixed name disables just one.
53
+ # disables every variant and a postfixed name disables just one. The
54
+ # postfixed form is honored whether or not a collision exists today, so
55
+ # the opt-out survives a collision appearing or resolving.
49
56
  def build(artifacts, lock: nil)
50
57
  result = Result.new(entries: [], disabled: [])
51
58
  artifacts.group_by { |a| [a.artifact_type, a.name] }.each do |(type, name), group|
@@ -59,19 +66,24 @@ module Rails
59
66
  dest: InstallLayout.dest_for(type, final_name),
60
67
  collision: collision
61
68
  )
62
- dropped = lock && (lock.disabled?(type, name) || (collision && lock.disabled?(type, final_name)))
69
+ dropped = lock && (lock.disabled?(type, name) ||
70
+ lock.disabled?(type, InstallLayout.postfixed_name(name, artifact.source_gem)))
63
71
  (dropped ? result.disabled : result.entries) << entry
64
72
  end
65
73
  end
66
74
  result
67
75
  end
68
76
 
69
- # A supporting file is disabled through its owning skill's name.
70
- def disabled_dest?(lock, type, dest)
77
+ # A supporting file is disabled through its owning skill's name. Given the
78
+ # installing gem, a canonically-installed file also answers to the
79
+ # postfixed name that disabled it while it was one of several variants.
80
+ def disabled_dest?(lock, type, dest, source_gem: nil)
71
81
  name = InstallLayout.installed_name(type, dest)
72
82
  return false unless name
73
83
  lookup = type == :skill_support ? :skill : type
74
- lock.disabled?(lookup, name) || lock.disabled?(lookup, InstallLayout.base_name(name))
84
+ base = InstallLayout.base_name(name)
85
+ return true if lock.disabled?(lookup, name) || lock.disabled?(lookup, base)
86
+ !source_gem.nil? && lock.disabled?(lookup, InstallLayout.postfixed_name(base, source_gem))
75
87
  end
76
88
  end
77
89
  end