Module: Woods::Extractors::SharedDependencyScanner

Overview

Common dependency scanning patterns shared across extractors.

Most extractors scan source code for the same four dependency types: model references (via ModelNameCache), service objects, background jobs, and mailers. This module centralizes those scanning patterns.

Individual scan methods accept an optional :via parameter so extractors can customize the relationship label (e.g., :serialization instead of the default :code_reference).

Examples:

class FooExtractor
  include SharedDependencyScanner

  def extract_dependencies(source)
    deps = scan_common_dependencies(source)
    deps << { type: :custom, target: "Bar", via: :special }
    deps.uniq { |d| [d[:type], d[:target]] }
  end
end

Constant Summary collapse

ROUTE_HELPER_PATTERN =

Match _path/_url route helpers anywhere in source. This intentionally matches all usages (assignments, string interpolation, etc.) not just link_to/redirect_to calls — any reference to a route helper indicates a dependency on that controller. False positives from non-route _path/_url suffixes (file_path, base_url, etc.) are filtered by RouteHelperResolver::IGNORED_HELPER_PREFIXES. Requires the including class to also include RouteHelperResolver and call build_route_helper_map in its initializer.

/\b(\w+)_(path|url)\b/
FORM_ACTION_HELPER =

Match form_with/form_for with a named route helper as the action/url. Scans only within the form opening tag (up to the first ‘do`, `%>`, or `end`) to avoid matching unrelated _path/_url helpers that appear after the form.

/form_(with|for)\b[^%]*?(\w+)_(path|url)/

Instance Method Summary collapse

Instance Method Details

#extract_constantize_targets(source) ⇒ Array<String>

Extract string-literal arguments passed to ‘.constantize` or `const_get(…)`. Matches both `“Library::Book”.constantize` and `Object.const_get(“Library::Book”)` / `const_get(“…”)`. Only returns names actually present in ModelNameCache.model_names so non-model uses (e.g. `“String”.constantize` in infra code) do not produce ghost edges.

Parameters:

  • source (String)

Returns:

  • (Array<String>)


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 82

def extract_constantize_targets(source)
  return [] unless ModelNameCache.respond_to?(:model_names)

  known = ModelNameCache.model_names.to_set
  return [] if known.empty?

  targets = []
  source.scan(/(["'])([A-Z][\w:]*)\1\s*\.\s*constantize\b/) do |_quote, name|
    targets << name if known.include?(name)
  end
  source.scan(/const_get\s*\(\s*(["'])([A-Z][\w:]*)\1/) do |_quote, name|
    targets << name if known.include?(name)
  end
  targets
end

#scan_common_dependencies(source) ⇒ Array<Hash>

Scan for all common dependency types and return a deduplicated array.

Combines model, service, job, and mailer scans. Use this when an extractor needs all four standard dependency types with the default :code_reference via label.

Parameters:

  • source (String)

    Ruby source code to scan

Returns:

  • (Array<Hash>)

    Deduplicated dependency hashes



139
140
141
142
143
144
145
146
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 139

def scan_common_dependencies(source)
  deps = []
  deps.concat(scan_model_dependencies(source))
  deps.concat(scan_service_dependencies(source))
  deps.concat(scan_job_dependencies(source))
  deps.concat(scan_mailer_dependencies(source))
  deps.uniq { |d| [d[:type], d[:target]] }
end

#scan_form_dependencies(source) ⇒ Array<Hash>

Scan source for form_with/form_for calls targeting named route helpers.

Gated by Woods.configuration.extract_navigation_edges. Requires RouteHelperResolver to be included and initialized.

Parameters:

  • source (String)

    Template/Ruby source code

Returns:

  • (Array<Hash>)

    Dependency hashes with via: :form_action



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 200

def scan_form_dependencies(source)
  return [] unless Woods.configuration&.extract_navigation_edges

  seen = Set.new
  deps = []
  source.scan(FORM_ACTION_HELPER).each do |_, route_name, suffix|
    resolved = resolve_route_helper("#{route_name}_#{suffix}")
    next unless resolved

    target = resolved[:controller]
    next if seen.include?(target)

    seen.add(target)
    deps << { type: :controller, target: target, via: :form_action }
  end
  deps
end

#scan_job_dependencies(source, via: :code_reference) ⇒ Array<Hash>

Scan for background job references (e.g., FooJob.perform_later).

Parameters:

  • source (String)

    Ruby source code to scan

  • via (Symbol) (defaults to: :code_reference)

    Relationship label (default: :code_reference)

Returns:

  • (Array<Hash>)

    Dependency hashes



114
115
116
117
118
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 114

def scan_job_dependencies(source, via: :code_reference)
  source.scan(/(\w+Job)\.perform/).flatten.uniq.map do |job|
    { type: :job, target: job, via: via }
  end
end

#scan_mailer_dependencies(source, via: :code_reference) ⇒ Array<Hash>

Scan for mailer references (e.g., UserMailer.welcome_email).

Parameters:

  • source (String)

    Ruby source code to scan

  • via (Symbol) (defaults to: :code_reference)

    Relationship label (default: :code_reference)

Returns:

  • (Array<Hash>)

    Dependency hashes



125
126
127
128
129
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 125

def scan_mailer_dependencies(source, via: :code_reference)
  source.scan(/(\w+Mailer)\./).flatten.uniq.map do |mailer|
    { type: :mailer, target: mailer, via: via }
  end
end

#scan_model_dependencies(source, via: :code_reference) ⇒ Array<Hash>

Scan for ActiveRecord model references using the precomputed regex.

Three passes:

  1. Fully-qualified names via the main ‘b(?:Foo|Bar::Baz)b` regex.

  2. ‘.constantize` / `const_get(…)` string-literal arguments —a `“Library::Book”.constantize` used to return zero edges because the scan ran over raw source and the regex didn’t pick up the quoted constant. Now we extract the string argument and resolve it.

  3. Bare short names (e.g. ‘Book` inside `module Library`) resolved through ModelNameCache.resolve_short_name when unambiguous.

Parameters:

  • source (String)

    Ruby source code to scan

  • via (Symbol) (defaults to: :code_reference)

    Relationship label (default: :code_reference)

Returns:

  • (Array<Hash>)

    Dependency hashes with :type, :target, :via



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 47

def scan_model_dependencies(source, via: :code_reference)
  targets = Set.new
  source.scan(ModelNameCache.model_names_regex).each { |m| targets << m }
  extract_constantize_targets(source).each { |t| targets << t }

  # Short-name + constantize resolution are additive passes guarded
  # by `respond_to?` so partial test doubles that only stub
  # `model_names_regex` still work. Real extraction runs always
  # have the full API.
  if ModelNameCache.respond_to?(:short_names_regex) && ModelNameCache.respond_to?(:resolve_short_name)
    # Strip `#` line comments before scanning so references inside
    # YARD docstrings / TODO comments don't generate ghost edges.
    # The negative lookahead `(?!\{)` keeps Ruby's `#{...}` string
    # interpolation intact — stripping blindly would eat every model
    # reference inside `"Book: #{Library::Book.new}"` etc., which
    # is a common ERB/Phlex/string pattern.
    scannable = source.gsub(/#(?!\{)[^\n]*/, '')
    scannable.scan(ModelNameCache.short_names_regex).each do |short|
      resolved = ModelNameCache.resolve_short_name(short)
      targets << resolved if resolved
    end
  end

  targets.map { |model_name| { type: :model, target: model_name, via: via } }
end

#scan_navigation_dependencies(source, via_type: :link_to) ⇒ Array<Hash>

Scan source for named route helpers and resolve them to controller targets.

Gated by Woods.configuration.extract_navigation_edges. Requires RouteHelperResolver to be included and initialized.

Parameters:

  • source (String)

    Ruby/ERB/HAML source code to scan

  • via_type (Symbol) (defaults to: :link_to)

    Relationship label (default: :link_to)

Returns:

  • (Array<Hash>)

    Dependency hashes with :type, :target, :via



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 170

def scan_navigation_dependencies(source, via_type: :link_to)
  return [] unless Woods.configuration&.extract_navigation_edges

  seen_helpers = Set.new
  seen_targets = Set.new
  deps = []
  source.scan(ROUTE_HELPER_PATTERN).each do |route_name, suffix|
    helper = "#{route_name}_#{suffix}"
    next if seen_helpers.include?(helper)

    seen_helpers.add(helper)
    resolved = resolve_route_helper(helper)
    next unless resolved

    target = resolved[:controller]
    next if seen_targets.include?(target)

    seen_targets.add(target)
    deps << { type: :controller, target: target, via: via_type }
  end
  deps
end

#scan_service_dependencies(source, via: :code_reference) ⇒ Array<Hash>

Scan for service object references (e.g., FooService.call, FooService::new).

Parameters:

  • source (String)

    Ruby source code to scan

  • via (Symbol) (defaults to: :code_reference)

    Relationship label (default: :code_reference)

Returns:

  • (Array<Hash>)

    Dependency hashes



103
104
105
106
107
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 103

def scan_service_dependencies(source, via: :code_reference)
  source.scan(/(\w+Service)(?:\.|::)/).flatten.uniq.map do |service|
    { type: :service, target: service, via: via }
  end
end