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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +299 -4
- data/README.md +71 -36
- data/lib/generators/hyperdrive/install/USAGE +11 -3
- data/lib/generators/hyperdrive/install/install_generator.rb +25 -10
- data/lib/generators/hyperdrive/install_summary.rb +3 -3
- data/lib/generators/hyperdrive/sync/USAGE +7 -5
- data/lib/generators/hyperdrive/sync/sync_generator.rb +1 -1
- data/lib/generators/hyperdrive/sync_runner.rb +20 -10
- data/lib/hyperdrive/skill_tasks.rb +39 -0
- data/lib/rails/commands/hyperdrive/hyperdrive_command.rb +59 -0
- data/lib/rails/hyperdrive/ancestor_locator.rb +10 -13
- data/lib/rails/hyperdrive/artifact_status.rb +16 -7
- data/lib/rails/hyperdrive/auto_install.rb +25 -7
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +360 -150
- data/lib/rails/hyperdrive/canonical_skill_render.rb +68 -39
- data/lib/rails/hyperdrive/companion_discovery.rb +4 -5
- data/lib/rails/hyperdrive/gem_manifest.rb +311 -80
- data/lib/rails/hyperdrive/gemspec_locator.rb +38 -0
- data/lib/rails/hyperdrive/install_layout.rb +148 -12
- data/lib/rails/hyperdrive/install_pipeline.rb +142 -78
- data/lib/rails/hyperdrive/install_plan.rb +21 -9
- data/lib/rails/hyperdrive/lock_file.rb +25 -3
- data/lib/rails/hyperdrive/manifest_lint.rb +230 -0
- data/lib/rails/hyperdrive/skill_template.rb +7 -5
- data/lib/rails/hyperdrive/tools/run_sql.rb +1 -1
- data/lib/rails/hyperdrive/version.rb +1 -1
- data/lib/rails/hyperdrive.rb +0 -22
- metadata +9 -8
- data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +0 -3
- data/lib/rails/hyperdrive/skill_tasks.rb +0 -27
- 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
|
|
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
|
-
|
|
15
|
+
DEFAULT_TARGET = :claude
|
|
13
16
|
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
@
|
|
37
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
install_file(
|
|
142
|
-
|
|
143
|
-
|
|
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)) && !
|
|
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
|
|
323
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
384
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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,
|
|
543
|
-
|
|
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
|
-
|
|
610
|
+
notices = Array(@report.notices)
|
|
611
|
+
return if additive? || notices.empty?
|
|
548
612
|
@shell.say ""
|
|
549
|
-
|
|
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
|
|
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
|
|
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
|
|
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) ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|