pray-cli 1.6.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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +125 -0
  5. data/SECURITY.md +28 -0
  6. data/bin/polyrun +108 -0
  7. data/bin/pray +26 -0
  8. data/lib/pray/archive.rb +68 -0
  9. data/lib/pray/auth_client.rb +97 -0
  10. data/lib/pray/cli/commands/auth.rb +42 -0
  11. data/lib/pray/cli/commands/distribution.rb +17 -0
  12. data/lib/pray/cli/commands/init.rb +62 -0
  13. data/lib/pray/cli/commands/meta.rb +15 -0
  14. data/lib/pray/cli/commands/packages.rb +107 -0
  15. data/lib/pray/cli/commands/trust.rb +73 -0
  16. data/lib/pray/cli/commands/workflow.rb +126 -0
  17. data/lib/pray/cli/help.rb +168 -0
  18. data/lib/pray/cli/helpers.rb +166 -0
  19. data/lib/pray/cli/parse.rb +136 -0
  20. data/lib/pray/cli/parse_auth.rb +91 -0
  21. data/lib/pray/cli/parse_trust.rb +152 -0
  22. data/lib/pray/cli/suggest.rb +57 -0
  23. data/lib/pray/cli.rb +117 -0
  24. data/lib/pray/confess.rb +113 -0
  25. data/lib/pray/config.rb +52 -0
  26. data/lib/pray/constraint.rb +80 -0
  27. data/lib/pray/destination.rb +158 -0
  28. data/lib/pray/dotenv.rb +46 -0
  29. data/lib/pray/environment.rb +41 -0
  30. data/lib/pray/error.rb +55 -0
  31. data/lib/pray/format_manifest.rb +255 -0
  32. data/lib/pray/format_serialize.rb +135 -0
  33. data/lib/pray/git_sources.rb +228 -0
  34. data/lib/pray/hashing.rb +59 -0
  35. data/lib/pray/invocation.rb +110 -0
  36. data/lib/pray/literal.rb +382 -0
  37. data/lib/pray/lockfile.rb +259 -0
  38. data/lib/pray/lockfile_serialize.rb +125 -0
  39. data/lib/pray/manifest.rb +126 -0
  40. data/lib/pray/manifest_formatter.rb +56 -0
  41. data/lib/pray/manifest_json.rb +128 -0
  42. data/lib/pray/manifest_parser.rb +152 -0
  43. data/lib/pray/manifest_parser_blocks.rb +216 -0
  44. data/lib/pray/manifest_parser_helpers.rb +208 -0
  45. data/lib/pray/materialize.rb +120 -0
  46. data/lib/pray/package_spec.rb +245 -0
  47. data/lib/pray/path_safety.rb +41 -0
  48. data/lib/pray/plan.rb +83 -0
  49. data/lib/pray/project_context.rb +67 -0
  50. data/lib/pray/publish.rb +162 -0
  51. data/lib/pray/registry.rb +355 -0
  52. data/lib/pray/render.rb +360 -0
  53. data/lib/pray/resolve.rb +396 -0
  54. data/lib/pray/resolve_context.rb +19 -0
  55. data/lib/pray/serve.rb +130 -0
  56. data/lib/pray/serve_federation.rb +109 -0
  57. data/lib/pray/session.rb +90 -0
  58. data/lib/pray/ssh_agent.rb +115 -0
  59. data/lib/pray/statement_surface.rb +262 -0
  60. data/lib/pray/substitute.rb +50 -0
  61. data/lib/pray/sync.rb +219 -0
  62. data/lib/pray/terminal.rb +30 -0
  63. data/lib/pray/trust.rb +201 -0
  64. data/lib/pray/trust_feed.rb +110 -0
  65. data/lib/pray/trust_ops.rb +202 -0
  66. data/lib/pray/verify.rb +257 -0
  67. data/lib/pray/version.rb +6 -0
  68. data/lib/pray-cli.rb +4 -0
  69. data/lib/pray.rb +46 -0
  70. data/pray-cli.gemspec +48 -0
  71. metadata +187 -0
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+
5
+ module Pray
6
+ module Config
7
+ PrayConfig = Struct.new(:local) do
8
+ def initialize(local: PrayLocalConfig.new)
9
+ super
10
+ end
11
+ end
12
+
13
+ PrayLocalConfig = Struct.new(:package, :source) do
14
+ def initialize(package: {}, source: {})
15
+ super
16
+ end
17
+ end
18
+
19
+ module_function
20
+
21
+ def load_user_config
22
+ path = user_config_path
23
+ return PrayConfig.new unless path && File.file?(path)
24
+
25
+ data = TomlRB.load_file(path)
26
+ PrayConfig.new(
27
+ local: PrayLocalConfig.new(
28
+ package: data.dig("local", "package") || {},
29
+ source: data.dig("local", "source") || {}
30
+ )
31
+ )
32
+ rescue TomlRB::ParseError => error
33
+ raise Error.parse("config", "#{path}: #{error.message}")
34
+ end
35
+
36
+ def user_config_path
37
+ if (path = ENV["PRAY_CONFIG"])
38
+ return path
39
+ end
40
+
41
+ if (home = ENV["PRAY_HOME"])
42
+ candidate = File.join(home, "config.toml")
43
+ return candidate if File.file?(candidate)
44
+ end
45
+
46
+ home = ENV["HOME"]
47
+ return nil unless home
48
+
49
+ File.join(home, ".config", "pray", "config.toml")
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Constraint
5
+ module_function
6
+
7
+ def normalize_version_constraint(constraint)
8
+ trimmed = constraint.strip
9
+ return trimmed if trimmed.empty? || trimmed == "*"
10
+ return trimmed if operator_prefixed?(trimmed)
11
+
12
+ if bare_semver?(trimmed)
13
+ "=#{trimmed}"
14
+ else
15
+ trimmed
16
+ end
17
+ end
18
+
19
+ def version_satisfies(version, constraint)
20
+ normalized = normalize_version_constraint(constraint)
21
+ return true if normalized.empty? || normalized == "*"
22
+
23
+ requirement = Gem::Requirement.new(normalized.strip)
24
+ requirement.satisfied_by?(Gem::Version.new(version))
25
+ rescue ArgumentError => error
26
+ raise Error.resolution(error.message)
27
+ end
28
+
29
+ def pessimistic_constraint_for_version(version)
30
+ parsed = Gem::Version.new(version)
31
+ if parsed.segments[1].to_i.zero? && parsed.segments[2].to_i.zero?
32
+ "~> #{parsed.segments[0]}.0"
33
+ else
34
+ "~> #{parsed.segments[0]}.#{parsed.segments[1]}"
35
+ end
36
+ end
37
+
38
+ def latest_constraint_for_package(current_constraint, latest_version)
39
+ normalized = normalize_version_constraint(current_constraint)
40
+ return "*" if normalized == "*"
41
+ return pessimistic_constraint_for_version(latest_version) if normalized.start_with?("~")
42
+
43
+ if normalized.start_with?("^")
44
+ parsed = Gem::Version.new(latest_version)
45
+ return "^#{parsed.segments[0]}.#{parsed.segments[1]}"
46
+ end
47
+
48
+ if normalized.start_with?("=") || bare_semver?(current_constraint.strip)
49
+ return "=#{latest_version}"
50
+ end
51
+
52
+ pessimistic_constraint_for_version(latest_version)
53
+ end
54
+
55
+ def ruby_pessimistic_to_semver(constraint)
56
+ text = constraint.strip.sub(/\A~>\s*/, "")
57
+ parts = text.split(".")
58
+ raise Error.resolution("unsupported Ruby pessimistic constraint: #{constraint}") if parts.empty? || parts.length > 3
59
+
60
+ numbers = [parts[0].to_i, (parts[1] || 0).to_i, (parts[2] || 0).to_i]
61
+ lower = numbers.join(".")
62
+ upper = case parts.length
63
+ when 1 then "#{numbers[0] + 1}.0.0"
64
+ when 2 then "#{numbers[0]}.#{numbers[1] + 1}.0"
65
+ else "#{numbers[0]}.#{numbers[1] + 1}.0"
66
+ end
67
+ ">= #{lower}, < #{upper}"
68
+ end
69
+
70
+ def operator_prefixed?(text)
71
+ text.start_with?("~>", "~", "^", "=", ">", "<") || text.include?("*")
72
+ end
73
+
74
+ def bare_semver?(text)
75
+ Gem::Version.correct?(text)
76
+ rescue
77
+ false
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ DestinationEntry = Struct.new(:kind, :name, :path) do
5
+ def self.package(name)
6
+ new(kind: "package", name: name, path: nil)
7
+ end
8
+
9
+ def self.local(path)
10
+ new(kind: "local", name: nil, path: path)
11
+ end
12
+ end
13
+
14
+ module Destination
15
+ module_function
16
+
17
+ def local_path_form?(value)
18
+ value.start_with?(".", "/") ||
19
+ value.end_with?(".md", ".txt", ".markdown") ||
20
+ !value.include?("/")
21
+ end
22
+
23
+ def destination_target_name(mode, path)
24
+ prefix = case mode
25
+ when "compose" then "compose"
26
+ when "tree" then "tree"
27
+ else "legacy"
28
+ end
29
+ "#{prefix}:#{path}"
30
+ end
31
+
32
+ def new_destination_target(mode, path)
33
+ target = ManifestTarget.new(
34
+ name: destination_target_name(mode, path),
35
+ mode: mode,
36
+ scoped: true
37
+ )
38
+ case mode
39
+ when "compose" then target.outputs << path
40
+ when "tree" then target.skills << path
41
+ end
42
+ target
43
+ end
44
+
45
+ def upsert_package(manifest, package)
46
+ existing = manifest.packages.find { |candidate| candidate.name == package.name }
47
+ unless existing
48
+ manifest.packages << package
49
+ return
50
+ end
51
+
52
+ if existing.constraint != package.constraint &&
53
+ existing.constraint != "*" &&
54
+ package.constraint != "*"
55
+ raise Error.manifest(
56
+ "package #{package.name} declared with conflicting constraints " \
57
+ "(#{existing.constraint} vs #{package.constraint})"
58
+ )
59
+ end
60
+ existing.constraint = package.constraint if existing.constraint == "*" && package.constraint != "*"
61
+
62
+ if existing.source.nil?
63
+ existing.source = package.source
64
+ elsif package.source && existing.source != package.source
65
+ raise Error.manifest("package #{package.name} declared with conflicting sources")
66
+ end
67
+
68
+ package.exports.each do |export|
69
+ existing.exports << export unless existing.exports.include?(export)
70
+ end
71
+ package.roles.each do |role|
72
+ existing.roles << role unless existing.roles.include?(role)
73
+ end
74
+
75
+ if package.file
76
+ if existing.file && existing.file != package.file
77
+ raise Error.manifest(
78
+ "package #{package.name} declared with conflicting file: destinations"
79
+ )
80
+ end
81
+ existing.file = package.file
82
+ end
83
+
84
+ existing.bound = existing.bound || package.bound
85
+ existing.optional = existing.optional || package.optional
86
+ existing.path ||= package.path
87
+ existing.git ||= package.git
88
+ existing.tag ||= package.tag
89
+ existing.rev ||= package.rev
90
+ existing.tarball ||= package.tarball
91
+ existing.oci ||= package.oci
92
+ package.groups.each do |group|
93
+ existing.groups << group unless existing.groups.include?(group)
94
+ end
95
+ end
96
+
97
+ def upsert_local(manifest, local)
98
+ existing = manifest.local.find { |candidate| candidate.path == local.path }
99
+ unless existing
100
+ manifest.local << local
101
+ return
102
+ end
103
+
104
+ existing.bound = existing.bound || local.bound
105
+ existing.optional = existing.optional || local.optional
106
+ if existing.position == "after" && local.position != "after"
107
+ existing.position = local.position
108
+ end
109
+ end
110
+
111
+ def bind_package_entry(target, package_name)
112
+ entry = DestinationEntry.package(package_name)
113
+ return if target.entries.any? { |candidate| candidate.kind == "package" && candidate.name == package_name }
114
+
115
+ target.entries << entry
116
+ end
117
+
118
+ def bind_local_entry(target, path)
119
+ return if target.entries.any? { |candidate| candidate.kind == "local" && candidate.path == path }
120
+
121
+ target.entries << DestinationEntry.local(path)
122
+ end
123
+
124
+ def role_for_destination(mode)
125
+ case mode
126
+ when "compose" then "fragment"
127
+ when "tree" then "folder"
128
+ end
129
+ end
130
+
131
+ def package_bound_to_compose?(package, target)
132
+ if target.scoped && target.mode == "compose"
133
+ return target.entries.any? { |entry| entry.kind == "package" && entry.name == package.name }
134
+ end
135
+ return false if package.bound || package.file
136
+
137
+ true
138
+ end
139
+
140
+ def package_bound_to_tree?(package, target)
141
+ if target.scoped && target.mode == "tree"
142
+ return target.entries.any? { |entry| entry.kind == "package" && entry.name == package.name }
143
+ end
144
+ return false if package.bound || package.file
145
+
146
+ true
147
+ end
148
+
149
+ def export_kind_matches_role?(kind, role)
150
+ case role
151
+ when "fragment" then kind == "fragment"
152
+ when "folder" then %w[folder skill].include?(kind)
153
+ when "file" then kind == "file"
154
+ else false
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Dotenv
5
+ module_function
6
+
7
+ def load_dotenv_variables(project_root_hint)
8
+ path = File.join(project_root_hint, ".env")
9
+ return {} unless File.file?(path)
10
+
11
+ text = File.read(path)
12
+ parse_dotenv_text(text)
13
+ rescue
14
+ {}
15
+ end
16
+
17
+ def parse_dotenv_text(text)
18
+ variables = {}
19
+ text.each_line do |line|
20
+ trimmed = line.strip
21
+ next if trimmed.empty? || trimmed.start_with?("#")
22
+
23
+ assignment = trimmed.delete_prefix("export ").strip
24
+ key, value = assignment.split("=", 2)
25
+ next unless value
26
+
27
+ key = key.strip
28
+ next if key.empty?
29
+ next unless key.start_with?("PRAY_")
30
+
31
+ variables[key] = parse_dotenv_value(value.strip)
32
+ end
33
+ variables
34
+ end
35
+
36
+ def parse_dotenv_value(value)
37
+ if value.length >= 2
38
+ quote = value[0]
39
+ if (quote == '"' || quote == "'") && value[-1] == quote
40
+ return value[1...-1]
41
+ end
42
+ end
43
+ value
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Environment
5
+ module_function
6
+
7
+ def package_matches_environment?(groups, environment)
8
+ return true if groups.empty?
9
+
10
+ return false if environment.nil?
11
+
12
+ groups.any? { |group| group == environment }
13
+ end
14
+
15
+ def collect_group_names(manifest)
16
+ manifest.packages.flat_map(&:groups).uniq.sort
17
+ end
18
+
19
+ def validate_environment(manifest, environment)
20
+ return if environment.nil?
21
+
22
+ if environment.empty?
23
+ raise Error.resolution("environment name cannot be empty")
24
+ end
25
+
26
+ known_groups = collect_group_names(manifest)
27
+ if known_groups.empty?
28
+ raise Error.resolution("unknown environment #{environment}; Prayfile defines no groups")
29
+ end
30
+ return if known_groups.include?(environment)
31
+
32
+ raise Error.resolution(
33
+ "unknown environment #{environment}; available groups are #{known_groups.join(", ")}"
34
+ )
35
+ end
36
+
37
+ def should_render_package?(declaration, environment)
38
+ package_matches_environment?(declaration.groups, environment)
39
+ end
40
+ end
41
+ end
data/lib/pray/error.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ class Error < StandardError
5
+ def initialize(category, message, parse_kind: nil)
6
+ @category = category
7
+ @parse_kind = parse_kind
8
+ super(message)
9
+ end
10
+
11
+ attr_reader :category
12
+
13
+ def exit_code
14
+ case category
15
+ when :parse, :usage then 2
16
+ when :manifest, :io then 1
17
+ when :resolution then 3
18
+ when :integrity then 4
19
+ when :render then 5
20
+ when :verify then 6
21
+ when :unsupported then 8
22
+ else 1
23
+ end
24
+ end
25
+
26
+ def to_s
27
+ prefix = case category
28
+ when :parse then "#{@parse_kind} parse error"
29
+ when :usage then "usage error"
30
+ when :manifest then "manifest error"
31
+ when :resolution then "resolution error"
32
+ when :integrity then "integrity error"
33
+ when :render then "render error"
34
+ when :verify then "verify error"
35
+ when :unsupported then "unsupported feature"
36
+ when :io then "I/O error"
37
+ else category.to_s
38
+ end
39
+ "#{prefix}: #{super}"
40
+ end
41
+
42
+ def self.parse(kind, message)
43
+ new(:parse, message, parse_kind: kind)
44
+ end
45
+
46
+ def self.manifest(message) = new(:manifest, message)
47
+ def self.resolution(message) = new(:resolution, message)
48
+ def self.integrity(message) = new(:integrity, message)
49
+ def self.render(message) = new(:render, message)
50
+ def self.verify(message) = new(:verify, message)
51
+ def self.unsupported(message) = new(:unsupported, message)
52
+ def self.usage(message) = new(:usage, message)
53
+ def self.io(error) = new(:io, error.message)
54
+ end
55
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ PackageFormatHint = Struct.new(:roles, :file_path, :exports) do
5
+ def initialize(roles: [], file_path: nil, exports: [])
6
+ super
7
+ end
8
+ end
9
+
10
+ module FormatManifest
11
+ module_function
12
+
13
+ def uses_destination_dsl?(manifest)
14
+ manifest.targets.any? { |target| target.scoped || target.mode != "legacy" } ||
15
+ manifest.packages.any? { |package| package.bound || package.file } ||
16
+ manifest.local.any?(&:bound)
17
+ end
18
+
19
+ def classify_format_hints(project)
20
+ hints = {}
21
+ project.packages.each do |package|
22
+ roles = []
23
+ file_path = package.declaration.file
24
+ package.selected_exports.each do |export_name|
25
+ export = package.spec.exports[export_name]
26
+ next unless export
27
+
28
+ %w[fragment folder file].each do |role|
29
+ if Destination.export_kind_matches_role?(export.kind, role) && !roles.include?(role)
30
+ roles << role
31
+ end
32
+ end
33
+ if file_path.nil? && export.kind == "file"
34
+ file_path = export.default_path || export_name
35
+ end
36
+ end
37
+ hints[package.declaration.name] = PackageFormatHint.new(
38
+ roles: roles,
39
+ file_path: file_path,
40
+ exports: ambiguous_exports_for_roles(package.selected_exports, package.spec.exports, roles)
41
+ )
42
+ end
43
+ hints
44
+ end
45
+
46
+ def recommend_manifest(manifest, hints)
47
+ recommended = if has_migratable_legacy_targets?(manifest)
48
+ migrate_legacy_manifest(manifest, hints)
49
+ else
50
+ deep_clone_manifest(manifest)
51
+ end
52
+ omit_context_resolved_exports(recommended)
53
+ omit_default_sources(recommended)
54
+ recommended
55
+ end
56
+
57
+ def format_recommended(manifest, hints)
58
+ recommended = recommend_manifest(manifest, hints)
59
+ text = FormatSerialize.serialize_recommended(recommended)
60
+ reparsed = Pray.parse_manifest(text)
61
+ if reparsed.manifest_hash != recommended.manifest_hash
62
+ raise Error.manifest("formatted Prayfile did not round-trip to an equivalent manifest")
63
+ end
64
+
65
+ text
66
+ end
67
+
68
+ def has_migratable_legacy_targets?(manifest)
69
+ manifest.targets.any? do |target|
70
+ !target.scoped && target.mode == "legacy" &&
71
+ (!target.outputs.empty? || !target.skills.empty?)
72
+ end
73
+ end
74
+
75
+ def migrate_legacy_manifest(manifest, hints)
76
+ next_manifest = Manifest.new(
77
+ prayfile_version: manifest.prayfile_version,
78
+ sources: manifest.sources.map { |source| source.dup },
79
+ targets: [],
80
+ packages: manifest.packages.map { |package| clone_package(package) },
81
+ local: manifest.local.map { |local| local.dup },
82
+ symbols: manifest.symbols.dup,
83
+ render: manifest.render.dup
84
+ )
85
+
86
+ apply_format_hints(next_manifest.packages, hints)
87
+
88
+ compose_paths = unique_paths(
89
+ manifest.targets.flat_map { |target| target.outputs.map { |path| [path, target.name] } }
90
+ )
91
+ tree_paths = unique_paths(
92
+ manifest.targets.flat_map { |target| target.skills.map { |path| [path, target.name] } }
93
+ )
94
+
95
+ compose_paths.each do |path, target_names|
96
+ target = Destination.new_destination_target("compose", path)
97
+ locals_for_compose(next_manifest.local).each do |local|
98
+ Destination.bind_local_entry(target, local.path)
99
+ entry = next_manifest.local.find { |candidate| candidate.path == local.path }
100
+ entry.bound = true if entry
101
+ end
102
+ packages_for_role(next_manifest.packages, "fragment", target_names).each do |package|
103
+ Destination.bind_package_entry(target, package.name)
104
+ mark_package_bound(next_manifest.packages, package.name, "fragment")
105
+ end
106
+ next_manifest.targets << target
107
+ end
108
+
109
+ tree_paths.each do |path, target_names|
110
+ target = Destination.new_destination_target("tree", path)
111
+ packages_for_role(next_manifest.packages, "folder", target_names).each do |package|
112
+ Destination.bind_package_entry(target, package.name)
113
+ mark_package_bound(next_manifest.packages, package.name, "folder")
114
+ end
115
+ next_manifest.targets << target
116
+ end
117
+
118
+ next_manifest.packages.each do |package|
119
+ next unless package.file
120
+
121
+ package.bound = true
122
+ package.roles << "file" unless package.roles.include?("file")
123
+ end
124
+ next_manifest.local.each do |local|
125
+ local.position = "after" if local.bound
126
+ end
127
+
128
+ manifest.targets.each do |target|
129
+ next unless FormatSerialize.target_has_extras?(target)
130
+
131
+ next_manifest.targets << ManifestTarget.new(
132
+ name: target.name,
133
+ commands: target.commands.dup,
134
+ rules: target.rules.dup,
135
+ max_bytes: target.max_bytes,
136
+ mode: "legacy",
137
+ scoped: false
138
+ )
139
+ end
140
+
141
+ next_manifest
142
+ end
143
+
144
+ def apply_format_hints(packages, hints)
145
+ packages.each do |package|
146
+ hint = hints[package.name]
147
+ if hint
148
+ hint.roles.each do |role|
149
+ package.roles << role unless package.roles.include?(role)
150
+ end
151
+ package.file ||= hint.file_path
152
+ if package.exports.empty? && !hint.exports.empty?
153
+ package.exports = hint.exports.dup
154
+ end
155
+ end
156
+ if package.file && !package.roles.include?("file")
157
+ package.roles << "file"
158
+ end
159
+ end
160
+ end
161
+
162
+ def omit_context_resolved_exports(manifest)
163
+ manifest.packages.each do |package|
164
+ package.exports.clear if package.bound && package.exports.length <= 1
165
+ end
166
+ end
167
+
168
+ def omit_default_sources(manifest)
169
+ sole_source = (manifest.sources.length == 1) ? manifest.sources.first.name : nil
170
+ source_names = manifest.sources.map(&:name).to_set
171
+ manifest.packages.each do |package|
172
+ source = package.source
173
+ next unless source
174
+
175
+ matches_sole = sole_source == source
176
+ namespace = package.name.split("/", 2).first
177
+ matches_namespace = namespace == source && source_names.include?(source)
178
+ package.source = nil if matches_sole || matches_namespace
179
+ end
180
+ end
181
+
182
+ def unique_paths(items)
183
+ map = {}
184
+ items.each do |path, target_name|
185
+ map[path] ||= Set.new
186
+ map[path] << target_name
187
+ end
188
+ map.sort.map { |path, names| [path, names] }
189
+ end
190
+
191
+ def locals_for_compose(locals)
192
+ before = []
193
+ after = []
194
+ locals.each do |local|
195
+ next if local.bound
196
+
197
+ if %w[start before].include?(local.position)
198
+ before << local
199
+ else
200
+ after << local
201
+ end
202
+ end
203
+ before + after
204
+ end
205
+
206
+ def packages_for_role(packages, role, target_names)
207
+ packages.select do |package|
208
+ next false if package.file
209
+ if !package.targets.empty? && package.targets.none? { |name| target_names.include?(name) }
210
+ next false
211
+ end
212
+
213
+ package.roles.include?(role)
214
+ end
215
+ end
216
+
217
+ def mark_package_bound(packages, name, role)
218
+ package = packages.find { |entry| entry.name == name }
219
+ return unless package
220
+
221
+ package.bound = true
222
+ package.roles << role unless package.roles.include?(role)
223
+ end
224
+
225
+ def ambiguous_exports_for_roles(selected_exports, exports, roles)
226
+ ambiguous = []
227
+ roles.each do |role|
228
+ matching = selected_exports.select do |export_name|
229
+ export = exports[export_name]
230
+ export && Destination.export_kind_matches_role?(export.kind, role)
231
+ end
232
+ next unless matching.length > 1
233
+
234
+ matching.each do |export_name|
235
+ ambiguous << export_name unless ambiguous.include?(export_name)
236
+ end
237
+ end
238
+ ambiguous
239
+ end
240
+
241
+ def deep_clone_manifest(manifest)
242
+ Marshal.load(Marshal.dump(manifest))
243
+ end
244
+
245
+ def clone_package(package)
246
+ package.dup.tap do |copy|
247
+ copy.exports = package.exports.dup
248
+ copy.targets = package.targets.dup
249
+ copy.features = package.features.dup
250
+ copy.groups = package.groups.dup
251
+ copy.roles = package.roles.dup
252
+ end
253
+ end
254
+ end
255
+ end