evilution 0.29.0 → 0.30.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +54 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +42 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/heredoc_span.rb +99 -0
  8. data/lib/evilution/baseline.rb +15 -2
  9. data/lib/evilution/cli/commands/compare.rb +13 -0
  10. data/lib/evilution/cli/parser/command_extractor.rb +3 -1
  11. data/lib/evilution/cli/parser/options_builder.rb +2 -2
  12. data/lib/evilution/config/file_loader.rb +40 -1
  13. data/lib/evilution/config.rb +11 -1
  14. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  15. data/lib/evilution/feedback/setup_warning.rb +79 -0
  16. data/lib/evilution/gem_detector.rb +132 -0
  17. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  18. data/lib/evilution/integration/loading/mutation_applier.rb +20 -5
  19. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  20. data/lib/evilution/integration/minitest.rb +37 -2
  21. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  22. data/lib/evilution/integration/rspec.rb +16 -1
  23. data/lib/evilution/isolation/fork.rb +77 -10
  24. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  25. data/lib/evilution/mcp/info_tool.rb +3 -1
  26. data/lib/evilution/mcp/mutate_tool/option_parser.rb +1 -1
  27. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  28. data/lib/evilution/mcp/mutate_tool.rb +22 -3
  29. data/lib/evilution/mcp/session_tool.rb +7 -4
  30. data/lib/evilution/mcp.rb +6 -0
  31. data/lib/evilution/mutation.rb +13 -1
  32. data/lib/evilution/mutator/base.rb +49 -1
  33. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  34. data/lib/evilution/mutator/operator/block_param_removal.rb +32 -0
  35. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +20 -2
  36. data/lib/evilution/mutator/operator/index_to_at.rb +13 -1
  37. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  38. data/lib/evilution/mutator/operator/receiver_replacement.rb +29 -1
  39. data/lib/evilution/mutator/operator/rescue_removal.rb +59 -10
  40. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  41. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  42. data/lib/evilution/mutator/registry.rb +2 -0
  43. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  44. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  45. data/lib/evilution/reporter/json.rb +2 -0
  46. data/lib/evilution/result/mutation_result.rb +12 -6
  47. data/lib/evilution/runner/baseline_runner.rb +5 -1
  48. data/lib/evilution/runner/isolation_resolver.rb +69 -8
  49. data/lib/evilution/runner/mutation_planner.rb +18 -1
  50. data/lib/evilution/session/schema.rb +44 -0
  51. data/lib/evilution/session/store.rb +5 -1
  52. data/lib/evilution/version.rb +1 -1
  53. data/lib/evilution.rb +2 -0
  54. data/schema/evilution.config.schema.json +205 -0
  55. data/script/build_runtime_snapshot +88 -0
  56. data/script/run_self_baseline +79 -0
  57. data/script/run_self_validation +54 -0
  58. metadata +15 -2
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../ast"
6
+
7
+ # Computes the byte-length needed for a mutation whose target range contains
8
+ # heredoc anchors (`<<~MARKER` / `<<-MARKER` / `<<MARKER`).
9
+ #
10
+ # Prism reports a heredoc anchor's `location` as the inline range of just
11
+ # `<<~MARKER` — the body lines and the closing terminator live in `closing_loc`
12
+ # which is on a later line. An operator that builds a byte edit from the
13
+ # anchor's inline range (e.g. `argument_removal` using
14
+ # `node.arguments.location`) covers the anchor but leaves the body+terminator
15
+ # in place, producing an orphaned heredoc fragment that the parser rejects.
16
+ #
17
+ # `extend_length` walks the supplied AST node for heredoc descendants whose
18
+ # anchor falls inside `[offset, offset + length)` and returns a length wide
19
+ # enough to also cover those descendants' `closing_loc.end_offset` — so the
20
+ # mutation's `replacement` replaces the heredoc body and terminator along with
21
+ # the anchor.
22
+ module Evilution::AST::HeredocSpan
23
+ module_function
24
+
25
+ def extend_length(node:, offset:, length:)
26
+ return length if node.nil?
27
+
28
+ end_offset = offset + length
29
+ max_end = end_offset
30
+ Walker.new(offset, end_offset) do |closing_end|
31
+ max_end = closing_end if closing_end > max_end
32
+ end.visit(node)
33
+ max_end - offset
34
+ end
35
+
36
+ class Walker < Prism::Visitor
37
+ def initialize(start_offset, end_offset, &block)
38
+ super()
39
+ @start_offset = start_offset
40
+ @end_offset = end_offset
41
+ @block = block
42
+ end
43
+
44
+ def visit_string_node(node)
45
+ record_if_heredoc(node)
46
+ super
47
+ end
48
+
49
+ def visit_interpolated_string_node(node)
50
+ record_if_heredoc(node)
51
+ super
52
+ end
53
+
54
+ def visit_x_string_node(node)
55
+ record_if_heredoc(node)
56
+ super
57
+ end
58
+
59
+ def visit_interpolated_x_string_node(node)
60
+ record_if_heredoc(node)
61
+ super
62
+ end
63
+
64
+ private
65
+
66
+ def record_if_heredoc(node)
67
+ return unless heredoc?(node)
68
+
69
+ closing = node.closing_loc
70
+ return unless anchor_in_range?(node) && closing
71
+
72
+ @block.call(closing_end_excluding_trailing_newline(closing))
73
+ end
74
+
75
+ def heredoc?(node)
76
+ node.respond_to?(:heredoc?) && node.heredoc?
77
+ end
78
+
79
+ # Anchor must be inside the mutation's target range; only then does its
80
+ # heredoc body sit outside the range and need pulling in.
81
+ def anchor_in_range?(node)
82
+ opening = node.opening_loc
83
+ return false if opening.nil?
84
+
85
+ opening.start_offset >= @start_offset && opening.start_offset < @end_offset
86
+ end
87
+
88
+ # Prism's closing_loc covers the terminator including the trailing
89
+ # newline. Excluding that newline preserves line structure after the
90
+ # replacement (any code that follows lands on its own line).
91
+ def closing_end_excluding_trailing_newline(closing)
92
+ end_off = closing.end_offset
93
+ slice = closing.slice
94
+ end_off -= 1 if slice && slice.end_with?("\n")
95
+ end_off
96
+ end
97
+ end
98
+ private_constant :Walker
99
+ end
@@ -15,16 +15,18 @@ class Evilution::Baseline
15
15
  end
16
16
  end
17
17
 
18
- def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30, runner: nil, fallback_dir: "spec")
18
+ def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30, runner: nil,
19
+ fallback_dir: "spec", test_files: nil)
19
20
  @spec_resolver = spec_resolver
20
21
  @timeout = timeout
21
22
  @runner = runner
22
23
  @fallback_dir = fallback_dir
24
+ @test_files = test_files
23
25
  end
24
26
 
25
27
  def call(subjects)
26
28
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
- spec_files = resolve_unique_spec_files(subjects)
29
+ spec_files = baseline_spec_files(subjects)
28
30
  failed = Set.new
29
31
 
30
32
  spec_files.each do |spec_file|
@@ -96,6 +98,17 @@ class Evilution::Baseline
96
98
 
97
99
  private
98
100
 
101
+ # When --spec was provided, run those files only. Auto-discovery is skipped
102
+ # entirely — the user has declared what covers their subjects and any
103
+ # mismatch between auto-discovery and their declaration is what produced
104
+ # the misleading "No matching test found" warning users have reported even
105
+ # while passing --spec.
106
+ def baseline_spec_files(subjects)
107
+ return Array(@test_files).uniq if @test_files && !@test_files.empty?
108
+
109
+ resolve_unique_spec_files(subjects)
110
+ end
111
+
99
112
  def resolve_unique_spec_files(subjects)
100
113
  warned = Set.new
101
114
  subjects.map do |s|
@@ -9,6 +9,7 @@ require_relative "../../compare"
9
9
  require_relative "../../compare/categorizer"
10
10
  require_relative "../../compare/detector"
11
11
  require_relative "../../compare/normalizer"
12
+ require_relative "../../session/schema"
12
13
 
13
14
  class Evilution::CLI::Commands::Compare < Evilution::CLI::Command
14
15
  SUPPORTED_FORMATS = %i[json text].freeze
@@ -46,6 +47,7 @@ class Evilution::CLI::Commands::Compare < Evilution::CLI::Command
46
47
  raise Evilution::Error, "file not found: #{path}" unless File.exist?(path)
47
48
 
48
49
  json = JSON.parse(File.read(path))
50
+ validate_session_schema(json, path)
49
51
  tool = Evilution::Compare::Detector.call(json)
50
52
  normalize(json, tool)
51
53
  rescue ::JSON::ParserError => e
@@ -56,6 +58,17 @@ class Evilution::CLI::Commands::Compare < Evilution::CLI::Command
56
58
  raise Evilution::Error, e.message
57
59
  end
58
60
 
61
+ # Validate before detection: schema_version is an evilution-only marker. A
62
+ # future schema may rearrange the shape enough that Detector cannot classify
63
+ # it; in that case the user must still see "Upgrade the evilution gem", not
64
+ # "cannot detect tool".
65
+ def validate_session_schema(json, path)
66
+ return unless json.is_a?(Hash)
67
+ return unless json.key?("schema_version") || json.key?(:schema_version)
68
+
69
+ Evilution::Session::Schema.validate!(json, source: path)
70
+ end
71
+
59
72
  def normalize(json, tool)
60
73
  normalizer = Evilution::Compare::Normalizer.new
61
74
  case tool
@@ -16,6 +16,8 @@ class Evilution::CLI::Parser::CommandExtractor
16
16
  "gc" => :session_gc
17
17
  }.freeze
18
18
 
19
+ RUN_ALIASES = %w[run mutate].freeze
20
+
19
21
  TESTS_SUBCOMMANDS = { "list" => :tests_list }.freeze
20
22
  ENVIRONMENT_SUBCOMMANDS = { "show" => :environment_show }.freeze
21
23
  UTIL_SUBCOMMANDS = { "mutation" => :util_mutation }.freeze
@@ -51,7 +53,7 @@ class Evilution::CLI::Parser::CommandExtractor
51
53
  if SIMPLE_COMMANDS.key?(first)
52
54
  @command = SIMPLE_COMMANDS[first]
53
55
  @argv.shift
54
- elsif first == "run"
56
+ elsif RUN_ALIASES.include?(first)
55
57
  @argv.shift
56
58
  elsif SUBCOMMAND_FAMILIES.key?(first)
57
59
  @argv.shift
@@ -36,8 +36,8 @@ class Evilution::CLI::Parser::OptionsBuilder
36
36
  opts.separator ""
37
37
  opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
38
38
  opts.separator ""
39
- opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
40
- opts.separator " util {mutation}, environment {show}, compare, mcp, version"
39
+ opts.separator "Commands: run (default; alias: mutate), init, session {list,show,diff,gc}, subjects,"
40
+ opts.separator " tests {list}, util {mutation}, environment {show}, compare, mcp, version"
41
41
  opts.separator ""
42
42
  opts.separator "Options:"
43
43
  end
@@ -5,12 +5,19 @@ require "yaml"
5
5
  module Evilution::Config::FileLoader
6
6
  module_function
7
7
 
8
+ # Keys recognised in YAML config files. `target_files` is intentionally excluded
9
+ # because it is CLI-positional (the file paths after `evilution run`).
10
+ KNOWN_KEYS = (Evilution::Config::DEFAULTS.keys + %i[hooks]).uniq.freeze
11
+
8
12
  def load
9
13
  Evilution::Config::CONFIG_FILES.each do |path|
10
14
  next unless File.exist?(path)
11
15
 
12
16
  data = YAML.safe_load_file(path, symbolize_names: true)
13
- return data.is_a?(Hash) ? data : {}
17
+ return {} unless data.is_a?(Hash)
18
+
19
+ validate_schema!(data, path: path) if data.key?(:schema_version)
20
+ return data
14
21
  rescue Psych::SyntaxError, Psych::DisallowedClass => e
15
22
  raise Evilution::ConfigError.new("failed to parse config file #{path}: #{e.message}", file: path)
16
23
  rescue SystemCallError => e
@@ -19,4 +26,36 @@ module Evilution::Config::FileLoader
19
26
 
20
27
  {}
21
28
  end
29
+
30
+ def validate_schema!(data, path:)
31
+ validate_schema_version_value!(data[:schema_version], path: path)
32
+ validate_known_keys!(data.keys, path: path)
33
+ end
34
+
35
+ def validate_schema_version_value!(version, path:)
36
+ unless version.is_a?(Integer) && version.positive?
37
+ raise Evilution::ConfigError.new(
38
+ "invalid schema_version #{version.inspect} in #{path}: must be a positive Integer",
39
+ file: path
40
+ )
41
+ end
42
+
43
+ return if version <= Evilution::Config::CURRENT_SCHEMA_VERSION
44
+
45
+ raise Evilution::ConfigError.new(
46
+ "schema_version #{version} in #{path} is newer than this evilution gem supports " \
47
+ "(current: #{Evilution::Config::CURRENT_SCHEMA_VERSION}). Upgrade the gem.",
48
+ file: path
49
+ )
50
+ end
51
+
52
+ def validate_known_keys!(keys, path:)
53
+ unknown = keys - KNOWN_KEYS
54
+ return if unknown.empty?
55
+
56
+ raise Evilution::ConfigError.new(
57
+ "unknown key(s) #{unknown.inspect} in #{path}. Known keys: #{KNOWN_KEYS.sort.inspect}",
58
+ file: path
59
+ )
60
+ end
22
61
  end
@@ -6,8 +6,10 @@ require_relative "spec_selector"
6
6
 
7
7
  class Evilution::Config
8
8
  CONFIG_FILES = %w[.evilution.yml config/evilution.yml].freeze
9
+ CURRENT_SCHEMA_VERSION = 1
9
10
 
10
11
  DEFAULTS = {
12
+ schema_version: CURRENT_SCHEMA_VERSION,
11
13
  timeout: 30, format: :text, target: nil, min_score: 0.0, integration: :rspec,
12
14
  verbose: false, quiet: false, jobs: 1, fail_fast: nil, baseline: true,
13
15
  isolation: :auto, incremental: false, suggest_tests: false, progress: true,
@@ -21,7 +23,7 @@ class Evilution::Config
21
23
  profile: :default
22
24
  }.freeze
23
25
 
24
- attr_reader :target_files, :timeout, :format,
26
+ attr_reader :target_files, :schema_version, :timeout, :format,
25
27
  :target, :min_score, :integration, :verbose, :quiet,
26
28
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
27
29
  :progress, :save_session, :line_ranges, :spec_files, :hooks,
@@ -112,6 +114,13 @@ class Evilution::Config
112
114
  # Evilution configuration
113
115
  # See: https://github.com/marinazzio/evilution
114
116
 
117
+ # Schema version for this config file (current: #{CURRENT_SCHEMA_VERSION}).
118
+ # Declaring schema_version opts the file into strict validation:
119
+ # unknown keys raise ConfigError, and a future schema_version is
120
+ # rejected so an old gem cannot silently misread a newer config.
121
+ # Omit to keep the legacy lenient behavior (unknown keys ignored).
122
+ schema_version: #{CURRENT_SCHEMA_VERSION}
123
+
115
124
  # Per-mutation timeout in seconds (default: 30)
116
125
  # timeout: 30
117
126
 
@@ -208,6 +217,7 @@ class Evilution::Config
208
217
 
209
218
  SIMPLE_ATTR_TRANSFORMS = {
210
219
  target_files: ->(v) { Array(v) },
220
+ schema_version: nil,
211
221
  timeout: nil,
212
222
  format: :to_sym.to_proc,
213
223
  target: nil,
@@ -3,8 +3,15 @@
3
3
  require_relative "../heuristic"
4
4
 
5
5
  class Evilution::Equivalent::Heuristic::DeadCode
6
+ # Both operators produce statement-deletion-shaped edits. MutationPlanner
7
+ # dedupes by (file_path, mutated_source); whichever operator is registered
8
+ # first surfaces its name on the surviving mutation. Classify equivalence
9
+ # by edit shape, not by operator label, so dead-code classification holds
10
+ # regardless of registry order (EV-74e3 PR #1236 review).
11
+ STATEMENT_DELETION_OPERATORS = %w[statement_deletion last_expression_removal].to_set.freeze
12
+
6
13
  def match?(mutation)
7
- return false unless mutation.operator_name == "statement_deletion"
14
+ return false unless STATEMENT_DELETION_OPERATORS.include?(mutation.operator_name)
8
15
 
9
16
  node = mutation.subject.node
10
17
  return false unless node
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../feedback"
4
+
5
+ # Detects "setup misconfiguration" patterns where mutation testing returned a
6
+ # result that is technically valid (score 0.0 with all-errored mutations) but
7
+ # is almost certainly the wrong score because the worker process couldn't
8
+ # evaluate any mutated source.
9
+ #
10
+ # Most common cause: MCP runs default to `preload: false` to keep the long-lived
11
+ # MCP server clean. Rails / Zeitwerk projects that depend on autoload then fail
12
+ # in every worker with `NameError: uninitialized constant ...`. The user sees
13
+ # "0% PASS-but-FAIL" with no obvious hint that they need to pass an explicit
14
+ # `preload: spec/rails_helper.rb` option.
15
+ #
16
+ # When triggered, this returns a warning string the MCP response can surface
17
+ # alongside the trimmed report — turning a silent wrong score into a loud
18
+ # pointer at the likely fix.
19
+ module Evilution::Feedback::SetupWarning
20
+ module_function
21
+
22
+ ERROR_DOMINANCE_THRESHOLD = 0.8
23
+ ERROR_CLASS_CLUSTER_THRESHOLD = 0.8
24
+
25
+ def call(summary)
26
+ return nil if summary.nil?
27
+ return nil unless errors_dominate?(summary)
28
+
29
+ errored = summary.results.select(&:error?)
30
+ dominant_class = dominant_error_class(errored)
31
+ return nil unless dominant_class
32
+
33
+ message_for(dominant_class, errored.size, summary.total)
34
+ end
35
+
36
+ def errors_dominate?(summary)
37
+ return false if summary.total.zero?
38
+
39
+ summary.errors.to_f / summary.total >= ERROR_DOMINANCE_THRESHOLD
40
+ end
41
+ private_class_method :errors_dominate?
42
+
43
+ def dominant_error_class(errored_results)
44
+ return nil if errored_results.empty?
45
+
46
+ counts = errored_results.each_with_object(Hash.new(0)) do |result, acc|
47
+ acc[result.error_class] += 1 if result.error_class
48
+ end
49
+ return nil if counts.empty?
50
+
51
+ klass, count = counts.max_by { |_, v| v }
52
+ return nil if count.to_f / errored_results.size < ERROR_CLASS_CLUSTER_THRESHOLD
53
+
54
+ klass
55
+ end
56
+ private_class_method :dominant_error_class
57
+
58
+ NAME_ERROR_HINT = "Most mutations errored with NameError. This usually means autoloaded constants " \
59
+ "(Rails / Zeitwerk) weren't available when the mutation re-evaluated the source. " \
60
+ "Pass `preload: 'spec/rails_helper.rb'` (or your project's preload entry) so the " \
61
+ "MCP server requires it before forking workers."
62
+
63
+ LOAD_ERROR_HINT = "Most mutations errored with LoadError. A `require` in the mutated source path " \
64
+ "failed before any test ran. Check that the file's dependencies are reachable from " \
65
+ "the MCP server's load path, or pass `preload: '<entrypoint>'` to set them up."
66
+
67
+ GENERIC_HINT_TEMPLATE = "Most mutations errored with %<klass>s (%<count>d / %<total>d). The mutation " \
68
+ "score reflects this setup failure, not the test suite. Try the CLI for an " \
69
+ "independent reading, or pass `preload: '<path>'` if the failure is autoload-related."
70
+
71
+ def message_for(klass, count, total)
72
+ case klass.to_s
73
+ when "NameError" then NAME_ERROR_HINT
74
+ when "LoadError" then LOAD_ERROR_HINT
75
+ else format(GENERIC_HINT_TEMPLATE, klass: klass, count: count, total: total)
76
+ end
77
+ end
78
+ private_class_method :message_for
79
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::GemDetector
4
+ @cache = {}
5
+ @mutex = Mutex.new
6
+
7
+ class << self
8
+ def gem_root_for(path)
9
+ return nil if path.nil?
10
+
11
+ dir = starting_dir(path)
12
+ return nil if dir.nil?
13
+
14
+ @mutex.synchronize do
15
+ return @cache[dir] if @cache.key?(dir)
16
+
17
+ @cache[dir] = walk_up(dir)
18
+ end
19
+ end
20
+
21
+ def gem_root_for_any(paths)
22
+ Array(paths).each do |path|
23
+ root = gem_root_for(path)
24
+ return root if root
25
+ end
26
+ nil
27
+ end
28
+
29
+ def gem_entry_for(root, target_paths: nil)
30
+ gem_name = gem_name_for(root, target_paths: target_paths)
31
+ return nil unless gem_name
32
+
33
+ dotted = File.join(root, "lib", "#{gem_name.tr("-", "/")}.rb")
34
+ return dotted if File.file?(dotted)
35
+
36
+ flat = File.join(root, "lib", "#{gem_name}.rb")
37
+ return flat if File.file?(flat)
38
+
39
+ nil
40
+ end
41
+
42
+ def reset_cache!
43
+ @mutex.synchronize { @cache.clear }
44
+ end
45
+
46
+ private
47
+
48
+ def starting_dir(path)
49
+ return File.expand_path(path) if File.directory?(path)
50
+ return File.expand_path(File.dirname(path)) if File.file?(path)
51
+
52
+ parent = File.expand_path(File.dirname(path))
53
+ File.directory?(parent) ? parent : nil
54
+ end
55
+
56
+ def walk_up(dir)
57
+ current = dir
58
+ loop do
59
+ return current unless Dir.glob(File.join(current, "*.gemspec")).empty?
60
+
61
+ parent = File.dirname(current)
62
+ return nil if parent == current
63
+
64
+ current = parent
65
+ end
66
+ end
67
+
68
+ # When the root has multiple gemspecs (e.g. dotenv ships dotenv.gemspec
69
+ # alongside dotenv-rails.gemspec), `Dir.glob.first` is filesystem-order-
70
+ # dependent and often picks the wrong one — preloading the rails companion
71
+ # then raises `uninitialized constant Rails`. Disambiguate by:
72
+ # 1. exact-entry match — if a target is *exactly* the lib entry for a
73
+ # gemspec (`lib/dotenv/rails.rb` for `dotenv-rails.gemspec`), use it
74
+ # 2. first-lib-subdir match — `lib/dotenv/parser.rb` matches `dotenv`
75
+ # 3. fall back to the shortest gemspec basename — `dotenv` <
76
+ # `dotenv-rails`, which is the conventional "parent" gem.
77
+ def gem_name_for(root, target_paths: nil)
78
+ names = Dir.glob(File.join(root, "*.gemspec")).map { |p| File.basename(p, ".gemspec") }
79
+ return nil if names.empty?
80
+ return names.first if names.length == 1
81
+
82
+ paths = Array(target_paths)
83
+ match_by_exact_entry(root, names, paths) ||
84
+ match_by_subdir(root, names, paths) ||
85
+ names.min_by(&:length)
86
+ end
87
+
88
+ def match_by_exact_entry(root, names, paths)
89
+ paths.each do |path|
90
+ next if path.nil?
91
+
92
+ expanded = File.expand_path(path)
93
+ match = names.find { |n| entry_paths_for(root, n).include?(expanded) }
94
+ return match if match
95
+ end
96
+ nil
97
+ end
98
+
99
+ def entry_paths_for(root, gem_name)
100
+ [
101
+ File.join(root, "lib", "#{gem_name.tr("-", "/")}.rb"),
102
+ File.join(root, "lib", "#{gem_name}.rb")
103
+ ]
104
+ end
105
+
106
+ def match_by_subdir(root, names, paths)
107
+ paths.each do |path|
108
+ subdir = lib_subdir_for(root, path)
109
+ next if subdir.nil?
110
+
111
+ match = names.find { |n| n == subdir }
112
+ return match if match
113
+ end
114
+ nil
115
+ end
116
+
117
+ # For `<root>/lib/dotenv/parser.rb` returns "dotenv". For
118
+ # `<root>/lib/dotenv-rails.rb` returns "dotenv-rails". Returns nil when
119
+ # the target isn't under `<root>/lib/`.
120
+ def lib_subdir_for(root, path)
121
+ return nil if path.nil?
122
+
123
+ expanded = File.expand_path(path)
124
+ lib_root = File.join(File.expand_path(root), "lib")
125
+ return nil unless expanded.start_with?("#{lib_root}/")
126
+
127
+ relative = expanded[(lib_root.length + 1)..]
128
+ first_segment = relative.split("/", 2).first
129
+ File.basename(first_segment, ".rb")
130
+ end
131
+ end
132
+ end