Module: Moult::DeadCode

Defined in:
lib/moult/dead_code.rb

Overview

Orchestrates the dead-code analysis: it asks the Index for every definition, keeps the ones with no production reference, gathers the facts each finding is judged on, and runs them through the pure Confidence model. The result is a ranked DeadCodeReport of confidence-graded candidates — never assertions of certain death.

This is the only layer that knows how the facts are sourced (the index, the Rails conventions, a metaprogramming scan of the owning file); Confidence stays a pure function of those facts so it can be tested in isolation.

Constant Summary collapse

TEST_PATH =
%r{(\A|/)(test|spec)/}
DYNAMIC_TOKENS =

Tokens that indicate dynamic dispatch / metaprogramming in a file. Their mere presence lowers confidence for definitions in that file: such code can be reached in ways static analysis cannot see. Matched conservatively (a false match only lowers confidence, never hides a finding).

/
  \b(
    send | public_send | __send__ |
    method_missing | respond_to_missing\? |
    define_method | define_singleton_method |
    class_eval | module_eval | instance_eval | instance_exec |
    const_get | const_set | constantize |
    eval
  )\b
/x

Class Method Summary collapse

Class Method Details

.build_report(root:, files:, index:, rails:, min_confidence: 0.0, git_ref: nil, generated_at: nil, backend_version: nil, coverage: nil) ⇒ DeadCodeReport

Parameters:

  • root (String)

    absolute analysis root

  • files (Array<String>)

    absolute Ruby file paths analysed

  • index (Index)

    resolved definition/reference index

  • rails (RailsConventions)

    Rails entrypoint awareness

  • min_confidence (Float) (defaults to: 0.0)

    drop findings below this confidence

  • coverage (Coverage::Dataset, nil) (defaults to: nil)

    runtime coverage to merge (Phase 3)

Returns:



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/moult/dead_code.rb', line 40

def build_report(root:, files:, index:, rails:, min_confidence: 0.0,
  git_ref: nil, generated_at: nil, backend_version: nil, coverage: nil)
  dynamic_files = dynamic_dispatch_files(files, root)
  constant_live = constant_liveness(index)

  findings = index.definitions.filter_map do |definition|
    next unless candidate?(definition)
    Confidence.score(context_for(definition, index: index, rails: rails, dynamic_files: dynamic_files,
      coverage: coverage, constant_live: constant_live))
  end

  findings.select! { |f| f.confidence >= min_confidence }
  findings.sort_by! { |f| [-f.confidence, f.name.to_s] }

  DeadCodeReport.new(
    root: root,
    findings: findings,
    git_ref: git_ref,
    generated_at: generated_at,
    backend: "rubydex",
    backend_version: backend_version,
    resolved: index.resolved?,
    rails: rails.rails?,
    diagnostics: index.diagnostics,
    coverage_source: coverage&.source
  )
end

.candidate?(definition) ⇒ Boolean

A definition is a candidate when nothing outside of tests references it.

Returns:

  • (Boolean)


69
70
71
# File 'lib/moult/dead_code.rb', line 69

def candidate?(definition)
  non_test_reference_paths(definition).empty?
end

.constant_liveness(index) ⇒ Object

FQ constant name => whether it has a production (non-test) reference. The liveness join for override_live: an override's ancestor method shares the override's name-keyed references, so the honest signal is the ancestor type's constant references. Reopened constants OR together; a name absent from this map (Object, gems) stays nil — unknown.



109
110
111
112
113
114
# File 'lib/moult/dead_code.rb', line 109

def constant_liveness(index)
  index.definitions.each_with_object({}) do |d, acc|
    next unless d.kind == :constant
    acc[d.name] = acc[d.name] || non_test_reference_paths(d).any?
  end
end

.context_for(definition, index:, rails:, dynamic_files:, coverage: nil, constant_live: {}) ⇒ Object



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/moult/dead_code.rb', line 73

def context_for(definition, index:, rails:, dynamic_files:, coverage: nil, constant_live: {})
  Confidence::Context.new(
    symbol_id: definition.symbol_id,
    kind: definition.kind,
    name: definition.name,
    span: definition.span,
    path: definition.path,
    visibility: definition.visibility,
    reference_count: definition.reference_count,
    test_only: test_only?(definition),
    rails_signals: rails.signals_for(definition),
    dynamic_dispatch: dynamic_files.include?(definition.path),
    override_of: definition.override_of,
    deprecated: false,
    index_resolved: index.resolved?,
    runtime: runtime_for(definition, coverage),
    override_live: definition.override_of && constant_live[definition.override_of],
    hierarchy_referenced: hierarchy_referenced?(definition)
  )
end

.dynamic_dispatch_files(files, root) ⇒ Set<String>

Returns root-relative paths whose source contains dynamic dispatch.

Returns:

  • (Set<String>)

    root-relative paths whose source contains dynamic dispatch



136
137
138
139
140
141
142
143
# File 'lib/moult/dead_code.rb', line 136

def dynamic_dispatch_files(files, root)
  files.each_with_object(Set.new) do |abs, set|
    source = File.read(abs)
    set << SymbolId.relative_path(abs, root) if source.match?(DYNAMIC_TOKENS)
  rescue
    next
  end
end

.hierarchy_referenced?(definition) ⇒ Boolean

Whether the owner type or any descendant is referenced outside tests. nil (unknown) propagates from the index; test filtering stays here, matching the reference_paths convention — the Index never learns what a test path is.

Returns:

  • (Boolean)


98
99
100
101
102
# File 'lib/moult/dead_code.rb', line 98

def hierarchy_referenced?(definition)
  paths = definition.owner_hierarchy_reference_paths
  return nil if paths.nil?
  Array(paths).reject { |path| path.to_s.match?(TEST_PATH) }.any?
end

.non_test_reference_paths(definition) ⇒ Object



131
132
133
# File 'lib/moult/dead_code.rb', line 131

def non_test_reference_paths(definition)
  Array(definition.reference_paths).reject { |path| path.to_s.match?(TEST_PATH) }
end

.runtime_for(definition, coverage) ⇒ Object

The runtime classification for this definition, joined on the same path + span that make up its symbol_id. nil when no coverage was supplied.



118
119
120
121
122
123
# File 'lib/moult/dead_code.rb', line 118

def runtime_for(definition, coverage)
  return nil unless coverage
  Coverage::Resolver.classify(
    coverage, path: definition.path, span: definition.span, kind: definition.kind
  )
end

.test_only?(definition) ⇒ Boolean

Referenced only from test/spec files: it is exercised, but possibly only to keep otherwise-dead production code alive — a weaker candidate, not excluded.

Returns:

  • (Boolean)


127
128
129
# File 'lib/moult/dead_code.rb', line 127

def test_only?(definition)
  definition.reference_count.to_i.positive? && non_test_reference_paths(definition).empty?
end