kettle-family 1.3.0 → 1.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2e7e86419a5d960e83f3e8f7cb5aa1348f5acbe217b02b43e27ca479b9dafd9
4
- data.tar.gz: cb5de478d104da8759b0746db6a58f415afb768aae9cd6622e3b4d2a42cdbd09
3
+ metadata.gz: 7b4a15c15f854710fb75ab2c866cf5ebb2b6dd24f1418a1e694446139a347421
4
+ data.tar.gz: 70bd34977e27daf2f6e5f8ef465b02c938438eb1346d3574fd7080f2f54d3661
5
5
  SHA512:
6
- metadata.gz: a60488bf1fc40699a0317e52d6b62318bf0eb243cc42f6950b39b1f7e2e785e7be4de6163a436057d2fcef5a602446dd1acbce249f6ecad7912ccfbc947d7526
7
- data.tar.gz: 7c35f5fb09c6edbd5dc63235c0e3a97b69e9aa21a2dffff7c453fa557ab54f9383b7d1d69a9d08972bd1a7f078352dff5cc32991e203d588765570eaecc3ed18
6
+ metadata.gz: 271e6a288806993a5cf584f9ba8293854c9b7ecb32dbec18b8b6cf293856d8ca7f92bb6d320f8fd97de6ba82622573dee01112974416c95ee2d87e11312dc463
7
+ data.tar.gz: 3fd884813e4bb730575704ceda96b874cacac2b83a80d597f86e1212ba266cb575526808abe1eb5061404cb5446c64946fa0575d10fae1993dd96868c3479639
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,94 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.2] - 2026-09-28
34
+
35
+ - TAG: [v1.3.2][1.3.2t]
36
+ - COVERAGE: 93.25% -- 6537/7010 lines in 37 files
37
+ - BRANCH COVERAGE: 77.77% -- 2659/3419 branches in 37 files
38
+ - 33.57% documented
39
+
40
+ ### Added
41
+
42
+ - kettle-jem-template-20260913-001 - Templating now also surfaces a review
43
+ entry in `dependency_conflicts.resolve` when a direct development
44
+ dependency doesn't support one or more of this project's declared
45
+ `engines:` and has no template-managed modular home (e.g. `sqlite3` on
46
+ `jruby`). Review each entry and pick a resolution per the project's own
47
+ engine support needs.
48
+
49
+ ### Changed
50
+
51
+ - [kc] kettle-jem/prepare: updated 13 project files:
52
+ - dependencies (13)
53
+
54
+ - [kc] kettle-jem/template: updated 14 project files:
55
+ - code and tests (2)
56
+ - dependencies (2)
57
+ - other (1)
58
+ - workflows (9)
59
+
60
+ ### Fixed
61
+
62
+ - Run local Kettle Jem through a prepared member bundle so local StructuredMerge dependencies are activated during templating.
63
+
64
+ - Pin bupb to an installed stable Bundler version, including its managed child updates.
65
+
66
+ - [kc] nomono-self-upgrade-bundler-env: Preserve RUBYOPT bundle activation for local Kettle Jem execution, but omit eager setup from nomono self-upgrade lockfile updates so the stale self-version can be reconciled without falling back from the local template stack.
67
+
68
+ - Recover template lockfiles with Bundler's valid bundle lock --update GEM syntax instead of passing gem names as invalid positional arguments.
69
+
70
+ - Template preparation now starts local Kettle Jem from its own bundle when the destination lockfile path-locks Nomono, allowing bootstrap repair before the destination bundle loads.
71
+
72
+ - Refresh dependent lockfiles before committing newly raised family dependency floors.
73
+
74
+ - Handle macOS canonical temp paths, Unix socket limits, and PTY EOF during family workflows.
75
+
76
+ - Canonicalize Windows system temporary paths for Git-based specs, launch the Mise integration fixture on Windows, and include WEBrick for local registry specs.
77
+
78
+ - Readiness checks accept existing binstub files on Windows, where Unix executable-bit checks are not portable.
79
+
80
+ - Make family specs portable on Windows and use valid generated gemspec fixtures.
81
+
82
+ - Support Windows pipe-driven interactive commands, absolute local-path lockfile remotes, and equivalent filesystem path spellings in family workflows.
83
+
84
+ - Apply workflow environment overrides portably after mise and normalize Windows filesystem paths in discovery and branch configuration.
85
+
86
+ - Normalize filesystem glob separators across supported platforms so family discovery, installs, and workflow asset lookup find paths on Windows.
87
+
88
+ - Normalize Windows path aliases and subprocess line endings in family workflows.
89
+
90
+ - Close the child input stream when a configured OTP provider returns no code, preventing interactive release commands from hanging.
91
+
92
+ - Start the Windows environment wrapper without inherited RUBYOPT so Bundler cannot activate the parent bundle before wrapper overrides are applied.
93
+
94
+ - Windows family workflows now reset lockfiles without relying on the POSIX env utility, terminate failed interactive child processes, and apply member exclusion patterns case-insensitively.
95
+
96
+ - Windows family workflow specs now preserve command arguments and handle case-insensitive checkout paths.
97
+
98
+ - Detect Windows drive-absolute local paths when preparing release lockfile normalization.
99
+
100
+ - Delegate canonical, identity, and containment path checks to kettle-dev so family workflows consistently handle symlink aliases and platform path semantics.
101
+
102
+ - Windows family discovery now applies exclusion globs across path separators, and branch-target config lookup uses shared path normalization.
103
+
104
+ - Resolve family-relative paths by filesystem identity so discovery exclusions and branch-stack configs work with Windows path aliases.
105
+
106
+ ## [1.3.1] - 2026-09-10
107
+
108
+ - TAG: [v1.3.1][1.3.1t]
109
+ - COVERAGE: 93.48% -- 6352/6795 lines in 36 files
110
+ - BRANCH COVERAGE: 77.98% -- 2582/3311 branches in 36 files
111
+ - 33.49% documented
112
+
113
+ ### Fixed
114
+
115
+ - Honor configured ordering in release-state reports, emit all five phases for shared-changelog members, and distinguish missing exact GitHub releases from lookup errors.
116
+
117
+ - Publish shared monorepo GitHub releases incrementally after each finalized member, retain successful workers from interrupted waves, attach both checksum formats, and report redundant family state cells once.
118
+
119
+ - Keep independent monorepo GitHub release identities exact and avoid false unlisted-member warnings during filtered state inspection.
120
+
33
121
  ## [1.3.0] - 2026-09-10
34
122
 
35
123
  - TAG: [v1.3.0][1.3.0t]
@@ -2731,7 +2819,11 @@ Please file a bug if you notice a violation of semantic versioning.
2731
2819
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2732
2820
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2733
2821
 
2734
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.0...HEAD
2822
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.2...HEAD
2823
+ [1.3.2]: https://github.com/kettle-dev/kettle-family/compare/v1.3.1...v1.3.2
2824
+ [1.3.2t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.2
2825
+ [1.3.1]: https://github.com/kettle-dev/kettle-family/compare/v1.3.0...v1.3.1
2826
+ [1.3.1t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.1
2735
2827
  [1.3.0]: https://github.com/kettle-dev/kettle-family/compare/v1.2.100...v1.3.0
2736
2828
  [1.3.0t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.0
2737
2829
  [1.2.100]: https://github.com/kettle-dev/kettle-family/compare/v1.2.99...v1.2.100
data/README.md CHANGED
@@ -1186,7 +1186,7 @@ Thanks for RTFM. ☺️
1186
1186
  [📌gitmoji]: https://gitmoji.dev
1187
1187
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1188
1188
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1189
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.764-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1189
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.010-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1190
1190
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1191
1191
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1192
1192
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "yaml"
5
+ require_relative "paths"
5
6
 
6
7
  module Kettle
7
8
  module Family
@@ -75,20 +76,22 @@ module Kettle
75
76
  raise Error, "could not determine git root for #{member.root}: #{stderr}" unless status.success?
76
77
 
77
78
  File.realpath(stdout.strip)
79
+ rescue SystemCallError
80
+ raise Error, "could not determine git root for #{member.root}: invalid Git root response"
78
81
  end
79
82
 
80
83
  def member_relative_root(member, root)
81
- member_root = File.realpath(member.root)
82
- return "." if member_root == root
83
- return member_root.delete_prefix("#{root}/") if member_root.start_with?("#{root}/")
84
+ relative = Paths.relative(member.root, root)
85
+ raise Error, "member root #{member.root} is outside git root #{root}" unless relative
84
86
 
85
- raise Error, "member root #{member.root} is outside git root #{root}"
87
+ relative
86
88
  end
87
89
 
88
90
  def member_local_config_paths(root, relative_root)
89
91
  branches = local_branches(root)
90
92
  candidates = Config::DEFAULT_PATHS.map do |path|
91
- (relative_root == ".") ? path : File.join(relative_root, path)
93
+ candidate = (relative_root == ".") ? path : File.join(relative_root, path)
94
+ candidate.tr("\\", "/")
92
95
  end
93
96
  branches.each_with_object([]) do |branch, memo|
94
97
  candidates.each do |path|
@@ -684,10 +684,10 @@ module Kettle
684
684
  members = discovery.members
685
685
  ordered = if command == "install"
686
686
  install_order(members, config)
687
- elsif %w[metadata release-state].include?(command)
687
+ elsif command == "metadata"
688
688
  members.sort_by(&:name)
689
689
  else
690
- Orderer.new(members: members, mode: config.order_mode, hints: config.order_hints).ordered
690
+ Orderer.new(members: members, mode: config.order_mode, hints: config.order_hints).ordered(allow_cycles: command == "release-state")
691
691
  end
692
692
  state_event_tape = release_state_event_tape(command: command, config: config, options: options)
693
693
  Selection.validate_release_state_only_filter!(effective_only)
@@ -11,6 +11,17 @@ module Kettle
11
11
  "KETTLE_RELEASE_GEM_SIGNING_PASSPHRASE"
12
12
  ].freeze
13
13
  MISE_SESSION_ENV_PREFIX = "__MISE_"
14
+ WINDOWS_ENV_EXEC_SCRIPT = <<~RUBY
15
+ unset_count = Integer(ARGV.shift)
16
+ unset_count.times { ENV.delete(ARGV.shift) }
17
+ assignment_count = Integer(ARGV.shift)
18
+ assignment_count.times do
19
+ key, value = ARGV.shift.split("=", 2)
20
+ ENV[key] = value
21
+ end
22
+ abort "missing command after environment arguments" if ARGV.shift != "--"
23
+ exec(*ARGV)
24
+ RUBY
14
25
 
15
26
  class OtpCoordinator
16
27
  def initialize(input: $stdin, output: $stdout, queue_total: nil, secrets_provider: nil, event_handler: nil)
@@ -371,7 +382,7 @@ module Kettle
371
382
  end
372
383
  end
373
384
  end
374
- rescue Errno::EIO
385
+ rescue Errno::EIO, EOFError
375
386
  # PTY raises EIO when the child process exits after closing the slave.
376
387
  rescue Error => error
377
388
  stderr << "#{error.message}\n"
@@ -386,6 +397,19 @@ module Kettle
386
397
  end
387
398
 
388
399
  def run_interactive_open3(env:, argv:, chdir:, member_name:, process_options:, stdout_line_handler:, log_io: nil, passthrough_output: true)
400
+ if Gem.win_platform?
401
+ return run_interactive_open3_threads(
402
+ env: env,
403
+ argv: argv,
404
+ chdir: chdir,
405
+ member_name: member_name,
406
+ process_options: process_options,
407
+ stdout_line_handler: stdout_line_handler,
408
+ log_io: log_io,
409
+ passthrough_output: passthrough_output
410
+ )
411
+ end
412
+
389
413
  captured_stdout = +""
390
414
  captured_stderr = +""
391
415
  stdout_line_buffer = +""
@@ -431,6 +455,62 @@ module Kettle
431
455
  [captured_stdout, captured_stderr, status]
432
456
  end
433
457
 
458
+ def run_interactive_open3_threads(env:, argv:, chdir:, member_name:, process_options:, stdout_line_handler:, log_io: nil, passthrough_output: true)
459
+ captured_stdout = +""
460
+ captured_stderr = +""
461
+ stdout_line_buffer = +""
462
+ prompt_buffer = +""
463
+ status = nil
464
+ Open3.popen3(env, *argv, chdir: chdir, **process_options) do |input, output, stderr_stream, wait_thread|
465
+ streams = [output, stderr_stream]
466
+ streams << $stdin if $stdin.tty? && !otp_coordinator
467
+ queue = Queue.new
468
+ readers = streams.map do |stream|
469
+ Thread.new do # rubocop:disable ThreadSafety/NewThread -- Windows pipes are not selectable with IO.select.
470
+ loop { queue << [stream, stream.readpartial(1024)] }
471
+ rescue IOError
472
+ queue << [stream, nil]
473
+ end
474
+ end
475
+ input_reader = readers[streams.index($stdin)] if streams.include?($stdin)
476
+ active_streams = streams.dup
477
+ begin
478
+ until active_streams.empty?
479
+ stream, chunk = queue.pop
480
+ unless chunk
481
+ active_streams.delete(stream)
482
+ next
483
+ end
484
+ if stream.equal?($stdin)
485
+ input.write(chunk)
486
+ next
487
+ end
488
+ if stream.equal?(output)
489
+ captured_stdout << chunk
490
+ transcript_write(log_io, chunk)
491
+ stdout_line_buffer = print_interactive_stdout(stdout_line_buffer, chunk, stdout_line_handler, passthrough_output: passthrough_output)
492
+ else
493
+ captured_stderr << chunk
494
+ transcript_write(log_io, chunk)
495
+ $stderr.print(chunk) if passthrough_output
496
+ end
497
+ prompt_buffer = handle_interactive_prompt(input, chunk, member_name: member_name, prompt_buffer: prompt_buffer)
498
+ end
499
+ status = wait_thread.value
500
+ rescue Error => exception
501
+ captured_stderr << "#{exception.message}\n"
502
+ transcript_write(log_io, "#{exception.message}\n")
503
+ terminate_process(wait_thread.pid)
504
+ status = failure_status
505
+ ensure
506
+ input_reader&.kill if input_reader&.alive?
507
+ readers.each(&:join)
508
+ end
509
+ flush_interactive_stdout(stdout_line_buffer, stdout_line_handler, passthrough_output: passthrough_output)
510
+ end
511
+ [captured_stdout, captured_stderr, status]
512
+ end
513
+
434
514
  def read_interactive_stream(
435
515
  reader,
436
516
  output,
@@ -562,7 +642,10 @@ module Kettle
562
642
 
563
643
  def write_otp_response(input, chunk, member_name:)
564
644
  response = otp_coordinator.request(member_name: member_name || "release", chunk: chunk)
565
- return if response.to_s.empty?
645
+ if response.to_s.empty?
646
+ input.close unless input.closed?
647
+ return
648
+ end
566
649
 
567
650
  input.write("#{response}\n")
568
651
  input.flush
@@ -586,7 +669,7 @@ module Kettle
586
669
  end
587
670
 
588
671
  def normalize_output(output)
589
- output.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
672
+ output.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "").gsub("\r\n", "\n")
590
673
  end
591
674
 
592
675
  def failure_status
@@ -594,8 +677,8 @@ module Kettle
594
677
  end
595
678
 
596
679
  def terminate_process(pid)
597
- Process.kill("TERM", pid)
598
- rescue Errno::ESRCH, Errno::EPERM
680
+ Process.kill(Gem.win_platform? ? "KILL" : "TERM", pid)
681
+ rescue Errno::ESRCH, Errno::EPERM, Errno::EINVAL
599
682
  nil
600
683
  end
601
684
 
@@ -612,6 +695,23 @@ module Kettle
612
695
  mise_argv = ["mise", "exec", "-C", member.root, "--"]
613
696
  return [*mise_argv, *argv] if injected_env.empty?
614
697
 
698
+ if Gem.win_platform?
699
+ return [
700
+ *mise_argv,
701
+ "ruby",
702
+ "--disable=rubyopt",
703
+ "-e",
704
+ WINDOWS_ENV_EXEC_SCRIPT,
705
+ "--",
706
+ unset_env.length.to_s,
707
+ *unset_env.map { |key, _value| key.to_s },
708
+ set_env.length.to_s,
709
+ *set_env.map { |key, value| "#{key}=#{value}" },
710
+ "--",
711
+ *argv
712
+ ]
713
+ end
714
+
615
715
  [*mise_argv, "env", *injected_env, *argv]
616
716
  end
617
717
 
@@ -402,9 +402,7 @@ module Kettle
402
402
  private
403
403
 
404
404
  def path_within_root?(path)
405
- expanded_path = File.expand_path(path)
406
- expanded_root = File.expand_path(root)
407
- expanded_path == expanded_root || expanded_path.start_with?("#{expanded_root}/")
405
+ Paths.within?(path, root)
408
406
  end
409
407
 
410
408
  def expand_config_relative_path(value)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "paths"
4
5
 
5
6
  module Kettle
6
7
  module Family
@@ -36,7 +37,7 @@ module Kettle
36
37
 
37
38
  (configured + discovered).each do |member|
38
39
  existing = by_name[member.name]
39
- raise Error, "duplicate family member #{member.name.inspect}" if existing && existing.root != member.root
40
+ raise Error, "duplicate family member #{member.name.inspect}" if existing && !same_directory?(existing.root, member.root)
40
41
 
41
42
  by_name[member.name] = member
42
43
  end
@@ -46,7 +47,7 @@ module Kettle
46
47
 
47
48
  def discover_members
48
49
  gemspecs = config.discover_member_roots.flat_map do |root|
49
- Dir.glob(File.join(root, "**", "*.gemspec"))
50
+ Paths.glob(root, "**", "*.gemspec")
50
51
  end
51
52
  gemspecs.reject! { |path| excluded_gemspec?(path) }
52
53
  gemspecs.map { |path| member_from_gemspec(path) }
@@ -82,7 +83,7 @@ module Kettle
82
83
  end
83
84
 
84
85
  def primary_gemspec(root)
85
- gemspecs = Dir.glob(File.join(root, "*.gemspec"))
86
+ gemspecs = Paths.glob(root, "*.gemspec")
86
87
  raise Error, "no gemspec found in #{root}" if gemspecs.empty?
87
88
  return gemspecs.first if gemspecs.one?
88
89
 
@@ -92,9 +93,10 @@ module Kettle
92
93
  def warn_unlisted_discovered_members(discovered, configured)
93
94
  return unless config.configured_member_roots? || config.explicit_members.any?
94
95
 
95
- configured_roots = configured.map { |member| normalized_path(member.root) }
96
+ # Selection limits evaluation, not membership in the configuration.
97
+ roots = configured.map(&:root) + config.configured_member_roots + config.explicit_members.map { |entry| entry.fetch("root") }
96
98
  discovered.each do |member|
97
- next if configured_roots.include?(normalized_path(member.root))
99
+ next if roots.any? { |root| same_directory?(root, member.root) }
98
100
 
99
101
  warnings << {
100
102
  "kind" => "unlisted_discovered_member",
@@ -106,7 +108,11 @@ module Kettle
106
108
  end
107
109
 
108
110
  def normalized_path(path)
109
- File.expand_path(path)
111
+ Paths.canonical(path).tr("\\", "/")
112
+ end
113
+
114
+ def same_directory?(left, right)
115
+ Paths.same?(left, right)
110
116
  end
111
117
 
112
118
  def member_from_gemspec(path)
@@ -170,7 +176,7 @@ module Kettle
170
176
  canonical = File.join(root, "lib", gem_name.tr("-", "_"), "version.rb")
171
177
  return canonical if File.file?(canonical)
172
178
 
173
- candidates = Dir.glob(File.join(root, "lib", "**", "version.rb"))
179
+ candidates = Paths.glob(root, "lib", "**", "version.rb")
174
180
  candidates.min
175
181
  end
176
182
 
@@ -218,8 +224,36 @@ module Kettle
218
224
 
219
225
  def path_matches_pattern?(path, pattern)
220
226
  relative_candidates(path).any? do |relative|
221
- File.fnmatch?(pattern, relative, File::FNM_DOTMATCH | File::FNM_EXTGLOB)
227
+ path_glob_match?(pattern, relative)
228
+ end
229
+ end
230
+
231
+ def path_glob_match?(pattern, path)
232
+ pattern_parts = pattern.tr("\\", "/").split("/")
233
+ path_parts = path.tr("\\", "/").split("/")
234
+ if Gem.win_platform?
235
+ pattern_parts.map!(&:downcase)
236
+ path_parts.map!(&:downcase)
222
237
  end
238
+
239
+ path_glob_parts_match?(pattern_parts, path_parts, {})
240
+ end
241
+
242
+ def path_glob_parts_match?(pattern_parts, path_parts, memo)
243
+ key = [pattern_parts.length, path_parts.length]
244
+ return memo[key] if memo.key?(key)
245
+ return memo[key] = path_parts.empty? if pattern_parts.empty?
246
+
247
+ pattern_part = pattern_parts.first
248
+ matched = if pattern_part == "**"
249
+ path_glob_parts_match?(pattern_parts.drop(1), path_parts, memo) ||
250
+ (!path_parts.empty? && path_glob_parts_match?(pattern_parts, path_parts.drop(1), memo))
251
+ else
252
+ !path_parts.empty? &&
253
+ File.fnmatch?(pattern_part, path_parts.first, File::FNM_DOTMATCH | File::FNM_EXTGLOB) &&
254
+ path_glob_parts_match?(pattern_parts.drop(1), path_parts.drop(1), memo)
255
+ end
256
+ memo[key] = matched
223
257
  end
224
258
 
225
259
  def relative_candidates(path)
@@ -229,12 +263,7 @@ module Kettle
229
263
  end
230
264
 
231
265
  def relative_path(path, root)
232
- expanded_path = File.expand_path(path)
233
- expanded_root = File.expand_path(root)
234
- prefix = "#{expanded_root}/"
235
- return unless expanded_path.start_with?(prefix)
236
-
237
- expanded_path.delete_prefix(prefix)
266
+ Paths.relative(path, root)
238
267
  end
239
268
 
240
269
  class GemfileDependencyCollector
@@ -7,6 +7,7 @@ require "open3"
7
7
  require "time"
8
8
 
9
9
  require_relative "concurrency"
10
+ require_relative "paths"
10
11
 
11
12
  module Kettle
12
13
  module Family
@@ -140,7 +141,7 @@ module Kettle
140
141
 
141
142
  raise Error, "install local dependency does not exist: #{path}" unless Dir.exist?(expanded)
142
143
 
143
- gemspecs = Dir.glob(File.join(expanded, "*.gemspec"))
144
+ gemspecs = Paths.glob(expanded, "*.gemspec")
144
145
  raise Error, "no gemspec found for install local dependency: #{path}" if gemspecs.empty?
145
146
  raise Error, "multiple gemspecs found for install local dependency: #{path}" if gemspecs.size > 1
146
147
 
@@ -163,7 +164,7 @@ module Kettle
163
164
  canonical = File.join(root, "lib", gem_name.tr("-", "_"), "version.rb")
164
165
  return canonical if File.file?(canonical)
165
166
 
166
- Dir.glob(File.join(root, "lib", "**", "version.rb")).min
167
+ Paths.glob(root, "lib", "**", "version.rb").min
167
168
  end
168
169
 
169
170
  def required_ruby_version(spec)
@@ -3,6 +3,8 @@
3
3
  require "rubygems"
4
4
  require "kettle/dev"
5
5
 
6
+ require_relative "paths"
7
+
6
8
  module Kettle
7
9
  module Family
8
10
  class NomonoBootstrap
@@ -91,7 +93,7 @@ module Kettle
91
93
  end
92
94
 
93
95
  def local_gemfile_edits(member)
94
- Dir.glob(File.join(member.root, "gemfiles/modular/**/*_local.gemfile")).sort.flat_map do |path|
96
+ Paths.glob(member.root, "gemfiles/modular/**/*_local.gemfile").sort.flat_map do |path|
95
97
  source = File.read(path)
96
98
  parse_result = Kettle::Dev::VersionBump.parse_source(source, path)
97
99
  Kettle::Dev::VersionBump.each_node(parse_result.value).filter_map do |node|
@@ -14,7 +14,7 @@ module Kettle
14
14
  @by_name = members.to_h { |member| [member.name, member] }
15
15
  end
16
16
 
17
- def ordered
17
+ def ordered(allow_cycles: false)
18
18
  case mode
19
19
  when "dependency"
20
20
  tsort
@@ -24,6 +24,9 @@ module Kettle
24
24
  raise Error, "unknown order mode #{mode.inspect}"
25
25
  end
26
26
  rescue TSort::Cyclic => error
27
+ # Read-only state inspection must remain available to diagnose cycles.
28
+ return hinted_members if allow_cycles
29
+
27
30
  raise Error, "dependency cycle detected: #{error.message}"
28
31
  end
29
32
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "kettle/dev"
5
+
6
+ module Kettle
7
+ module Family
8
+ module Paths
9
+ module_function
10
+
11
+ def canonical(path, base: nil)
12
+ Kettle::Dev::Paths.canonical(path, base: base)
13
+ end
14
+
15
+ def same?(left, right, base: nil)
16
+ Kettle::Dev::Paths.same?(left, right, base: base)
17
+ end
18
+
19
+ def within?(path, root, base: nil)
20
+ Kettle::Dev::Paths.within?(path, root, base: base)
21
+ end
22
+
23
+ # Computes a relative path by walking filesystem ancestors. Component
24
+ # string comparisons are insufficient on Windows, where equivalent
25
+ # paths may have different drive-letter casing or filesystem aliases.
26
+ def relative(path, root)
27
+ candidate = canonical(path)
28
+ boundary = canonical(root)
29
+ return "." if same?(candidate, boundary)
30
+
31
+ is_directory = File.directory?(candidate)
32
+ current = is_directory ? candidate : File.dirname(candidate)
33
+ parts = is_directory ? [] : [File.basename(candidate)]
34
+
35
+ until same?(current, boundary)
36
+ parent = File.dirname(current)
37
+ return unless parent != current
38
+
39
+ parts.unshift(File.basename(current))
40
+ current = parent
41
+ end
42
+
43
+ File.join(*parts).tr("\\", "/")
44
+ end
45
+
46
+ def glob(*parts)
47
+ Dir.glob(File.join(*parts).tr("\\", "/"))
48
+ end
49
+
50
+ def local_path_remote?(remote)
51
+ text = remote.to_s
52
+ drive_absolute = text.length >= 3 && text[1] == ":" && ["/", "\\"].include?(text[2])
53
+ drive_absolute || text.start_with?("/", "./", "../", ".\\", "..\\") || Pathname.new(text).absolute?
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "paths"
4
+
3
5
  module Kettle
4
6
  module Family
5
7
  class ReadinessCheck
@@ -43,7 +45,8 @@ module Kettle
43
45
  def missing_required_bins
44
46
  required_bins.filter_map do |path|
45
47
  full_path = File.join(member.root, path)
46
- next if File.file?(full_path) && File.executable?(full_path)
48
+ executable = Gem.win_platform? || File.executable?(full_path)
49
+ next if File.file?(full_path) && executable
47
50
 
48
51
  "missing executable binstub #{path}"
49
52
  end
@@ -100,7 +103,8 @@ module Kettle
100
103
  return [] unless File.file?(lockfile)
101
104
 
102
105
  File.readlines(lockfile).filter_map.with_index(1) do |line, index|
103
- next unless line.start_with?(" remote: /", " remote: ./", " remote: ../")
106
+ next unless line.start_with?(" remote:")
107
+ next unless Paths.local_path_remote?(line.split("remote:", 2).last.to_s.strip)
104
108
  next if allowed_local_path?(line)
105
109
 
106
110
  "release lockfile has local path remote at Gemfile.lock:#{index}"
@@ -112,17 +116,14 @@ module Kettle
112
116
  remote_path = normalized_path(remote, base: member.root)
113
117
  return false unless remote_path
114
118
 
115
- allowed_local_path_roots.any? { |root| remote_path == root || remote_path.start_with?("#{root}/") }
119
+ allowed_local_path_roots.any? { |root| Paths.within?(remote_path, root) }
116
120
  end
117
121
 
118
122
  def normalized_path(path, base: nil)
119
123
  text = path.to_s
120
124
  return nil if text.empty? || text.casecmp("false").zero?
121
125
 
122
- expanded = File.expand_path(text, base)
123
- File.realpath(expanded)
124
- rescue Errno::ENOENT
125
- expanded
126
+ Paths.canonical(text, base: base)
126
127
  end
127
128
 
128
129
  def required_files