Module: MilkTea::Linter::LinterFlowRules

Included in:
MilkTea::Linter
Defined in:
lib/milk_tea/tooling/linter/flow_rules.rb

Instance Method Summary collapse

Instance Method Details

#cfg_binding_resolutionObject



86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 86

def cfg_binding_resolution
  return @cfg_binding_resolution if @cfg_binding_resolution_computed

  binding_resolution = @sema_facts&.binding_resolution
  @cfg_binding_resolution = if binding_resolution
                              ControlFlow::BindingResolution.new(
                                identifier_binding_ids: binding_resolution.identifier_binding_ids,
                                declaration_binding_ids: binding_resolution.declaration_binding_ids,
                                mutating_argument_identifier_ids: binding_resolution.mutating_argument_identifier_ids,
                              )
                            end
  @cfg_binding_resolution_computed = true
  @cfg_binding_resolution
end

#cfg_identifier_binding_key(identifier) ⇒ Object



145
146
147
148
149
150
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 145

def cfg_identifier_binding_key(identifier)
  binding_resolution = cfg_binding_resolution
  return identifier.name unless binding_resolution

  binding_resolution.identifier_binding_ids[identifier.object_id] || identifier.name
end

#dead_assignment_analysis(stmts) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 117

def dead_assignment_analysis(stmts)
  return nil if stmts.nil? || stmts.empty?

  @dead_assignment_analysis_cache[stmts.object_id] ||= begin
    binding_resolution = cfg_binding_resolution
    graph = profile_phase("dead_assignment.graph") do
      ControlFlow::Builder.new(
        ignore_name: method(:ignored_binding_name?),
        binding_resolution:,
        local_decl_without_initializer_writes: true,
      ).build(stmts)
    end
    liveness = profile_phase("dead_assignment.liveness") { ControlFlow::Liveness.solve(graph) }
    locally_declared = profile_phase("dead_assignment.locals") do
      graph.each_node.each_with_object(Set.new) do |node, bindings|
        node.writes_info.each do |write|
          bindings << write[:binding_key] if write[:origin] == :declaration
        end
      end
    end
    DeadAssignmentAnalysis.new(
      graph:,
      liveness:,
      readable_bindings: graph.read_bindings,
      locally_declared:,
    )
  end
end

#emit_borrow_warnings(stmts) ⇒ Object

Detects obvious aliasing hazards: a mutable reference (ref_of / ptr_of) is taken from a local variable that is also written later in the same body.



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 64

def emit_borrow_warnings(stmts)
  return if stmts.nil? || stmts.empty?

  borrowed = collect_borrowed_names(stmts)
  return if borrowed.empty?

  written = collect_written_names(stmts)
  (borrowed & written).each do |name|
    # Find the earliest borrow site for the warning location
    borrow_line, borrow_column, borrow_length = find_borrow_location(stmts, name)
    @warnings << Warning.new(
      path: @path,
      line: borrow_line,
      column: borrow_column,
      length: borrow_length,
      code: "borrow-and-mutate",
      message: "'#{name}' is borrowed via ref_of/ptr_of and also mutated in the same scope — potential aliasing hazard",
      severity: :warning,
      symbol_name: name,
    )
  end
end

#emit_constant_condition_warnings(stmts) ⇒ Object

── constant-condition ───────────────────────────────────────────────── Uses ConstantPropagation to detect conditions that are always true/false. Skips while true — it is an idiomatic infinite loop. Skips if conditions inside loops, since variables can change across iterations.



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 161

def emit_constant_condition_warnings(stmts)
  return if stmts.nil? || stmts.empty?

  analysis = statement_flow_analysis(stmts)
  return unless analysis

  binding_resolution = cfg_binding_resolution
  graph = analysis.graph
  cp = analysis.constant_propagation
  loop_bodies = analysis.loop_body_nodes

  graph.each_node do |node|
    cond_expr, line, keyword_pattern, skip_node =
      case node.kind
      when :if_condition
        branch = node.statement
        # Skip if conditions inside loops; variables can change across iterations.
        skip = loop_bodies.include?(node.id)
        [branch&.condition, branch&.line || node.line, "else if|if", skip]
      when :while_condition
        wstmt = node.statement
        # `while true` is an idiomatic infinite loop — do not warn
        skip = wstmt&.condition.is_a?(AST::BooleanLiteral) && wstmt.condition.value == true
        condition = wstmt&.condition
        [condition, node.line, "while", skip]
      else
        next
      end

    next if skip_node || cond_expr.nil?

    in_state  = cp.in_states[node.id] || {}
    const_val = ControlFlow::ConstantPropagation.constant_value_of(
      cond_expr,
      in_state,
      binding_resolution:,
      strict_binding_ids: !binding_resolution.nil?
    )
    next unless const_val == true || const_val == false

    ctx = node.kind == :while_condition ? "loop condition" : "branch condition"
    line, column, length = condition_span(cond_expr, line:, keyword_pattern:)
    @warnings << Warning.new(
      path: @path,
      line:,
      column:,
      length:,
      code: "constant-condition",
      message: "#{ctx} is always #{const_val}",
      severity: :warning,
      symbol_name: condition_symbol_name(cond_expr)
    )
  end
end

#emit_dead_assignment_warnings(stmts) ⇒ Object



6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 6

def emit_dead_assignment_warnings(stmts)
  analysis = dead_assignment_analysis(stmts)
  return unless analysis

  graph = analysis.graph
  liveness = analysis.liveness
  readable_bindings = analysis.readable_bindings
  locally_declared = analysis.locally_declared

  graph.each_node do |node|
    node.writes_info.each do |write|
      next if write[:origin] == :call_argument
        next if write[:origin] == :declaration

      binding_key = write[:binding_key]
      name = write[:name]
      next unless readable_bindings.include?(binding_key)
      next unless locally_declared.include?(binding_key)
      next if liveness.live_out[node.id].include?(binding_key)

      @warnings << Warning.new(
        path: @path,
        line: write[:line],
        column: write[:column],
        length: name.length,
        code: "dead-assignment",
        message: "value assigned to '#{name}' is never read",
        symbol_name: name
      )
    end
  end
end

#emit_loop_single_iteration_warnings(stmts) ⇒ Object

── loop-single-iteration ────────────────────────────────────────────── A loop whose body unconditionally exits (return/break) before the back-edge is taken will execute at most once.



324
325
326
327
328
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 324

def emit_loop_single_iteration_warnings(stmts)
  return if stmts.nil? || stmts.empty?

  walk_stmts_for_loop_check(stmts)
end

#emit_redundant_null_check_warnings(stmts) ⇒ Object

── redundant-null-check ─────────────────────────────────────────────── After a variable has been narrowed to non-null by a prior check, a subsequent x != nil guard is always true.



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 272

def emit_redundant_null_check_warnings(stmts)
  return if stmts.nil? || stmts.empty?

  analysis = statement_flow_analysis(stmts)
  return unless analysis

  graph = analysis.graph
  nf = analysis.nullability

  graph.each_node do |node|
    next unless node.kind == :if_condition

    branch = node.statement
    next unless branch.is_a?(AST::IfBranch)

    identifier = null_check_identifier(branch.condition)
    next unless identifier
    next if ignored_binding_name?(identifier.name)

    nonnull = nf.nonnull_before(branch)
    next unless nonnull.include?(cfg_identifier_binding_key(identifier))

    line, column, length = condition_span(branch.condition, line: node.line, keyword_pattern: "else if|if")

    @warnings << Warning.new(
      path: @path,
      line:,
      column:,
      length:,
      code: "redundant-null-check",
      message: "'#{identifier.name}' is already known to be non-null here — this nil check is redundant",
      severity: :hint,
      symbol_name: identifier.name
    )
  end
end

#emit_unreachable_warnings(stmts) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 38

def emit_unreachable_warnings(stmts)
  analysis = statement_flow_analysis(stmts)
  return unless analysis

  graph = analysis.graph
  reachable = analysis.reachability

  graph.each_node do |node|
    next if reachable.reachable_ids.include?(node.id)
    next if node.kind == :exit
    next unless node.statement

    statement = node.statement
    line = statement.line
    @warnings << Warning.new(
      path: @path,
      line:,
      column: statement_column(statement),
      length: statement_length(statement),
      code: "unreachable-code",
      message: "unreachable code"
    )
  end
end

#ignored_binding_name?(name) ⇒ Boolean

Returns:

  • (Boolean)


151
152
153
154
155
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 151

def ignored_binding_name?(name)
  return true unless name

  name == "_" || name.start_with?("_")
end

#null_check_identifier(cond) ⇒ Object

Returns the Identifier being nil-tested if cond is x != nil or nil != x, otherwise nil.



311
312
313
314
315
316
317
318
319
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 311

def null_check_identifier(cond)
  return nil unless cond.is_a?(AST::BinaryOp) && cond.operator == "!="

  if cond.left.is_a?(AST::Identifier) && cond.right.is_a?(AST::NullLiteral)
    cond.left
  elsif cond.left.is_a?(AST::NullLiteral) && cond.right.is_a?(AST::Identifier)
    cond.right
  end
end

#statement_flow_analysis(stmts) ⇒ Object



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 100

def statement_flow_analysis(stmts)
  return nil if stmts.nil? || stmts.empty?

  @statement_flow_analysis_cache[stmts.object_id] ||= begin
    binding_resolution = cfg_binding_resolution
    graph = profile_phase("flow.graph") do
      ControlFlow::Builder.new(ignore_name: method(:ignored_binding_name?), binding_resolution:).build(stmts)
    end
    reachability = profile_phase("flow.reachability") { ControlFlow::Reachability.solve(graph) }
    nullability = profile_phase("flow.nullability") { ControlFlow::NullabilityFlow.solve(graph) }
    constant_propagation = profile_phase("flow.constant_propagation") do
      ControlFlow::ConstantPropagation.solve(graph, binding_resolution:, strict_binding_ids: !binding_resolution.nil?)
    end
    loop_body_nodes = profile_phase("flow.loop_body_nodes") { compute_loop_body_nodes(graph) }
    StatementFlowAnalysis.new(graph:, reachability:, nullability:, constant_propagation:, loop_body_nodes:)
  end
end

#walk_stmts_for_loop_check(stmts) ⇒ Object



330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'lib/milk_tea/tooling/linter/flow_rules.rb', line 330

def walk_stmts_for_loop_check(stmts)
  stmts.each do |stmt|
    case stmt
    when AST::WhileStmt
      body = stmt.body || []
      if !body.empty? && ControlFlow::Termination.loop_body_always_exits?(body)
        @warnings << Warning.new(
          path: @path,
          line: stmt.line,
          column: stmt.column,
          length: stmt.length || "while".length,
          code: "loop-single-iteration",
          message: "loop body always exits on the first iteration - consider replacing with an 'if' block",
          severity: :warning
        )
      end
      walk_stmts_for_loop_check(body)
    when AST::ForStmt
      body = stmt.body || []
      if !body.empty? && ControlFlow::Termination.loop_body_always_exits?(body)
        @warnings << Warning.new(
          path: @path,
          line: stmt.line,
          column: stmt.column,
          length: (stmt.respond_to?(:length) ? stmt.length : nil) || "for".length,
          code: "loop-single-iteration",
          message: "loop body always exits on the first iteration - consider iterating directly without a loop",
          severity: :warning
        )
      end
      walk_stmts_for_loop_check(body)
    when AST::IfStmt
      stmt.branches.each { |b| walk_stmts_for_loop_check(b.body) }
      walk_stmts_for_loop_check(stmt.else_body) if stmt.else_body
    when AST::MatchStmt
      stmt.arms.each { |arm| walk_stmts_for_loop_check(arm.body) }
    when AST::UnsafeStmt
      walk_stmts_for_loop_check(stmt.body) if stmt.body
    when AST::DeferStmt
      walk_stmts_for_loop_check(stmt.body) if stmt.body
    when AST::WhenStmt
      stmt.branches.each { |b| walk_stmts_for_loop_check(b.body) }
      walk_stmts_for_loop_check(stmt.else_body) if stmt.else_body
    end
  end
end