ace-lab 0.13.1 → 0.14.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.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "fileutils"
5
+ require "pathname"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # Shared extension-projection primitives (task 8xs.t.pu0.0): the
11
+ # ONE place that knows how the installed gem's handbook/extensions
12
+ # tree is located, enumerated, and projected safely — used by both
13
+ # ExtensionProvisioner (convergence) and Verify (readiness), so
14
+ # the two can never drift.
15
+ module ExtensionProjection
16
+ RECEIPT_NAME = ".ace-lab-extension-projection.json"
17
+
18
+ module_function
19
+
20
+ # The installed gem's own tree at the EXACT declared version.
21
+ # @return [String, nil] the handbook/extensions directory or nil
22
+ # when the gem (or its extensions tree) is not installed
23
+ def gem_source_root(gem_home, name, version)
24
+ candidate = File.join(gem_home, "gems", "#{name}-#{version}")
25
+ extensions = File.join(candidate, "handbook", "extensions")
26
+ File.directory?(extensions) ? extensions : nil
27
+ end
28
+
29
+ # Relative path → absolute source file for every file under the
30
+ # gem's handbook/extensions tree (e.g. "ace-wake/index.js").
31
+ # @return [Hash<String, String>]
32
+ def source_files(source_root)
33
+ Dir.glob(File.join(source_root, "**", "*")).select { |path| File.file?(path) }
34
+ .to_h { |path| [path.delete_prefix("#{source_root}/"), path] }
35
+ end
36
+
37
+ # Resolve and fully validate a projection destination before
38
+ # anything is written or verified (the provider_syncer
39
+ # validate_projection_destination posture): every path
40
+ # component under the projection directory must be a directory
41
+ # or creatable — a symlinked component could redirect the
42
+ # write outside the extensions directory, so it is refused.
43
+ # @param output_dir [String] manifest-declared extension dir
44
+ # @param relative_path [String] receipt-relative file path
45
+ # @return [String] the validated absolute destination
46
+ # @raise [Ace::Lab::Error] invalid or escaping destination
47
+ def validate_destination!(output_dir, relative_path)
48
+ segments = relative_path.to_s.split(File::SEPARATOR)
49
+ if segments.empty? || segments.any? { |segment| segment.empty? || segment == "." || segment == ".." }
50
+ raise Ace::Lab::Error, "cannot project #{relative_path.inspect}: invalid destination path"
51
+ end
52
+
53
+ begin
54
+ current = Pathname.new(File.realpath(output_dir))
55
+ rescue Errno::ENOENT
56
+ # the projection directory itself does not exist yet — no
57
+ # component below it can escape
58
+ return File.join(output_dir, *segments)
59
+ end
60
+ segments.each_with_index do |segment, index|
61
+ current = current.join(segment)
62
+ if File.symlink?(current.to_s)
63
+ raise Ace::Lab::Error,
64
+ "cannot project #{relative_path} into #{output_dir}: a symlinked path component " \
65
+ "would escape the projection directory"
66
+ end
67
+ # The FINAL segment is the file itself — only intermediate
68
+ # components must be directories (compare by position, not
69
+ # value: "a/a/index.js" repeats "a" legitimately).
70
+ next if index == segments.size - 1
71
+
72
+ if !current.directory? && File.exist?(current.to_s)
73
+ raise Ace::Lab::Error,
74
+ "cannot project #{relative_path} into #{output_dir}: #{current} exists and is not a directory"
75
+ end
76
+ end
77
+ if File.directory?(current.to_s)
78
+ # Receipt ownership of a path never authorizes writing
79
+ # through a directory planted at that path — cp would
80
+ # reinterpret the destination as a container.
81
+ raise Ace::Lab::Error,
82
+ "cannot project #{relative_path} into #{output_dir}: #{current} exists and is a directory"
83
+ end
84
+ current.to_s
85
+ rescue Errno::ENOENT
86
+ raise Ace::Lab::Error, "cannot project #{relative_path} into #{output_dir}: projection directory vanished"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "extension_projection"
5
+ require "etc"
6
+ require "fileutils"
7
+ require "json"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # Projects harness extension assets from an INSTALLED gem's exact
13
+ # declared version into the manifest-declared extension directory
14
+ # (task 8xs.t.pu0.0): the manual host-lane copy of the ace-wake
15
+ # extension is replaced by a converged, repeatable projection.
16
+ #
17
+ # Source of truth is the installed gem tree itself —
18
+ # `<gem_home>/gems/<name>-<version>/handbook/extensions` — so the
19
+ # projection always matches the exact version the manifest
20
+ # declared (never the checkout, never a newer gem beside it).
21
+ # Each target directory carries a receipt
22
+ # (`.ace-lab-extension-projection.json`) recording the producing
23
+ # gem, version and file list; ONLY receipt-listed files are ever
24
+ # removed, so user-authored extensions in the same directory are
25
+ # never touched. The report names the version that WAS loaded
26
+ # before this run (`loaded_version`) and whether the projection
27
+ # changed (`restart_required`) — a changed projection means the
28
+ # running harness processes still hold the OLD extension; the
29
+ # installer REPORTS the restart need, it never restarts an
30
+ # executor on its own.
31
+ class ExtensionProvisioner
32
+ RECEIPT_NAME = ExtensionProjection::RECEIPT_NAME
33
+ FILE_MODE = 0o644
34
+ DIR_MODE = 0o755
35
+
36
+ # @param gem_home [String] target runtime the gems were
37
+ # converged into
38
+ # @param dry_run [Boolean] predict without writing
39
+ # @param account_lookup [Proc, nil] passwd lookup seam for the
40
+ # declared accounts (projection ownership convergence)
41
+ def initialize(gem_home:, dry_run: false, account_lookup: nil)
42
+ @gem_home = gem_home
43
+ @dry_run = dry_run
44
+ @account_lookup = account_lookup || method(:lookup_passwd)
45
+ end
46
+
47
+ # @param extensions [Array<Hash>] normalized manifest gemset
48
+ # extensions entries ({"gem" =>, "path" =>, "account" =>})
49
+ # @param gems [Array<Hash>] declared gem set (name/version)
50
+ # @return [Array<Hash>] one report per entry:
51
+ # {extension:, gem:, version:, path:, account:, status:,
52
+ # loaded_version:, restart_required:, files:, message:}
53
+ def call(extensions:, gems:)
54
+ extensions.map do |entry|
55
+ converge(entry, gems)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def converge(entry, gems)
62
+ declared = gems.find { |gem| gem["name"] == entry["gem"] }
63
+ version = declared && declared["version"]
64
+ source = version && ExtensionProjection.gem_source_root(@gem_home, entry["gem"], version)
65
+
66
+ if source.nil?
67
+ # Fresh-lab dry run (the gem phase predicts would_install
68
+ # for the same missing gem): predict the projection the
69
+ # converged gem would feed instead of reporting a failure
70
+ # the wet run would not produce.
71
+ if @dry_run
72
+ return entry_report(entry, version, "would_project", nil, true, [])
73
+ end
74
+ return failed(entry, version,
75
+ "gem '#{entry["gem"]}' is not installed in runtime '#{@gem_home}' at declared " \
76
+ "version '#{version}'; converge the gemset first (ace-lab install)")
77
+ end
78
+
79
+ expected = ExtensionProjection.source_files(source)
80
+ if expected.empty?
81
+ return failed(entry, version,
82
+ "gem '#{entry["gem"]}' #{version} carries no handbook/extensions tree")
83
+ end
84
+
85
+ output_dir = entry["path"]
86
+ escape = projection_escape_reason(output_dir, expected.keys)
87
+ return failed(entry, version, escape) if escape
88
+
89
+ receipt = read_receipt(output_dir)
90
+ loaded_version = receipt && receipt["gem"] == entry["gem"] ? receipt["version"] : nil
91
+
92
+ changed = projection_changed?(output_dir, expected, receipt, version)
93
+ if !changed
94
+ converge_modes(output_dir, expected, entry)
95
+ return entry_report(entry, version, "unchanged", loaded_version, false,
96
+ receipt ? receipt.fetch("files") : expected.keys)
97
+ end
98
+ # dry-run predicts the projection (restart needed) without
99
+ # writing anything
100
+ return entry_report(entry, version, "projected", loaded_version, true, expected.keys) if @dry_run
101
+
102
+ project(output_dir, expected, entry, version)
103
+ entry_report(entry, version, "projected", loaded_version, true, expected.keys)
104
+ rescue SystemCallError => e
105
+ failed(entry, version,
106
+ "extension projection into '#{output_dir}' failed (#{e.class.name}); " \
107
+ "rerun ace-lab prepare with sudo or adjust ownership manually")
108
+ end
109
+
110
+ def failed(entry, version, message)
111
+ entry_report(entry, version, "failed", nil, false, [])
112
+ .merge("message" => message)
113
+ end
114
+
115
+ def entry_report(entry, version, status, loaded_version, restart_required, files)
116
+ {"extension" => entry["gem"], "gem" => entry["gem"], "version" => version,
117
+ "path" => entry["path"], "account" => entry["account"], "status" => status,
118
+ "loaded_version" => loaded_version, "restart_required" => restart_required,
119
+ "files" => files, "message" => nil}
120
+ end
121
+
122
+ # Refuse a destination that escapes the projection directory
123
+ # through a symlinked component BEFORE anything is written (the
124
+ # provider_syncer validate_projection_destination posture).
125
+ def projection_escape_reason(output_dir, relative_paths)
126
+ relative_paths.each { |relative| ExtensionProjection.validate_destination!(output_dir, relative) }
127
+ nil
128
+ rescue Ace::Lab::Error => e
129
+ e.message
130
+ end
131
+
132
+ # A projection changed when the receipt is absent, names a
133
+ # different version, lists a different file set, or any
134
+ # expected file's content differs on disk.
135
+ def projection_changed?(output_dir, expected, receipt, version)
136
+ return true if receipt.nil?
137
+ return true if receipt["version"] != version
138
+ return true unless receipt.fetch("files", []).sort == expected.keys.sort
139
+
140
+ expected.any? do |relative, source|
141
+ target = File.join(output_dir, relative)
142
+ !File.file?(target) || !FileUtils.compare_file(source, target)
143
+ end
144
+ end
145
+
146
+ def project(output_dir, expected, entry, version)
147
+ FileUtils.mkdir_p(output_dir)
148
+ expected.each do |relative, source|
149
+ target = File.join(output_dir, relative)
150
+ FileUtils.mkdir_p(File.dirname(target))
151
+ FileUtils.cp(source, target)
152
+ end
153
+
154
+ # Only receipt-owned files no longer in the projection are
155
+ # removed; a removal that fails keeps its receipt entry so a
156
+ # later run retries it (user-authored extensions are never
157
+ # touched).
158
+ previous = read_receipt(output_dir)&.fetch("files", []) || []
159
+ retained = previous.filter_map do |relative|
160
+ target = File.join(output_dir, relative)
161
+ next relative if expected.key?(relative)
162
+
163
+ FileUtils.rm_f(target)
164
+ File.exist?(target) ? relative : nil
165
+ end
166
+
167
+ write_receipt(output_dir, entry, version, (expected.keys + retained).sort)
168
+ converge_modes(output_dir, expected, entry)
169
+ end
170
+
171
+ # Mode/ownership convergence (the key-provisioner posture):
172
+ # projected files carry owner read/write plus world read
173
+ # (0644), projection directories 0755, and both are owned by
174
+ # the declared account whenever the passwd lookup resolves it —
175
+ # so the target executor can always read its extensions.
176
+ def converge_modes(output_dir, expected, entry)
177
+ account = @account_lookup.call(entry["account"])
178
+ targets = expected.keys.map { |relative| File.join(output_dir, relative) }
179
+ targets << File.join(output_dir, RECEIPT_NAME)
180
+ targets.each do |target|
181
+ next unless File.file?(target)
182
+
183
+ FileUtils.chmod(FILE_MODE, target)
184
+ FileUtils.chown(account.uid, account.gid, target) if account
185
+ end
186
+ root = File.realpath(output_dir)
187
+ dirs = expected.keys.filter_map do |relative|
188
+ File.dirname(File.join(root, relative)) if relative.include?(File::SEPARATOR)
189
+ end.uniq
190
+ until dirs.empty?
191
+ dir = dirs.pop
192
+ FileUtils.chmod(DIR_MODE, dir)
193
+ FileUtils.chown(account.uid, account.gid, dir) if account
194
+ parent = File.dirname(dir)
195
+ dirs << parent if dir != root && parent.start_with?("#{root}/") && !dirs.include?(parent) && parent != root
196
+ end
197
+ end
198
+
199
+ def write_receipt(output_dir, entry, version, files)
200
+ document = {
201
+ "gem" => entry["gem"], "version" => version,
202
+ "projected_at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
203
+ "files" => files.sort
204
+ }
205
+ File.write(File.join(output_dir, RECEIPT_NAME), JSON.pretty_generate(document))
206
+ end
207
+
208
+ def read_receipt(output_dir)
209
+ path = File.join(output_dir, RECEIPT_NAME)
210
+ return nil unless File.file?(path)
211
+
212
+ document = JSON.parse(File.read(path))
213
+ document.is_a?(Hash) ? document : nil
214
+ rescue JSON::ParserError, Errno::EACCES
215
+ nil
216
+ end
217
+
218
+ def lookup_passwd(username)
219
+ Etc.getpwnam(username)
220
+ rescue ArgumentError
221
+ nil
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -49,7 +49,12 @@ module Ace
49
49
  if runtime_failure
50
50
  base(gem, gem_home).merge("status" => "failed", "message" => runtime_failure)
51
51
  else
52
- converge(base(gem, gem_home), gem, gem_home)
52
+ report = converge(base(gem, gem_home), gem, gem_home)
53
+ if %w[exists installed updated].include?(report["status"])
54
+ converge_post_presence(report, gem, gem_home)
55
+ else
56
+ report
57
+ end
53
58
  end
54
59
  end
55
60
  end
@@ -78,6 +83,55 @@ module Ace
78
83
  [:ok, versions]
79
84
  end
80
85
 
86
+ # Public READ-ONLY probes (verify, task 8xs.t.pu0.0): the same
87
+ # primitives convergence runs, exposed so readiness reuses the
88
+ # identical logic instead of reimplementing it.
89
+ #
90
+ # Unsatisfied runtime dependencies of the declared gem — the
91
+ # concrete missing-dependency list readiness reports.
92
+ # @return [Array(Symbol, Object)] [:ok, reasons] (empty reasons
93
+ # = closed) or the failing probe's [:unknown/:failed,
94
+ # message] — the same shape newer_versions returns.
95
+ def closure_failures(name:, version:, gem_home:)
96
+ queue = [[name, version]]
97
+ seen = {}
98
+ reasons = []
99
+ until queue.empty?
100
+ current_name, current_version = queue.shift
101
+ next if seen[[current_name, current_version]]
102
+
103
+ seen[[current_name, current_version]] = true
104
+ deps = runtime_dependencies(name: current_name, version: current_version, gem_home: gem_home)
105
+ return [:failed, deps] if deps.is_a?(String)
106
+
107
+ deps.each do |dep|
108
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
109
+ return [kind, versions_or_message] if kind != :ok
110
+
111
+ satisfying = satisfying_versions(dep, versions_or_message)
112
+ if satisfying.empty?
113
+ reasons << "dependency '#{dep["name"]} (#{dep["requirement"]})' required by gem " \
114
+ "'#{current_name}' is not satisfied in runtime '#{gem_home}'"
115
+ else
116
+ queue << [dep["name"], satisfying.max_by { |v| Gem::Version.new(v) }]
117
+ end
118
+ end
119
+ end
120
+ [:ok, reasons]
121
+ end
122
+
123
+ # Installed versions of `name` NEWER than the declared one —
124
+ # the versions that would win activation and take over the CLI.
125
+ # @return [Array(String, Object)] [:ok, versions] or the probe's
126
+ # [:unknown/:failed, message]
127
+ def newer_versions(name:, declared_version:, gem_home:)
128
+ kind, versions_or_message = runtime_versions(name: name, gem_home: gem_home)
129
+ return [kind, versions_or_message] if kind != :ok
130
+
131
+ declared = Gem::Version.new(declared_version)
132
+ [:ok, versions_or_message.select { |version| Gem::Version.new(version) > declared }]
133
+ end
134
+
81
135
  private
82
136
 
83
137
  def base(gem, gem_home)
@@ -132,6 +186,153 @@ module Ace
132
186
  end
133
187
  end
134
188
 
189
+ # Post-presence convergence (task 8xs.t.pu0.0): the declared
190
+ # version being present is necessary but not sufficient.
191
+ # 1. NEWER versions of the declared gem sitting beside the
192
+ # declared one would win RubyGems activation and take over
193
+ # the runtime's CLI — the manifest is the single owner of
194
+ # the declared set, so convergence removes them from the
195
+ # target runtime.
196
+ # 2. The dependency closure must actually resolve in the
197
+ # runtime: a gem whose version already figures in
198
+ # `gem list` can still miss a transitive runtime
199
+ # dependency (the manually-repaired `json` class of gap);
200
+ # missing dependencies are converged by installing them
201
+ # at the parent's requirement, and an unsatisfiable
202
+ # dependency is a classified failure — never readiness.
203
+ # A dry run predicts both without writing (would_uninstall /
204
+ # would_install) and stops recursion at the prediction.
205
+ def converge_post_presence(report, gem, gem_home)
206
+ removal = converge_newer_versions(gem, gem_home)
207
+ return report.merge("status" => "failed", "message" => removal) if removal.is_a?(String)
208
+ report["removed_newer_versions"] = removal
209
+
210
+ closure = converge_closure(gem, gem_home)
211
+ return report.merge("status" => "failed", "message" => closure) if closure.is_a?(String)
212
+ report["closure"] = closure
213
+ report
214
+ end
215
+
216
+ # @return [Array<Hash>, String] per-version removal reports or a
217
+ # classified failure message
218
+ def converge_newer_versions(gem, gem_home)
219
+ kind, versions_or_message = runtime_versions(name: gem["name"], gem_home: gem_home)
220
+ return versions_or_message if kind != :ok
221
+
222
+ declared = Gem::Version.new(gem["version"])
223
+ newer = versions_or_message.select { |version| Gem::Version.new(version) > declared }
224
+ return [] if newer.empty?
225
+
226
+ return newer.map { |version| {"version" => version, "status" => "would_uninstall"} } if @dry_run
227
+
228
+ newer.map do |version|
229
+ result = @runner.call(runtime_argv(gem_home,
230
+ ["gem", "uninstall", gem["name"], "--version", version, "-x", "--force"]))
231
+ if result["status"] != "ok"
232
+ break command_failure("gem uninstall #{gem["name"]} #{version}", result)
233
+ end
234
+ {"version" => version, "status" => "uninstalled"}
235
+ end
236
+ end
237
+
238
+ # Breadth-first walk of the gem's RUNTIME dependency closure in
239
+ # the target runtime. `gem dependency` output is plain text (no
240
+ # marshaled objects), development dependencies are ignored.
241
+ # @return [Array<Hash>, String] per-dependency convergence
242
+ # actions or a classified failure message
243
+ def converge_closure(gem, gem_home)
244
+ queue = [[gem["name"], gem["version"]]]
245
+ seen = {}
246
+ actions = []
247
+ until queue.empty?
248
+ name, version = queue.shift
249
+ next if seen[[name, version]]
250
+
251
+ seen[[name, version]] = true
252
+ deps = runtime_dependencies(name: name, version: version, gem_home: gem_home)
253
+ return deps if deps.is_a?(String)
254
+
255
+ deps.each do |dep|
256
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
257
+ return versions_or_message if kind != :ok
258
+
259
+ satisfying = satisfying_versions(dep, versions_or_message)
260
+ if satisfying.empty?
261
+ if @dry_run
262
+ actions << {"name" => dep["name"], "requirement" => dep["requirement"],
263
+ "status" => "would_install"}
264
+ next # recursion stops at the prediction
265
+ end
266
+
267
+ failure = install_dependency(dep, gem_home)
268
+ return failure if failure
269
+
270
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
271
+ return versions_or_message if kind != :ok
272
+ satisfying = satisfying_versions(dep, versions_or_message)
273
+ if satisfying.empty?
274
+ return "dependency '#{dep["name"]} (#{dep["requirement"]})' required by gem " \
275
+ "'#{name}' is not satisfiable in runtime '#{gem_home}' after install; " \
276
+ "check the gem source or the manifest declaration"
277
+ end
278
+ actions << {"name" => dep["name"], "requirement" => dep["requirement"],
279
+ "status" => "installed"}
280
+ end
281
+
282
+ chosen = satisfying.max_by { |v| Gem::Version.new(v) }
283
+ queue << [dep["name"], chosen]
284
+ end
285
+ end
286
+ actions
287
+ end
288
+
289
+ # Installs a missing dependency at the parent requirement: the
290
+ # requirement string the runtime's own resolver would accept
291
+ # (`gem install name -v "~> 2.7"`).
292
+ # @return [String, nil] classified failure, nil on success
293
+ def install_dependency(dep, gem_home)
294
+ result = @runner.call(runtime_argv(gem_home,
295
+ ["gem", "install", dep["name"], "--version", dep["requirement"], "--no-document"]))
296
+ return nil if result["status"] == "ok"
297
+
298
+ command_failure("gem install #{dep["name"]}", result)
299
+ end
300
+
301
+ # The runtime (non-development) dependencies of an installed
302
+ # gem, from `gem dependency <name> --version <v>` plain-text
303
+ # output: "dep (~> 2.0, runtime)" lines under the gem header.
304
+ # @return [Array<Hash{"name","requirement"}>, String] or a
305
+ # classified failure message
306
+ def runtime_dependencies(name:, version:, gem_home:)
307
+ result = @runner.call(runtime_argv(gem_home,
308
+ ["gem", "dependency", name, "--version", version]))
309
+ if result["status"] == "enoent"
310
+ return "gem not found in PATH; install RubyGems and rerun ace-lab prepare"
311
+ end
312
+ return "gem dependency failed (#{exit_detail(result["status"])}); rerun ace-lab prepare" \
313
+ unless result["status"] == "ok"
314
+
315
+ result["out"].lines.filter_map do |line|
316
+ match = line.match(/\A\s+([A-Za-z0-9._-]+) \(([^)]*)\)\s*\z/)
317
+ next if match.nil?
318
+
319
+ parts = match[2].split(",").map(&:strip)
320
+ type = %w[runtime development].include?(parts.last) ? parts.pop : "runtime"
321
+ next if type == "development"
322
+
323
+ requirement = parts.join(", ")
324
+ requirement = ">= 0" if requirement.empty?
325
+ {"name" => match[1], "requirement" => requirement}
326
+ end
327
+ end
328
+
329
+ def satisfying_versions(dep, versions)
330
+ requirement = dep["requirement"].split(",").map(&:strip)
331
+ versions.select do |version|
332
+ Gem::Dependency.new(dep["name"], *requirement).match?(dep["name"], version)
333
+ end
334
+ end
335
+
135
336
  # Installs the EXACT declared version into the runtime. Sources
136
337
  # are the gem command's own defaults — fetching from the
137
338
  # configured gem source is provisioning network access, the same
@@ -169,8 +370,16 @@ module Ace
169
370
 
170
371
  # `gem list <name> --exact --local` prints one `name (v1, v2)`
171
372
  # line when the name is present in the runtime and nothing when
172
- # it is absent. A present-but-unparseable line is a failed probe
173
- # (nil, fail closed), never a guessed empty list.
373
+ # it is absent. A DEFAULT gem's bundled version appears as a
374
+ # `default: V` part (`abbrev (default: 0.1.2)`,
375
+ # `json (2.9.4, default: 2.9.1)`): the marked V is the default
376
+ # gem's own version, every other part is an additionally
377
+ # installed version — both are present in the runtime, so the
378
+ # normalized list carries the marked version in place of the
379
+ # marker. Every candidate is validated by Gem::Version itself —
380
+ # platform-suffixed or otherwise malformed strings fail the
381
+ # probe (nil, fail closed) instead of crashing a later
382
+ # Gem::Version.new caller; never a guessed empty list.
174
383
  def parse_versions(out, name)
175
384
  line = out.lines.find { |candidate| candidate.start_with?("#{name} ") }
176
385
  return [] if line.nil?
@@ -178,7 +387,17 @@ module Ace
178
387
  match = line.chomp.match(/\A#{Regexp.escape(name)} \((.*)\)\z/)
179
388
  return nil if match.nil?
180
389
 
181
- match[1].split(",").map(&:strip)
390
+ versions = match[1].split(",").map(&:strip).map do |part|
391
+ default = part.match(/\Adefault: (\S+)\z/)
392
+ default ? default[1] : part
393
+ end
394
+ begin
395
+ versions.each { |version| Gem::Version.new(version) }
396
+ rescue ArgumentError
397
+ return nil
398
+ end
399
+
400
+ versions
182
401
  end
183
402
 
184
403
  # Classified one-line command failure: status plus the first
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # INTERNAL project-location read for the lab-level overseer start
9
+ # (task 8xs.t.pu0.a): the project list and their CANONICAL
10
+ # locations come from the raw topology configuration
11
+ # (`topology.projects`: id/path) read at the lab root — the schema
12
+ # normalization and the public projection deliberately strip paths,
13
+ # so this reader goes to the raw cascade document and takes only
14
+ # what the start needs. Locations are never derived from a public
15
+ # id/label or a repo name. The public CLI keeps its allowlisted
16
+ # projection; only the start operation consumes locations,
17
+ # in-process.
18
+ class ProjectLocations
19
+ # @param lab_root [String] lab coordination directory whose
20
+ # cascade lists the configured projects
21
+ # @return [Array<Hash>] {"id" =>, "path" =>} sorted by id; a
22
+ # project entry without a usable path is returned with a nil
23
+ # path so its OWN start reports the concrete error
24
+ # @raise [Ace::Lab::InvalidConfigurationError] the topology
25
+ # configuration could not be read or has no project list
26
+ def self.read(lab_root)
27
+ config_path = File.join(lab_root, ".ace/lab/config.yml")
28
+ document = YAML.safe_load_file(config_path,
29
+ permitted_classes: [Date], aliases: true)
30
+ unless document.is_a?(Hash)
31
+ raise Ace::Lab::InvalidConfigurationError, ".ace/lab/config.yml must be a mapping"
32
+ end
33
+
34
+ raw = document.dig("topology", "projects")
35
+ unless raw.is_a?(Array)
36
+ raise Ace::Lab::InvalidConfigurationError,
37
+ "topology.projects must be a list in .ace/lab/config.yml"
38
+ end
39
+
40
+ raw.map do |entry|
41
+ next {"id" => nil, "path" => nil} unless entry.is_a?(Hash)
42
+
43
+ # A nil/blank path encoding is returned as a nil path (the
44
+ # project's OWN start reports the concrete error) — never a
45
+ # bogus empty-string path, never an exception. The config is
46
+ # read by explicit path: no global cwd mutation.
47
+ path = entry["path"]
48
+ path = nil unless path.is_a?(String) && !path.strip.empty?
49
+ {"id" => entry["id"].to_s, "path" => path}
50
+ end.sort_by { |project| project["id"].to_s }
51
+ rescue Errno::ENOENT
52
+ raise Ace::Lab::InvalidConfigurationError,
53
+ "no lab configuration at #{lab_root}/.ace/lab/config.yml; run " \
54
+ "ace-lab project add or fix the lab root"
55
+ rescue Psych::SyntaxError => e
56
+ raise Ace::Lab::InvalidConfigurationError,
57
+ "lab configuration could not be parsed: #{e.message}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end