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 }
    consolidate_dependencies(deps)
  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

#consolidate_dependencies(*dependency_arrays) ⇒ Array<Hash>

Merge dependency arrays and deduplicate by [type, target].

Centralizes the deps.uniq { |d| [d[:type], d[:target]] } chain duplicated at the end of most extractors' extract_dependencies methods. Arrays are flattened one level and nils removed; the first occurrence of each [type, target] pair wins, so the first :via label recorded is preserved — identical to the inline chains this replaces.

Parameters:

  • dependency_arrays (Array<Array<Hash>>)

    One or more dependency arrays

Returns:

  • (Array<Hash>)

    Flattened, nil-free, deduplicated dependency hashes



216
217
218
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 216

def consolidate_dependencies(*dependency_arrays)
  dependency_arrays.flatten(1).compact.uniq { |d| [d[:type], d[:target]] }
end

#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>)


139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 139

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



196
197
198
199
200
201
202
203
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 196

def scan_common_dependencies(source)
  consolidate_dependencies(
    scan_model_dependencies(source),
    scan_service_dependencies(source),
    scan_job_dependencies(source),
    scan_mailer_dependencies(source)
  )
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



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 272

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



171
172
173
174
175
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 171

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



182
183
184
185
186
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 182

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
72
73
74
75
76
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 47

def scan_model_dependencies(source, via: :code_reference)
  # Strip `#` line comments before scanning so references inside
  # YARD docstrings / TODO comments don't generate ghost edges.
  # Applied to ALL passes — a commented `Library::Book` should not
  # produce an edge through the full-name pass. Stripping is
  # string-literal-aware: a `#` inside a `"..."`/`'...'` literal is
  # NOT a comment, so a line like `link_to "Tag #ruby", Article.recent`
  # keeps its `Article` reference (a plain `#...` regex would have
  # eaten the rest of the line and dropped the edge). String
  # interpolation (`"Book: #{Library::Book.new}"`) is preserved for the
  # same reason — the `#{...}` lives inside the literal.
  scannable = strip_ruby_line_comments(source)

  targets = Set.new
  scannable.scan(ModelNameCache.model_names_regex).each { |m| targets << m }
  extract_constantize_targets(scannable).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)
    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



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 242

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



160
161
162
163
164
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 160

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

#strip_line_comment(line) ⇒ String

Strip a trailing # comment from a single line, ignoring # inside string literals. Preserves the line's trailing newline.

Parameters:

  • line (String)

Returns:

  • (String)


103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 103

def strip_line_comment(line)
  in_single = false
  in_double = false
  i = 0
  len = line.length
  while i < len
    ch = line[i]
    if (in_single || in_double) && ch == '\\'
      i += 2 # skip escaped char inside a literal
      next
    elsif in_single
      in_single = false if ch == "'"
    elsif in_double
      in_double = false if ch == '"'
    elsif ch == "'"
      in_single = true
    elsif ch == '"'
      in_double = true
    elsif ch == '#'
      trailing = line[i..].end_with?("\n") ? "\n" : ''
      return line[0...i] + trailing
    end
    i += 1
  end
  line
end

#strip_ruby_line_comments(source) ⇒ String

Remove # line comments from Ruby source without touching # characters that sit inside single- or double-quoted string literals.

A naive gsub(/#.*/, '') truncates lines like redirect "/posts#comments"; Post.touch at the in-string #, silently dropping the Post reference. This scanner walks each line tracking quote state so only a genuine (unquoted) # starts a comment. Escapes (\", \') inside literals are honored. Heredocs, %-literals, and character literals whose char is a quote (?', ?") are not modeled — these are rare in the constant-bearing code this scans, and mis-reading one only risks a spurious edge (a comment left unstripped) or a missed edge, never a crash or a dropped-but-real reference outside those constructs.

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (String)

    source with unquoted # comments removed



94
95
96
# File 'lib/woods/extractors/shared_dependency_scanner.rb', line 94

def strip_ruby_line_comments(source)
  source.each_line.map { |line| strip_line_comment(line) }.join
end