evilution 1.3.0 → 1.5.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +132 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +136 -38
  6. data/docs/architecture.md +135 -11
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +121 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/class_body.rb +101 -0
  13. data/lib/evilution/ast/included_block.rb +41 -0
  14. data/lib/evilution/ast/literal_callable.rb +21 -0
  15. data/lib/evilution/ast/never_nil.rb +36 -0
  16. data/lib/evilution/ast/parser.rb +187 -21
  17. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  18. data/lib/evilution/ast/pattern/parser.rb +14 -15
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/round_half_mode.rb +38 -0
  21. data/lib/evilution/ast/scope_declaration.rb +45 -0
  22. data/lib/evilution/ast/source_surgeon.rb +2 -3
  23. data/lib/evilution/ast/syntax_check.rb +56 -0
  24. data/lib/evilution/ast/uncovered_code.rb +88 -0
  25. data/lib/evilution/ast/value_object_definition.rb +25 -0
  26. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  27. data/lib/evilution/baseline/report.rb +66 -0
  28. data/lib/evilution/baseline/spec_failure.rb +38 -0
  29. data/lib/evilution/baseline.rb +67 -30
  30. data/lib/evilution/cli/exit_guard.rb +26 -4
  31. data/lib/evilution/cli/parser/file_args.rb +2 -1
  32. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  33. data/lib/evilution/cli.rb +3 -2
  34. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  35. data/lib/evilution/config.rb +3 -2
  36. data/lib/evilution/equivalent/detector.rb +3 -1
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  38. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  39. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  40. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  41. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  42. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  43. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  44. data/lib/evilution/example_filter.rb +45 -0
  45. data/lib/evilution/hooks/registry.rb +2 -1
  46. data/lib/evilution/integration/base.rb +11 -1
  47. data/lib/evilution/integration/known_failures.rb +26 -0
  48. data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
  49. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  50. data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
  51. data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
  52. data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
  53. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  54. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  55. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  56. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  57. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  58. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  59. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  60. data/lib/evilution/integration/minitest.rb +45 -6
  61. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  62. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  63. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  64. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  65. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  66. data/lib/evilution/integration/rspec.rb +22 -4
  67. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  68. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  69. data/lib/evilution/integration/test_unit.rb +37 -9
  70. data/lib/evilution/isolation/fork.rb +84 -11
  71. data/lib/evilution/isolation/in_process.rb +100 -8
  72. data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
  73. data/lib/evilution/mcp/info_tool.rb +3 -2
  74. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  75. data/lib/evilution/memory/leak_check.rb +27 -3
  76. data/lib/evilution/mutation.rb +21 -9
  77. data/lib/evilution/mutator/base.rb +69 -5
  78. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  79. data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
  80. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  81. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  82. data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
  83. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  84. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  85. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  86. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  87. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  88. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  89. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  90. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  91. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  92. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  93. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  94. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  95. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  96. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  97. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  98. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  99. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  100. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  101. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  102. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  103. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  104. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  105. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  106. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  107. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  108. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  109. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  110. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  111. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  112. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  113. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  114. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  115. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  116. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  117. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  118. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  119. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  120. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  121. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  122. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  123. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  124. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  125. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  126. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  127. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  128. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  129. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  130. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  131. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  132. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  133. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  134. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  135. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  136. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  137. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  138. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  139. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  140. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  141. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  142. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  143. data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
  144. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  145. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  146. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  147. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  148. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  149. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  150. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  151. data/lib/evilution/mutator/primitives.rb +24 -2
  152. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  153. data/lib/evilution/mutator/registry.rb +58 -2
  154. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  155. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  156. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  157. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  158. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  159. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  160. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  161. data/lib/evilution/parallel/work_queue.rb +15 -9
  162. data/lib/evilution/process_supervisor.rb +3 -2
  163. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  164. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  165. data/lib/evilution/reporter/json/baseline.rb +15 -0
  166. data/lib/evilution/reporter/json.rb +7 -2
  167. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  168. data/lib/evilution/result/mutation_result.rb +9 -1
  169. data/lib/evilution/result/summary.rb +42 -2
  170. data/lib/evilution/runner/baseline_runner.rb +9 -4
  171. data/lib/evilution/runner/canary.rb +2 -57
  172. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  173. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  174. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  175. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  176. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  177. data/lib/evilution/runner/mutation_executor.rb +1 -1
  178. data/lib/evilution/runner/mutation_planner.rb +11 -2
  179. data/lib/evilution/runner/report_publisher.rb +3 -2
  180. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  181. data/lib/evilution/runner/subject_pipeline.rb +57 -43
  182. data/lib/evilution/runner.rb +5 -2
  183. data/lib/evilution/subject.rb +25 -2
  184. data/lib/evilution/version.rb +1 -1
  185. data/lib/evilution.rb +90 -0
  186. data/script/memory_check +62 -16
  187. metadata +113 -4
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+ require_relative "literal_callable"
6
+
7
+ # The callables written out in a callback or validation declaration of a
8
+ # class body:
9
+ #
10
+ # validate :credit_limit, if: -> { paid? && total.positive? }
11
+ # validates :title, presence: true, unless: [-> { draft? }, :imported?]
12
+ # before_save { self.slug = title.parameterize }
13
+ # after_commit -> { notify }, on: :create
14
+ #
15
+ # That is the condition given to `if:` / `unless:`, and the callback itself
16
+ # when it is a block or a lambda. A condition or callback named by a symbol
17
+ # is a method, and a subject of its own already.
18
+ #
19
+ # Declarations are recognised by name -- `validate`, `validates`,
20
+ # `validates_*`, `before_*`, `after_*`, `around_*` -- and only receiver-less,
21
+ # directly in a class body: that is the call the mutated file re-runs.
22
+ module Evilution::AST::CallbackDeclaration
23
+ # body: the node holding the callable's body. declaration: the call it is
24
+ # written in. label: how the declaration reads, for naming the subject.
25
+ Callable = Data.define(:body, :declaration, :label)
26
+
27
+ NAME = /\A(?:validate|validates|validates_\w+|(?:before|after|around)_\w+)\z/
28
+ CONDITIONS = %w[if unless].freeze
29
+ private_constant :NAME, :CONDITIONS
30
+
31
+ # The callables of every callback declaration among a class body's direct
32
+ # statements.
33
+ def self.in_body(body)
34
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
35
+ return [] unless statements.is_a?(Prism::StatementsNode)
36
+
37
+ statements.body.select { |node| match?(node) }.flat_map { |declaration| callables(declaration) }
38
+ end
39
+
40
+ # Whether node is a callback declaration that can be run again on its own.
41
+ # One holding a heredoc cannot be wrapped on its own lines, and is left out.
42
+ def self.match?(node)
43
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && NAME.match?(node.name.to_s) && !heredoc?(node)
44
+ end
45
+
46
+ def self.callables(declaration)
47
+ label = label_of(declaration)
48
+ bodies_of(declaration).map { |body| Callable.new(body: body, declaration: declaration, label: label) }
49
+ end
50
+
51
+ # `validate :credit_limit, if: ...` reads as `validate(:credit_limit)`; a
52
+ # declaration naming nothing, as its own name.
53
+ def self.label_of(declaration)
54
+ named = arguments_of(declaration).find { |argument| argument.is_a?(Prism::SymbolNode) }
55
+ named ? "#{declaration.name}(:#{named.unescaped})" : declaration.name.to_s
56
+ end
57
+
58
+ def self.bodies_of(declaration)
59
+ arguments = arguments_of(declaration)
60
+ bodies = arguments.filter_map { |argument| Evilution::AST::LiteralCallable.body_of(argument) }
61
+ bodies.concat(condition_bodies(arguments.last))
62
+ bodies << declaration.block if declaration.block.is_a?(Prism::BlockNode)
63
+ bodies
64
+ end
65
+
66
+ # `if: -> { }` and `unless: [-> { }, :named]` alike.
67
+ def self.condition_bodies(keywords)
68
+ return [] unless keywords.is_a?(Prism::KeywordHashNode)
69
+
70
+ keywords.elements.select { |pair| condition?(pair) }.flat_map do |pair|
71
+ values = pair.value.is_a?(Prism::ArrayNode) ? pair.value.elements : [pair.value]
72
+ values.filter_map { |value| Evilution::AST::LiteralCallable.body_of(value) }
73
+ end
74
+ end
75
+
76
+ def self.condition?(pair)
77
+ pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) && CONDITIONS.include?(pair.key.unescaped)
78
+ end
79
+
80
+ def self.arguments_of(call)
81
+ call.arguments ? call.arguments.arguments : []
82
+ end
83
+
84
+ def self.heredoc?(node)
85
+ return true if node.respond_to?(:heredoc?) && node.heredoc?
86
+
87
+ node.compact_child_nodes.any? { |child| heredoc?(child) }
88
+ end
89
+
90
+ private_class_method :callables, :label_of, :bodies_of, :condition_bodies, :condition?, :arguments_of, :heredoc?
91
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # The body of a class, module or `class << self`: the code written outside
7
+ # every method, where no method subject reaches it.
8
+ #
9
+ # Operators that mutate such code attribute their mutants to one method, so
10
+ # each mutant has exactly one subject. That method is the scope's anchor: the
11
+ # first method written in the scope, in source order. Blocks of the body
12
+ # (`included do ... end`) are searched, nested scopes are not: their methods
13
+ # anchor the inner scope. A scope with no method of its own borrows the anchor
14
+ # of the first singleton class in its body that has one, so a class whose
15
+ # methods all sit in `class << self` is still reached.
16
+ class Evilution::AST::ClassBody
17
+ SCOPES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
18
+ # Nodes whose children belong to something else: a method, or another scope.
19
+ OPAQUE = [Prism::DefNode, *SCOPES].freeze
20
+
21
+ # The location that closes each kind of conditional or loop.
22
+ CLOSINGS = {
23
+ Prism::IfNode => :end_keyword_loc,
24
+ Prism::UnlessNode => :end_keyword_loc,
25
+ Prism::ElseNode => :end_keyword_loc,
26
+ Prism::WhileNode => :closing_loc,
27
+ Prism::UntilNode => :closing_loc
28
+ }.freeze
29
+
30
+ attr_reader :node
31
+
32
+ # The bodies anchored at the method starting on the given line, outermost
33
+ # first. A method anchors its own scope, and with it any enclosing scope
34
+ # that borrows from it.
35
+ def self.anchored_at(tree, line)
36
+ scopes_in(tree).map { |scope| new(scope) }.select { |body| body.anchor_line == line }
37
+ end
38
+
39
+ def self.scopes_in(node)
40
+ node.compact_child_nodes.flat_map do |child|
41
+ nested = scopes_in(child)
42
+ SCOPES.include?(child.class) ? [child, *nested] : nested
43
+ end
44
+ end
45
+
46
+ private_class_method :scopes_in
47
+
48
+ def initialize(node)
49
+ @node = node
50
+ end
51
+
52
+ def anchor_line
53
+ first_method = contents.find { |child| child.is_a?(Prism::DefNode) }
54
+ return first_method.location.start_line if first_method
55
+
56
+ singleton_classes = contents.grep(Prism::SingletonClassNode)
57
+ singleton_classes.filter_map { |scope| self.class.new(scope).anchor_line }.first
58
+ end
59
+
60
+ # The statements of the body the block selects, each one removable: cut
61
+ # out, it leaves code that still parses. Methods and nested scopes are
62
+ # neither offered nor searched, and neither is a statement guarded by a
63
+ # modifier (`include Foo if legacy?`) or an expression used as a value.
64
+ def declarations(&)
65
+ body = @node.body
66
+ return [] unless body
67
+
68
+ statements_in(body, removable: body.is_a?(Prism::StatementsNode)).select(&)
69
+ end
70
+
71
+ private
72
+
73
+ # Every node written in the scope, in source order, down to the methods and
74
+ # nested scopes, which are listed but not entered. The scope's own header
75
+ # (its name, its superclass expression) is not part of the body.
76
+ def contents
77
+ @contents ||= @node.body ? descend(@node.body) : []
78
+ end
79
+
80
+ def statements_in(node, removable:)
81
+ node.compact_child_nodes.flat_map do |child|
82
+ next [] if OPAQUE.include?(child.class)
83
+
84
+ nested = statements_in(child, removable: child.is_a?(Prism::StatementsNode) && !modifier?(node))
85
+ removable ? [child, *nested] : nested
86
+ end
87
+ end
88
+
89
+ # A conditional or loop written without its closing keyword: the modifier
90
+ # forms and the ternary, whose statements cannot be cut out.
91
+ def modifier?(node)
92
+ closing = CLOSINGS[node.class]
93
+ !closing.nil? && node.public_send(closing).nil?
94
+ end
95
+
96
+ def descend(node)
97
+ node.compact_child_nodes.flat_map do |child|
98
+ OPAQUE.include?(child.class) ? [child] : [child, *descend(child)]
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # The block of an `ActiveSupport::Concern`'s `included do ... end`: code
7
+ # written in a module body that runs in the body of each class including the
8
+ # module. Only the receiver-less, argument-less form with a literal block
9
+ # counts -- `def self.included(base)` and `included(base) { }` are plain Ruby
10
+ # hooks with rules of their own.
11
+ module Evilution::AST::IncludedBlock
12
+ # The block node, or nil when node is not an `included` block call.
13
+ def self.of(node)
14
+ hook_block(node, :included)
15
+ end
16
+
17
+ # The block node of a `prepended do ... end`, the same hook for the classes
18
+ # that prepend the module; nil when node is not one.
19
+ def self.prepended(node)
20
+ hook_block(node, :prepended)
21
+ end
22
+
23
+ def self.hook_block(node, name)
24
+ return nil unless node.is_a?(Prism::CallNode) && node.name == name
25
+ return nil unless node.receiver.nil? && node.arguments.nil?
26
+
27
+ node.block if node.block.is_a?(Prism::BlockNode)
28
+ end
29
+ private_class_method :hook_block
30
+
31
+ # The included and prepended blocks among a module body's direct statements.
32
+ def self.hooks_in_body(body)
33
+ direct_statements(body).filter_map { |node| of(node) || prepended(node) }
34
+ end
35
+
36
+ def self.direct_statements(body)
37
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
38
+ statements.is_a?(Prism::StatementsNode) ? statements.body : []
39
+ end
40
+ private_class_method :direct_statements
41
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # A callable written out where it is used: `-> { }`, `lambda { }` or
7
+ # `proc { }`. Its body is what mutations go into. A constant, a variable or
8
+ # `method(:name)` in the same place has no body of its own to mutate.
9
+ module Evilution::AST::LiteralCallable
10
+ BLOCK_CALLS = %i[lambda proc].freeze
11
+ private_constant :BLOCK_CALLS
12
+
13
+ # The node holding the callable's body -- a LambdaNode, or the BlockNode of
14
+ # a `lambda` / `proc` call -- or nil when node is not a literal callable.
15
+ def self.body_of(node)
16
+ return node if node.is_a?(Prism::LambdaNode)
17
+ return nil unless node.is_a?(Prism::CallNode) && node.receiver.nil? && BLOCK_CALLS.include?(node.name)
18
+
19
+ node.block if node.block.is_a?(Prism::BlockNode)
20
+ end
21
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # Expressions whose value is never nil whatever the program does: `self` and
7
+ # the literals other than `nil`. Anything read or computed (a variable, a
8
+ # constant, a call) is not one.
9
+ module Evilution::AST::NeverNil
10
+ NODE_TYPES = [
11
+ Prism::SelfNode,
12
+ Prism::StringNode,
13
+ Prism::InterpolatedStringNode,
14
+ Prism::XStringNode,
15
+ Prism::InterpolatedXStringNode,
16
+ Prism::SymbolNode,
17
+ Prism::InterpolatedSymbolNode,
18
+ Prism::IntegerNode,
19
+ Prism::FloatNode,
20
+ Prism::RationalNode,
21
+ Prism::ImaginaryNode,
22
+ Prism::ArrayNode,
23
+ Prism::HashNode,
24
+ Prism::RangeNode,
25
+ Prism::RegularExpressionNode,
26
+ Prism::InterpolatedRegularExpressionNode,
27
+ Prism::TrueNode,
28
+ Prism::FalseNode,
29
+ Prism::LambdaNode
30
+ ].freeze
31
+ private_constant :NODE_TYPES
32
+
33
+ def self.node?(node)
34
+ NODE_TYPES.any? { |type| node.is_a?(type) }
35
+ end
36
+ end
@@ -2,6 +2,11 @@
2
2
 
3
3
  require "prism"
4
4
  require_relative "../ast"
5
+ require_relative "value_object_definition"
6
+ require_relative "scope_declaration"
7
+ require_relative "aasm_declaration"
8
+ require_relative "included_block"
9
+ require_relative "callback_declaration"
5
10
 
6
11
  module Evilution::AST
7
12
  class Parser
@@ -40,55 +45,216 @@ module Evilution::AST
40
45
  @file_path = file_path
41
46
  @subjects = []
42
47
  @context = []
48
+ @singleton = [false]
49
+ @hooks = []
43
50
  end
44
51
 
45
52
  def visit_module_node(node)
46
- @context.push(constant_name(node.constant_path))
47
- super
48
- @context.pop
53
+ within_context(constant_name(node.constant_path), rooted: rooted?(node.constant_path)) do
54
+ hooks = IncludedBlock.hooks_in_body(node.body)
55
+ within_hooks(hooks) do
56
+ hooks.each { |block| add_declaration_subjects(block.body) }
57
+ within_scope(singleton: false) { super }
58
+ end
59
+ end
49
60
  end
50
61
 
62
+ # A class whose superclass is a value-object definition
63
+ # (`class Coord < Data.define(:lat, :lng)`) makes a constant subject of
64
+ # that definition, named after the class.
51
65
  def visit_class_node(node)
52
- @context.push(constant_name(node.constant_path))
53
- super
54
- @context.pop
66
+ within_context(constant_name(node.constant_path), rooted: rooted?(node.constant_path)) do
67
+ superclass = node.superclass
68
+ add_subject(superclass, @context.join("::"), :constant) if ValueObjectDefinition.match?(superclass)
69
+ add_declaration_subjects(node.body)
70
+ within_scope(singleton: false) { super }
71
+ end
72
+ end
73
+
74
+ # A `def` inside `class << self` defines a method on the enclosing
75
+ # class's singleton, like `def self.name`; inside `class << Store` it
76
+ # defines one on Store.
77
+ def visit_singleton_class_node(node)
78
+ expression = node.expression
79
+ return within_scope(singleton: true) { super } unless constant_expression?(expression)
80
+
81
+ within_context(constant_name(expression), rooted: rooted?(expression)) do
82
+ within_scope(singleton: true) { super }
83
+ end
55
84
  end
56
85
 
57
86
  def visit_def_node(node)
58
- separator = node.receiver ? "." : "#"
59
- add_subject(node, separator)
87
+ separator = node.receiver || @singleton.last ? "." : "#"
88
+ add_subject(node, "#{@context.join("::")}#{separator}#{node.name}", :method)
89
+ super
90
+ end
91
+
92
+ # An assignment to a constant cannot be written inside a method, so it is
93
+ # a subject of its own: the whole assignment, named after the constant. A
94
+ # value-object definition keeps its narrower constant subject instead.
95
+ def visit_constant_write_node(node)
96
+ return within_definition(node.value, node.name.to_s) { super } if ValueObjectDefinition.match?(node.value)
97
+
98
+ add_subject(node, scoped_name(node.name), :constant_write)
99
+ super
100
+ end
101
+
102
+ def visit_constant_path_write_node(node)
103
+ name = path_name(node.target)
104
+ rooted = rooted?(node.target)
105
+ return within_definition(node.value, name, rooted: rooted) { super } if ValueObjectDefinition.match?(node.value)
106
+
107
+ add_subject(node, rooted ? name : scoped_name(name), :constant_write)
108
+ super
109
+ end
110
+
111
+ # `LIMIT += 1` assigns the constant again and is a subject like the
112
+ # assignment it follows, under the same name.
113
+ def visit_constant_operator_write_node(node)
114
+ add_subject(node, scoped_name(node.name), :constant_write)
115
+ super
116
+ end
117
+
118
+ def visit_constant_path_operator_write_node(node)
119
+ name = path_name(node.target)
120
+ add_subject(node, rooted?(node.target) ? name : scoped_name(name), :constant_write)
60
121
  super
61
122
  end
62
123
 
63
124
  private
64
125
 
65
- def add_subject(node, separator)
66
- scope = @context.join("::")
67
- name = if scope.empty?
68
- "#{separator}#{node.name}"
69
- else
70
- "#{scope}#{separator}#{node.name}"
71
- end
126
+ def within_scope(singleton:)
127
+ @singleton.push(singleton)
128
+ yield
129
+ ensure
130
+ @singleton.pop
131
+ end
132
+
133
+ # What the block finds inside one of these hook blocks of a concern is
134
+ # marked as written there.
135
+ def within_hooks(blocks)
136
+ enclosing = @hooks
137
+ @hooks = blocks.map { |block| block.location.start_offset...block.location.end_offset }
138
+ yield
139
+ ensure
140
+ @hooks = enclosing
141
+ end
72
142
 
73
- loc = node.location
74
- method_source = @source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
75
- .force_encoding(@source.encoding)
143
+ def in_hook?(location)
144
+ @hooks.any? { |range| range.cover?(location.start_offset) }
145
+ end
146
+
147
+ def scoped_name(name)
148
+ [*@context, name].join("::")
149
+ end
150
+
151
+ # `::LIMIT` and `::Shared::LIMIT` name a constant from the top level,
152
+ # whatever scope they are written in.
153
+ def rooted?(path)
154
+ path.slice.start_with?("::")
155
+ end
156
+
157
+ def constant_expression?(node)
158
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
159
+ end
160
+
161
+ # A value-object definition assigned to a constant is a subject of its
162
+ # own, and a scope for what its block defines: `def area` in
163
+ # `Size = Struct.new(:w, :h) do ... end` is `Size#area`.
164
+ def within_definition(definition, name, rooted: false)
165
+ within_context(name, rooted: rooted) do
166
+ add_subject(definition, @context.join("::"), :constant)
167
+ yield
168
+ end
169
+ end
170
+
171
+ # Names what the block finds under `name`: within the scope being
172
+ # visited, or from the top level when the name was written rooted.
173
+ def within_context(name, rooted:)
174
+ enclosing = @context
175
+ @context = rooted ? [name] : [*enclosing, name]
176
+ yield
177
+ ensure
178
+ @context = enclosing
179
+ end
180
+
181
+ # The callables handed to the declarations of a class body, or of a
182
+ # concern's `included` or `prepended` block.
183
+ def add_declaration_subjects(body)
184
+ add_scope_subjects(body)
185
+ add_aasm_subjects(body)
186
+ add_callback_subjects(body)
187
+ end
188
+
189
+ # `scope :recent, -> { ... }` defines the class method `recent`. The
190
+ # subject spans the whole declaration and mutates its body.
191
+ # Written in a concern's `included` or `prepended` block it defines that
192
+ # method on every class taking the concern, and is named after the concern.
193
+ def add_scope_subjects(body)
194
+ ScopeDeclaration.in_body(body).each do |declaration|
195
+ name = "#{@context.join("::")}.#{ScopeDeclaration.scope_name(declaration)}"
196
+ add_subject(ScopeDeclaration.body_of(declaration), name, :scope, span: declaration)
197
+ end
198
+ end
199
+
200
+ # A guard or callback written out inside `aasm do ... end` runs when the
201
+ # method its event or state defines is called (`ship`, `paid?`), and is
202
+ # named after it. The subject spans that declaration and mutates the
203
+ # callable's body; one event may hold several. A machine declared in a
204
+ # concern's `included` block is named after the concern.
205
+ def add_aasm_subjects(body)
206
+ AasmDeclaration.in_body(body).each do |callable|
207
+ name = "#{@context.join("::")}##{callable.method_name}"
208
+ add_subject(callable.body, name, :aasm, span: callable.declaration)
209
+ end
210
+ end
211
+
212
+ # A condition or a callback written out in a declaration such as
213
+ # `validate :credit_limit, if: -> { ... }` defines no method to be named
214
+ # after, so it is named the way the declaration reads:
215
+ # `Order.validate(:credit_limit)`. The subject spans the declaration and
216
+ # mutates the callable's body. In a concern's `included` block it is named
217
+ # after the concern.
218
+ def add_callback_subjects(body)
219
+ CallbackDeclaration.in_body(body).each do |callable|
220
+ add_subject(callable.body, "#{@context.join("::")}.#{callable.label}", :callback, span: callable.declaration)
221
+ end
222
+ end
223
+
224
+ def add_subject(node, name, kind, span: node)
225
+ loc = span.location
226
+ source = @source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
227
+ .force_encoding(@source.encoding)
76
228
 
77
229
  @subjects << Evilution::Subject.new(
78
230
  name: name,
79
231
  file_path: @file_path,
80
232
  line_number: loc.start_line,
81
- source: method_source,
82
- node: node
233
+ source: source,
234
+ node: node,
235
+ kind: kind,
236
+ concern_hook: in_hook?(loc)
83
237
  )
84
238
  end
85
239
 
240
+ # `self::Unit` has no static full name; it is named within the scope it
241
+ # is written in, like a plain `Unit`.
242
+ def path_name(target)
243
+ target.full_name.delete_prefix("::")
244
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
245
+ target.name.to_s
246
+ end
247
+
248
+ # `class self::Unit` and `class << self::Unit` have no static full name
249
+ # either; like `path_name`, they are named by their own constant.
86
250
  def constant_name(node)
87
251
  if node.respond_to?(:full_name)
88
- node.full_name
252
+ node.full_name.delete_prefix("::")
89
253
  else
90
254
  node.name.to_s
91
255
  end
256
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
257
+ node.name.to_s
92
258
  end
93
259
  end
94
260
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../pattern"
4
+
5
+ # Reads a method name the way an attribute value spells it: an identifier with
6
+ # an optional `?`, `!` or `=`, a bare operator, or anything else in quotes.
7
+ class Evilution::AST::Pattern::MethodName
8
+ # Longest first, so `<=>` is not read as `<=`. Operators starting with `!`,
9
+ # `*` or `|` already mean negation, a wildcard or an alternative in a
10
+ # pattern, and have to be quoted.
11
+ OPERATORS = %w[<=> === == =~ []= [] <= << < >= >> > +@ -@ + - / % & ^ ~].freeze
12
+ IDENTIFIER = /\G[a-zA-Z_][a-zA-Z0-9_]*[?!=]?/
13
+ QUOTES = %w[' "].freeze
14
+
15
+ # The name starting at +pos+ in +input+, and the position just after it.
16
+ def self.scan(input, pos)
17
+ new(input, pos).scan
18
+ end
19
+
20
+ def initialize(input, pos)
21
+ @input = input
22
+ @pos = pos
23
+ end
24
+
25
+ def scan
26
+ char = @input[@pos]
27
+ raise Evilution::ConfigError, "unexpected end of pattern at position #{@pos}" if char.nil?
28
+
29
+ name = QUOTES.include?(char) ? quoted(char) : operator || identifier(char)
30
+ [name, @pos]
31
+ end
32
+
33
+ private
34
+
35
+ def quoted(quote)
36
+ finish = @input.index(quote, @pos + 1)
37
+ raise Evilution::ConfigError, "unterminated quoted name starting at position #{@pos}" if finish.nil?
38
+
39
+ name = @input[(@pos + 1)...finish]
40
+ raise Evilution::ConfigError, "empty quoted name at position #{@pos}" if name.empty?
41
+
42
+ @pos = finish + 1
43
+ name
44
+ end
45
+
46
+ def operator
47
+ name = OPERATORS.find { |candidate| @input[@pos, candidate.length] == candidate }
48
+ @pos += name.length if name
49
+ name
50
+ end
51
+
52
+ def identifier(char)
53
+ match = IDENTIFIER.match(@input, @pos)
54
+ if match.nil?
55
+ raise Evilution::ConfigError,
56
+ "invalid name starting with '#{char}' at position #{@pos}; " \
57
+ "quote names that are not identifiers or operators, e.g. name='|'"
58
+ end
59
+
60
+ @pos = match.end(0)
61
+ match[0]
62
+ end
63
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../pattern"
4
4
  require_relative "matcher"
5
+ require_relative "method_name"
5
6
 
6
7
  class Evilution::AST::Pattern::Parser
7
8
  def initialize(input)
@@ -39,17 +40,18 @@ class Evilution::AST::Pattern::Parser
39
40
  node_type = consume_identifier
40
41
  skip_whitespace
41
42
 
42
- attributes = {}
43
- if current_char == "{"
44
- advance(1)
45
- attributes = parse_attributes
46
- skip_whitespace
47
- expect_char("}")
48
- end
49
-
43
+ attributes = current_char == "{" ? parse_braced_attributes : {}
50
44
  Evilution::AST::Pattern::NodeMatcher.new(node_type, attributes)
51
45
  end
52
46
 
47
+ def parse_braced_attributes
48
+ advance(1)
49
+ attributes = parse_attributes
50
+ skip_whitespace
51
+ expect_char("}")
52
+ attributes
53
+ end
54
+
53
55
  def parse_attributes
54
56
  attrs = {}
55
57
  skip_whitespace
@@ -109,15 +111,11 @@ class Evilution::AST::Pattern::Parser
109
111
  end
110
112
 
111
113
  def parse_value_or_nested
112
- id = consume_identifier
114
+ id, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
113
115
  skip_whitespace
114
116
 
115
117
  if current_char == "{"
116
- advance(1)
117
- attrs = parse_attributes
118
- skip_whitespace
119
- expect_char("}")
120
- Evilution::AST::Pattern::NodeMatcher.new(id, attrs)
118
+ Evilution::AST::Pattern::NodeMatcher.new(id, parse_braced_attributes)
121
119
  else
122
120
  parse_alternatives_from(id)
123
121
  end
@@ -133,7 +131,8 @@ class Evilution::AST::Pattern::Parser
133
131
  advance(1)
134
132
  values << "*"
135
133
  else
136
- values << consume_identifier
134
+ name, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
135
+ values << name
137
136
  end
138
137
  end
139
138