Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
66 changes: 64 additions & 2 deletions lib/nomono/resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions spec/nomono/gemfile_dsl_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 120 additions & 0 deletions spec/nomono/resolver_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down