From 326134f6cc4b644e14905b32cc1d639ab3c82849 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Sat, 18 Jul 2026 16:18:23 -0600 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20opt-in=20path=20alias=20norma?= =?UTF-8?q?lization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 + README.md | 4 ++ lib/nomono/resolver.rb | 66 +++++++++++++++++- spec/nomono/gemfile_dsl_spec.rb | 17 +++++ spec/nomono/resolver_spec.rb | 120 ++++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a85ad06..502bce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ Please file a bug if you notice a violation of semantic versioning. ### Added +- Added opt-in path alias normalization so local sibling paths can be rewritten + to a configured canonical spelling before Bundler writes them to lockfiles. + ### Changed ### Deprecated diff --git a/README.md b/README.md index be7b02d..2f0b751 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ Nomono has an *environment contract*. By default (`prefix: "NOMONO_GEMS"`): - Any other value — treated as an explicit workspace root path (`/absolute/path` used as-is; relative path prepended with `$HOME`) - `NOMONO_GEMS_VENDORED_GEMS` (or legacy `VENDORED_GEMS`) is a comma-delimited list of gem names resolved from a vendor directory. This is **independent** of `NOMONO_GEMS_DEV` — it applies when you have vendored copies of specific gems that should override both the workspace and released versions. - `NOMONO_GEMS_VENDOR_GEM_DIR` (or legacy `VENDOR_GEM_DIR`) is the base path of the vendor directory used for gems listed in `NOMONO_GEMS_VENDORED_GEMS`. Defaults to `$workspace_root/vendor`. This is **not** an alternative form of the workspace root — it only affects vendored gems. +- `NOMONO_GEMS_PATH_ALIASES` (or global fallback `NOMONO_PATH_ALIASES`) is a comma-delimited list of `source=canonical` absolute path aliases. Each pair must resolve to the same real directory. Matching local gem paths are rewritten to the canonical spelling before Bundler receives them, preventing `Gemfile.lock` churn from equivalent paths such as `/var/home/...` and `/home/...`. - `NOMONO_GEMS_DEBUG` enables debug output from nomono. ### Examples @@ -190,6 +191,9 @@ eval_nomono_gems( gems: %w[kettle-dev kettle-test kettle-soup-cover], prefix: "KETTLE_DEV", path_env: "KETTLE_DEV_DEV", + path_aliases: { + "/var/home/pboling" => "/home/pboling" + }, vendored_gems_env: "VENDORED_GEMS", vendor_gem_dir_env: "VENDOR_GEM_DIR", debug_env: "KETTLE_DEV_DEBUG" diff --git a/lib/nomono/resolver.rb b/lib/nomono/resolver.rb index fd6f442..4f67cca 100644 --- a/lib/nomono/resolver.rb +++ b/lib/nomono/resolver.rb @@ -12,7 +12,7 @@ def initialize(env: ENV, home: nil) end def gems(gems:, prefix: "NOMONO_GEMS", allowlist: gems, path_env: nil, vendored_gems_env: nil, vendor_gem_dir_env: nil, - debug_env: nil, root: ["src", "my"], strict: true) + debug_env: nil, path_aliases: nil, path_aliases_env: nil, root: ["src", "my"], strict: true, **_options) requested = normalize_gems(gems) allowed = normalize_gems(allowlist) requested.each { |gem_name| validate_gem_name!(gem_name, allowed, strict: strict) } @@ -25,10 +25,13 @@ def gems(gems:, prefix: "NOMONO_GEMS", allowlist: gems, path_env: nil, vendored_ vendored = parse_vendored(fetch_with_fallback(vendored_gems_env || "#{prefix}_VENDORED_GEMS", "", "VENDORED_GEMS"), allowed) vendor_dir_value = fetch_with_fallback(vendor_gem_dir_env || "#{prefix}_VENDOR_GEM_DIR", File.join(dev_root, "vendor"), "VENDOR_GEM_DIR") vendor_dir = absolutize(vendor_dir_value) + aliases = normalize_path_aliases( + path_aliases || fetch_with_fallback(path_aliases_env || "#{prefix}_PATH_ALIASES", "", "NOMONO_PATH_ALIASES") + ) gem_paths = requested.each_with_object({}) do |gem_name, memo| base = vendored.include?(gem_name) ? vendor_dir : dev_root - memo[gem_name] = File.join(base, gem_name) + memo[gem_name] = normalize_path_alias(File.join(base, gem_name), aliases) end debug_key = debug_env || "#{prefix}_DEBUG" @@ -86,6 +89,65 @@ def absolutize(path) join_home(path) end + def normalize_path_aliases(value) + aliases = case value + when nil + [] + when Hash + value.map { |source, canonical| [source.to_s, canonical.to_s] } + when Array + value.map do |entry| + unless entry.respond_to?(:to_ary) && entry.to_ary.size == 2 + raise Error, "path aliases must be configured as source=canonical pairs" + end + + entry.to_ary.map(&:to_s) + end + else + value.to_s.split(",").map do |entry| + next if entry.strip.empty? + + source, canonical = entry.split("=", 2).map { |part| part.to_s.strip } + [source, canonical] + end.compact + end + + aliases.map do |source, canonical| + validate_path_alias!(source, canonical) + [strip_trailing_slash(source), strip_trailing_slash(canonical)] + end.sort_by { |source, _canonical| -source.length } + end + + def validate_path_alias!(source, canonical) + if source.to_s.empty? || canonical.to_s.empty? + raise Error, "path aliases must be configured as source=canonical pairs" + end + unless source.start_with?("/") && canonical.start_with?("/") + raise Error, "path aliases must use absolute paths" + end + return if File.realpath(source) == File.realpath(canonical) + + raise Error, "path alias #{source}=#{canonical} does not resolve to the same directory" + rescue SystemCallError => e + raise Error, "path alias #{source}=#{canonical} cannot be verified: #{e.message}" + end + + def normalize_path_alias(path, aliases) + aliases.each do |source, canonical| + next unless path == source || path.start_with?("#{source}/") + + return "#{canonical}#{path.delete_prefix(source)}" + end + + path + end + + def strip_trailing_slash(path) + path = path.dup + path.chop! while path.end_with?("/") + path + end + def join_home(*segments) File.join(home, *segments) end diff --git a/spec/nomono/gemfile_dsl_spec.rb b/spec/nomono/gemfile_dsl_spec.rb index 395656e..c7bff5f 100644 --- a/spec/nomono/gemfile_dsl_spec.rb +++ b/spec/nomono/gemfile_dsl_spec.rb @@ -28,4 +28,21 @@ def gem(name, **options) ["kettle-dev", {path: "/workspace/my/kettle-dev"}] ]) end + + it "evaluates normalized aliases into Gemfile gem(path:) calls" do + env = { + "KETTLE_DEV_DEV" => "/var/home/test/src/my", + "KETTLE_DEV_PATH_ALIASES" => "/var/home/test=/home/test" + } + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with("/var/home/test").and_return("/mnt/home/test") + allow(File).to receive(:realpath).with("/home/test").and_return("/mnt/home/test") + allow(Nomono).to receive(:resolver).and_return(Nomono::Resolver.new(env: env, home: "/home/test")) + + dsl.eval_nomono_gems(gems: %w[kettle-dev], prefix: "KETTLE_DEV") + + expect(dsl.captured).to eq([ + ["kettle-dev", {path: "/home/test/src/my/kettle-dev"}] + ]) + end end diff --git a/spec/nomono/resolver_spec.rb b/spec/nomono/resolver_spec.rb index 97ffcea..a8996b0 100644 --- a/spec/nomono/resolver_spec.rb +++ b/spec/nomono/resolver_spec.rb @@ -64,6 +64,126 @@ ).to include("kettle-dev" => "/home/test/relative/path/kettle-dev") end + it "ignores unknown future keyword options" do + env["NOMONO_GEMS_DEV"] = "/workspace/my" + + expect( + resolver.gems(gems: %w[kettle-dev], future_option: "ignored") + ).to eq("kettle-dev" => "/workspace/my/kettle-dev") + end + + it "normalizes configured path aliases before returning gem paths" do + env["KETTLE_DEV_DEV"] = "/var/home/test/src/my" + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with("/var/home/test").and_return("/mnt/home/test") + allow(File).to receive(:realpath).with("/home/test").and_return("/mnt/home/test") + + expect( + resolver.gems( + gems: %w[kettle-dev], + prefix: "KETTLE_DEV", + path_aliases: {"/var/home/test" => "/home/test"} + ) + ).to eq("kettle-dev" => "/home/test/src/my/kettle-dev") + end + + it "normalizes vendored gem paths with configured path aliases" do + env["KETTLE_DEV_DEV"] = "/var/home/test/src/my" + env["VENDORED_GEMS"] = "kettle-test" + env["VENDOR_GEM_DIR"] = "/var/home/test/src/my/vendor" + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with("/var/home/test").and_return("/mnt/home/test") + allow(File).to receive(:realpath).with("/home/test").and_return("/mnt/home/test") + + expect( + resolver.gems( + gems: gems, + prefix: "KETTLE_DEV", + path_aliases: [["/var/home/test", "/home/test"]] + ) + ).to include("kettle-test" => "/home/test/src/my/vendor/kettle-test") + end + + it "supports path aliases from family env configuration" do + env["KETTLE_DEV_DEV"] = "/var/home/test/src/my" + env["KETTLE_DEV_PATH_ALIASES"] = "/var/home/test=/home/test" + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with("/var/home/test").and_return("/mnt/home/test") + allow(File).to receive(:realpath).with("/home/test").and_return("/mnt/home/test") + + expect( + resolver.gems(gems: %w[kettle-dev], prefix: "KETTLE_DEV") + ).to eq("kettle-dev" => "/home/test/src/my/kettle-dev") + end + + it "supports global path aliases env fallback" do + env["KETTLE_DEV_DEV"] = "/var/home/test/src/my" + env["NOMONO_PATH_ALIASES"] = "/var/home/test=/home/test" + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with("/var/home/test").and_return("/mnt/home/test") + allow(File).to receive(:realpath).with("/home/test").and_return("/mnt/home/test") + + expect( + resolver.gems(gems: %w[kettle-dev], prefix: "KETTLE_DEV") + ).to eq("kettle-dev" => "/home/test/src/my/kettle-dev") + end + + it "uses the most specific matching path alias" do + env["KETTLE_DEV_DEV"] = "/var/home/test/src/my" + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with("/var/home/test").and_return("/mnt/home/test") + allow(File).to receive(:realpath).with("/home/test").and_return("/mnt/home/test") + allow(File).to receive(:realpath).with("/var/home/test/src").and_return("/mnt/home/test/src") + allow(File).to receive(:realpath).with("/workspace/src").and_return("/mnt/home/test/src") + + expect( + resolver.gems( + gems: %w[kettle-dev], + prefix: "KETTLE_DEV", + path_aliases: { + "/var/home/test" => "/home/test", + "/var/home/test/src" => "/workspace/src" + } + ) + ).to eq("kettle-dev" => "/workspace/src/my/kettle-dev") + end + + it "rejects path aliases that do not resolve to the same directory" do + env["KETTLE_DEV_DEV"] = "/var/home/test/src/my" + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with("/var/home/test").and_return("/mnt/home/test") + allow(File).to receive(:realpath).with("/home/test").and_return("/other/home/test") + + expect do + resolver.gems( + gems: %w[kettle-dev], + prefix: "KETTLE_DEV", + path_aliases: {"/var/home/test" => "/home/test"} + ) + end.to raise_error(Nomono::Error, %r{does not resolve to the same directory}) + end + + it "rejects malformed path alias env entries" do + env["NOMONO_GEMS_DEV"] = "/workspace/my" + env["NOMONO_GEMS_PATH_ALIASES"] = "/var/home/test" + + expect do + resolver.gems(gems: %w[kettle-dev]) + end.to raise_error(Nomono::Error, "path aliases must be configured as source=canonical pairs") + end + + it "wraps realpath system errors when path aliases cannot be verified" do + env["NOMONO_GEMS_DEV"] = "/workspace/my" + allow(File).to receive(:realpath).with("/var/home/test").and_raise(Errno::ENOTDIR, "/var/home/test") + + expect do + resolver.gems( + gems: %w[kettle-dev], + path_aliases: {"/var/home/test" => "/home/test"} + ) + end.to raise_error(Nomono::Error, %r{cannot be verified}) + end + it "prints resolved paths when debug mode is enabled" do env["NOMONO_GEMS_DEV"] = "/workspace/my" env["KETTLE_DEV_DEBUG"] = "yes"