Skip to content

Commit cb9c8e0

Browse files
committed
fix: 静的解析の副作用を防止 / Avoid analysis side effects
1 parent a668fd1 commit cb9c8e0

5 files changed

Lines changed: 72 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load.
1111
- Repeated loads retain assigned constant aliases when the source file is unchanged.
1212
- Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, including receivers found by Ruby's lexical constant fallback, without reviving failed assignments or aliases behind disabled conditions.
13+
- Constant discovery analyzes the preloaded source without triggering autoloads, so inactive autoload branches and self-removing target files do not add side effects or fail after a successful load.
1314
- Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error.
1415
- Framework argument errors raised by constructors are also wrapped in the same user-facing runner error.
1516
- Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs.

lib/rubycli/argument_parser.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,10 @@ def valid_eval_option_value?(token, known_option_names)
221221
end
222222

223223
def known_option_token?(token, known_option_names)
224-
return false unless token&.match?(/\A-{1,2}[a-zA-Z0-9_-]+\z/)
224+
match = token&.match(/\A-{1,2}([a-zA-Z0-9_-]+)(?:=.*)?\z/)
225+
return false unless match
225226

226-
key = token.delete_prefix('--').delete_prefix('-').tr('-', '_')
227+
key = match[1].tr('-', '_')
227228
return true if known_option_names.include?(key)
228229

229230
known_option_names.one? { |name| name.start_with?(key) }

lib/rubycli/constant_capture.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def initialize
1212

1313
def capture(file)
1414
normalized_file = normalize(file)
15+
source = File.read(normalized_file)
1516
previous_names = @captured[normalized_file].dup
1617
previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {})
1718
executed_line_contexts = Hash.new { |hash, line| hash[line] = [] }
@@ -31,7 +32,7 @@ def capture(file)
3132
ensure
3233
trace&.disable
3334
if normalized_file && before_snapshot
34-
current_assignment_definitions = assigned_constant_definitions(normalized_file)
35+
current_assignment_definitions = assigned_constant_definitions(source)
3536
apply_trace_events(
3637
observed_events,
3738
executed_line_contexts,
@@ -209,8 +210,8 @@ def context_requirements_met?(active_context_events, required_context_events)
209210
end
210211
end
211212

212-
def assigned_constant_definitions(file)
213-
syntax_tree = Ripper.sexp(File.read(file))
213+
def assigned_constant_definitions(source)
214+
syntax_tree = Ripper.sexp(source)
214215
return {} unless syntax_tree
215216

216217
collect_assigned_constant_definitions(syntax_tree, [], [], {})
@@ -539,8 +540,10 @@ def resolve_lexical_constant_name(constant_name, namespace)
539540

540541
def constant_path_defined?(name)
541542
parts = name.split('::')
542-
parts.reduce(Object) do |owner, part|
543+
parts.each_with_index.reduce(Object) do |owner, (part, index)|
543544
return false unless owner.is_a?(Module) && owner.const_defined?(part, false)
545+
return true if index == parts.length - 1
546+
return false if owner.autoload?(part, false)
544547

545548
owner.const_get(part, false)
546549
end

test/argument_parser_test.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -363,13 +363,17 @@ def test_eval_mode_accepts_option_looking_required_values
363363
def test_eval_mode_does_not_consume_known_option_as_required_value
364364
method = EvalRequiredOptionSamples.method(:run)
365365

366-
error = assert_raises(Rubycli::ArgumentError) do
367-
Rubycli.with_eval_mode(true) do
366+
Rubycli.with_eval_mode(true) do
367+
plain_error = assert_raises(Rubycli::ArgumentError) do
368368
@parser.parse(['--callback', '--verbose'], method)
369369
end
370-
end
370+
embedded_error = assert_raises(Rubycli::ArgumentError) do
371+
@parser.parse(['--callback', '--verbose=1'], method)
372+
end
371373

372-
assert_includes error.message, "Option '--callback' requires a value"
374+
assert_includes plain_error.message, "Option '--callback' requires a value"
375+
assert_includes embedded_error.message, "Option '--callback' requires a value"
376+
end
373377
end
374378

375379
def test_validate_inputs_warns_when_values_outside_choices

test/constant_capture_test.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,59 @@ module CaptureFallbackConstSetNamespace
590590
end
591591
end
592592

593+
def test_does_not_trigger_autoload_while_resolving_const_set_receiver
594+
capture = Rubycli::ConstantCapture.new
595+
previous_marker = ENV['RUBYCLI_AUTOLOAD_CAPTURED']
596+
Tempfile.create(['autoload_side_effect', '.rb']) do |autoload_file|
597+
autoload_file.write(<<~RUBY)
598+
ENV['RUBYCLI_AUTOLOAD_CAPTURED'] = 'yes'
599+
module CaptureAutoloadConstSetOwner; end
600+
RUBY
601+
autoload_file.flush
602+
603+
Tempfile.create(['autoload_const_set_alias', '.rb']) do |target_file|
604+
target_file.write(<<~RUBY)
605+
autoload :CaptureAutoloadConstSetOwner, #{autoload_file.path.dump}
606+
if false
607+
CaptureAutoloadConstSetOwner.const_set(:Runner, Module.new)
608+
end
609+
module CaptureAutoloadActualRunner
610+
def self.run; end
611+
end
612+
RUBY
613+
target_file.flush
614+
615+
capture_io { capture.capture(target_file.path) { load target_file.path } }
616+
617+
assert_includes capture.constants_for(target_file.path), 'CaptureAutoloadActualRunner'
618+
assert_nil ENV['RUBYCLI_AUTOLOAD_CAPTURED']
619+
end
620+
ensure
621+
ENV['RUBYCLI_AUTOLOAD_CAPTURED'] = previous_marker
622+
cleanup_constant(:CaptureAutoloadConstSetOwner)
623+
cleanup_constant(:CaptureAutoloadActualRunner)
624+
end
625+
end
626+
627+
def test_uses_preloaded_source_when_target_deletes_itself
628+
capture = Rubycli::ConstantCapture.new
629+
Tempfile.create(['self_deleting_capture', '.rb']) do |file|
630+
file.write(<<~RUBY)
631+
module CaptureSelfDeletingRunner
632+
def self.run; end
633+
end
634+
File.delete(__FILE__)
635+
RUBY
636+
file.flush
637+
638+
capture_io { capture.capture(file.path) { load file.path } }
639+
640+
assert_includes capture.constants_for(file.path), 'CaptureSelfDeletingRunner'
641+
ensure
642+
cleanup_constant(:CaptureSelfDeletingRunner)
643+
end
644+
end
645+
593646
def test_does_not_match_existence_guard_for_another_namespace
594647
capture = Rubycli::ConstantCapture.new
595648
Tempfile.create(['qualified_guard_alias', '.rb']) do |file|

0 commit comments

Comments
 (0)