Skip to content

Commit a6ed557

Browse files
committed
fix: 定数再読込の実行判定を厳密化 / Track completed constant definitions
1 parent e8be1b6 commit a6ed557

3 files changed

Lines changed: 346 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
- Required options accept negative exponent and radix notation such as `-1e3` and `-0x10` without mistaking them for another option.
99
- Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load.
1010
- Repeated loads retain assigned constant aliases when the source file is unchanged.
11-
- Repeated loads after source edits retain aliases created by direct, guarded, multiple, and `const_set` assignments while their active definitions remain, without reviving aliases behind newly disabled conditions.
11+
- Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, without reviving failed assignments or aliases behind disabled conditions.
1212
- Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error.
1313
- Framework argument errors raised by constructors are also wrapped in the same user-facing runner error.
1414
- Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs.

lib/rubycli/constant_capture.rb

Lines changed: 164 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ def capture(file)
1515
previous_names = @captured[normalized_file].dup
1616
previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {})
1717
current_assignment_definitions = assigned_constant_definitions(normalized_file)
18-
executed_lines = []
18+
executed_line_contexts = Hash.new { |hash, line| hash[line] = [] }
1919
assignment_events = Hash.new { |hash, line| hash[line] = [] }
2020
observed_events = []
2121
@captured[normalized_file] = []
2222
before_snapshot = constant_snapshot(normalized_file)
23-
trace = TracePoint.new(:class, :line, :c_call, :c_return) do |tp|
23+
trace = TracePoint.new(:class, :line, :call, :return, :b_call, :b_return, :c_call, :c_return) do |tp|
2424
location = tp.path
2525
next unless location && same_file?(normalized_file, location)
2626

@@ -32,7 +32,12 @@ def capture(file)
3232
ensure
3333
trace&.disable
3434
if normalized_file && before_snapshot
35-
apply_trace_events(observed_events, executed_lines, assignment_events, normalized_file)
35+
apply_trace_events(
36+
observed_events,
37+
executed_line_contexts,
38+
assignment_events,
39+
normalized_file
40+
)
3641
after_snapshot = constant_snapshot(normalized_file)
3742
changed_names = after_snapshot.keys.select do |name|
3843
before_snapshot[name] != after_snapshot[name]
@@ -43,7 +48,7 @@ def capture(file)
4348
definition_active = active_definition_retained?(
4449
previous_definitions,
4550
current_definitions,
46-
executed_lines,
51+
executed_line_contexts,
4752
assignment_events
4853
)
4954
after_snapshot.key?(name) && definition_active
@@ -128,11 +133,18 @@ def qualified_constant_name(owner_name, constant_name)
128133
"#{owner_name}::#{constant_name}"
129134
end
130135

131-
def apply_trace_events(events, executed_lines, assignment_events, file)
136+
def apply_trace_events(events, executed_line_contexts, assignment_events, file)
137+
active_context_events = []
132138
events.each do |event, target, line, method_id|
133139
case event
134140
when :line
135-
executed_lines << line
141+
executed_line_contexts[line] << active_context_events.dup
142+
when :call, :b_call
143+
active_context_events << event
144+
when :return
145+
remove_active_context_event(active_context_events, :call)
146+
when :b_return
147+
remove_active_context_event(active_context_events, :b_call)
136148
when :c_call
137149
if method_id == :const_added || (method_id == :warn && target.equal?(Warning))
138150
assignment_events[line] << method_id
@@ -146,6 +158,11 @@ def apply_trace_events(events, executed_lines, assignment_events, file)
146158
end
147159
end
148160

161+
def remove_active_context_event(active_context_events, event)
162+
index = active_context_events.rindex(event)
163+
active_context_events.delete_at(index) if index
164+
end
165+
149166
def constants_set_at(owner, file, line)
150167
owner_name = owner.equal?(Object) ? '' : owner.name
151168
return [] if owner_name.nil? || owner_name.start_with?('#<')
@@ -161,20 +178,37 @@ def constants_set_at(owner, file, line)
161178
end
162179
end
163180

164-
def active_definition_retained?(previous_definitions, current_definitions, executed_lines, assignment_events)
181+
def active_definition_retained?(
182+
previous_definitions,
183+
current_definitions,
184+
executed_line_contexts,
185+
assignment_events
186+
)
165187
previous_definitions.any? do |previous|
166188
current_definitions.any? do |current|
167189
next false unless previous[:signature] == current[:signature]
168190

169191
events = assignment_events[current[:line]]
170192
assignment_observed = events.include?(:const_added) || events.count(:warn) >= 2
171-
current[:persistent] ||
172-
assignment_observed ||
173-
(!current[:ambiguous_line] && executed_lines.include?(current[:line]))
193+
persistence_observed = current[:persistence_line] &&
194+
!current[:persistence_ambiguous] &&
195+
executed_line_contexts[current[:persistence_line]].any? do |active_context_events|
196+
context_requirements_met?(
197+
active_context_events,
198+
current[:required_context_events]
199+
)
200+
end
201+
assignment_observed || persistence_observed
174202
end
175203
end
176204
end
177205

206+
def context_requirements_met?(active_context_events, required_context_events)
207+
required_context_events.uniq.all? do |event|
208+
active_context_events.count(event) >= required_context_events.count(event)
209+
end
210+
end
211+
178212
def assigned_constant_definitions(file)
179213
syntax_tree = Ripper.sexp(File.read(file))
180214
return {} unless syntax_tree
@@ -248,11 +282,24 @@ def collect_assigned_constant_definitions(node, namespace, contexts, definitions
248282
collect_assigned_constant_definitions(node[2], namespace, contexts, definitions)
249283
body_context = contexts + [[[node.first, canonical_syntax(node[2])], source_line(node[1])]]
250284
collect_assigned_constant_definitions(node[3], namespace, body_context, definitions)
251-
when :def, :defs, :lambda, :do_block, :brace_block
252-
lazy_context = contexts + [[[node.first], source_line(node)]]
285+
when :method_add_block
286+
collect_assigned_constant_definitions(node[1], namespace, contexts, definitions)
287+
lazy_context = contexts + [[[:block], source_line(node[1]), :b_call]]
288+
collect_assigned_constant_definitions(node[2], namespace, lazy_context, definitions)
289+
when :def, :defs
290+
lazy_context = contexts + [[[node.first], source_line(node), :call]]
253291
node.drop(1).each do |child|
254292
collect_assigned_constant_definitions(child, namespace, lazy_context, definitions)
255293
end
294+
when :lambda
295+
lazy_context = contexts + [[[node.first], source_line(node), :b_call]]
296+
node.drop(1).each do |child|
297+
collect_assigned_constant_definitions(child, namespace, lazy_context, definitions)
298+
end
299+
when :do_block, :brace_block
300+
node.drop(1).each do |child|
301+
collect_assigned_constant_definitions(child, namespace, contexts, definitions)
302+
end
256303
else
257304
node.each do |child|
258305
collect_assigned_constant_definitions(child, namespace, contexts, definitions)
@@ -266,14 +313,19 @@ def collect_assignment_targets(node, namespace, definitions, signature, contexts
266313
assigned_name = constant_name_from_node(node, namespace)
267314
if assigned_name
268315
line = source_line(node)
269-
# A line event can precede a skipped postfix/short-circuit assignment.
270-
ambiguous_line = contexts.any? { |context| context[1] == line }
271-
persistent = constant_existence_guard?(contexts, assigned_name) && !lazy_context?(contexts)
316+
persistence = persistence_metadata(
317+
contexts,
318+
assigned_name,
319+
namespace,
320+
line,
321+
signature[1] == '||='
322+
)
272323
definition = {
273324
signature: signature,
274325
line: line,
275-
ambiguous_line: ambiguous_line,
276-
persistent: persistent
326+
persistence_line: persistence[:line],
327+
persistence_ambiguous: persistence[:ambiguous],
328+
required_context_events: persistence[:required_context_events]
277329
}
278330
definitions[assigned_name] = Array(definitions[assigned_name]) | [definition]
279331
elsif node.is_a?(Array)
@@ -292,7 +344,7 @@ def collect_const_set_definition(node, namespace, contexts, definitions)
292344
constant_name = literal_constant_name(arguments.first)
293345
return unless constant_name
294346

295-
owner_name = if receiver.nil?
347+
owner_name = if receiver.nil? || self_receiver?(receiver)
296348
namespace.join('::')
297349
elsif object_receiver?(receiver)
298350
''
@@ -304,13 +356,13 @@ def collect_const_set_definition(node, namespace, contexts, definitions)
304356
assigned_name = [owner_name, constant_name].reject(&:empty?).join('::')
305357
signature = [:const_set, nil, contexts.map(&:first)]
306358
line = source_line(node)
307-
ambiguous_line = contexts.any? { |context| context[1] == line }
359+
persistence = persistence_metadata(contexts, assigned_name, namespace, line, false)
308360
definition = {
309361
signature: signature,
310362
line: line,
311-
ambiguous_line: ambiguous_line,
312-
persistent: contexts.empty? ||
313-
(constant_existence_guard?(contexts, assigned_name) && !lazy_context?(contexts))
363+
persistence_line: persistence[:line],
364+
persistence_ambiguous: persistence[:ambiguous],
365+
required_context_events: persistence[:required_context_events]
314366
}
315367
definitions[assigned_name] = Array(definitions[assigned_name]) | [definition]
316368
end
@@ -349,28 +401,103 @@ def object_receiver?(node)
349401
node&.first == :var_ref && node.dig(1, 0) == :@const && node.dig(1, 1) == 'Object'
350402
end
351403

352-
def constant_existence_guard?(contexts, assigned_name)
353-
constant_name = assigned_name.split('::').last
354-
contexts.any? do |context, _line|
355-
has_method = find_syntax_token(context) { |_type, value| value == 'const_defined?' }
356-
has_constant = find_syntax_token(context) { |_type, value| value == constant_name }
357-
defined_guard = syntax_node_with_token?(context, :defined, constant_name)
358-
(has_method && has_constant) || defined_guard
404+
def self_receiver?(node)
405+
node&.first == :var_ref && node.dig(1, 0) == :@kw && node.dig(1, 1) == 'self'
406+
end
407+
408+
def persistence_metadata(contexts, assigned_name, namespace, assignment_line, short_circuit_assignment)
409+
guard = contexts.reverse.find do |context|
410+
missing_constant_guard?(context.first, assigned_name, namespace)
411+
end
412+
persistence_line = guard&.[](1)
413+
persistence_line ||= assignment_line if short_circuit_assignment
414+
return { line: nil, ambiguous: false, required_context_events: [] } unless persistence_line
415+
416+
same_line_contexts = contexts.select { |context| context[1] == persistence_line }
417+
ambiguous = same_line_contexts.any? do |context|
418+
!lazy_context?(context) &&
419+
!missing_constant_guard?(context.first, assigned_name, namespace)
359420
end
421+
required_context_events = same_line_contexts.filter_map do |context|
422+
context[2]
423+
end
424+
{
425+
line: persistence_line,
426+
ambiguous: ambiguous,
427+
required_context_events: required_context_events
428+
}
429+
end
430+
431+
def missing_constant_guard?(context, assigned_name, namespace)
432+
condition, expected = guarded_condition(context)
433+
return false unless condition
434+
435+
condition, expected = unwrap_guard_condition(condition, expected)
436+
expected == false && guarded_constant_name(condition, namespace) == assigned_name
360437
end
361438

362-
def syntax_node_with_token?(node, node_type, token_value)
363-
return false unless node.is_a?(Array)
364-
if node.first == node_type
365-
return true if find_syntax_token(node) { |_type, value| value == token_value }
439+
def guarded_condition(context)
440+
case context.first
441+
when :if
442+
[context[2], context[1] == :then]
443+
when :unless
444+
[context[2], context[1] != :then]
445+
when :if_mod
446+
[context[1], true]
447+
when :unless_mod
448+
[context[1], false]
449+
when :ifop
450+
[context[2], context[1] == :then]
451+
when :binary
452+
[context[2], context[1] == :'&&']
453+
end
454+
end
455+
456+
def unwrap_guard_condition(condition, expected)
457+
loop do
458+
case condition&.first
459+
when :paren
460+
expressions = condition[1]
461+
break unless expressions.is_a?(Array) && expressions.length == 1
462+
463+
condition = expressions.first
464+
when :unary
465+
break unless condition[1] == :!
466+
467+
condition = condition[2]
468+
expected = !expected
469+
else
470+
break
471+
end
366472
end
473+
[condition, expected]
474+
end
475+
476+
def guarded_constant_name(condition, namespace)
477+
if condition&.first == :defined
478+
return constant_name_from_node(condition[1], namespace)
479+
end
480+
481+
receiver, method_name, arguments = call_parts(condition)
482+
return nil unless method_name == 'const_defined?'
483+
484+
constant_name = literal_constant_name(arguments.first)
485+
return nil unless constant_name
486+
487+
owner_name = if receiver.nil? || self_receiver?(receiver)
488+
namespace.join('::')
489+
elsif object_receiver?(receiver)
490+
''
491+
else
492+
constant_name_from_node(receiver, namespace)
493+
end
494+
return nil if owner_name.nil?
367495

368-
node.any? { |child| syntax_node_with_token?(child, node_type, token_value) }
496+
[owner_name, constant_name].reject(&:empty?).join('::')
369497
end
370498

371-
def lazy_context?(contexts)
372-
lazy_types = %i[def defs lambda do_block brace_block]
373-
contexts.any? { |context, _line| lazy_types.include?(context.first) }
499+
def lazy_context?(context)
500+
!context[2].nil?
374501
end
375502

376503
def find_syntax_token(node, &predicate)

0 commit comments

Comments
 (0)