Module: Woods::Extractors::SharedDependencyScanner
- Included in:
- ActionCableExtractor, CachingExtractor, ConcernExtractor, ConfigurationExtractor, ControllerExtractor, DatabaseViewExtractor, DecoratorExtractor, EventExtractor, FactoryExtractor, GraphQLExtractor, JobExtractor, LibExtractor, MailerExtractor, ManagerExtractor, MigrationExtractor, ModelExtractor, PhlexExtractor, PolicyExtractor, PoroExtractor, PunditExtractor, RakeTaskExtractor, SerializerExtractor, ServiceExtractor, StateMachineExtractor, TestMappingExtractor, ValidatorExtractor, ViewComponentExtractor
- Defined in:
- lib/woods/extractors/shared_dependency_scanner.rb
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).
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,%>, orend) to avoid matching unrelated _path/_url helpers that appear after the form. /form_(with|for)\b[^%]*?(\w+)_(path|url)/
Instance Method Summary collapse
-
#consolidate_dependencies(*dependency_arrays) ⇒ Array<Hash>
Merge dependency arrays and deduplicate by
[type, target]. -
#extract_constantize_targets(source) ⇒ Array<String>
Extract string-literal arguments passed to
.constantizeorconst_get(...). -
#scan_common_dependencies(source) ⇒ Array<Hash>
Scan for all common dependency types and return a deduplicated array.
-
#scan_form_dependencies(source) ⇒ Array<Hash>
Scan source for form_with/form_for calls targeting named route helpers.
-
#scan_job_dependencies(source, via: :code_reference) ⇒ Array<Hash>
Scan for background job references (e.g., FooJob.perform_later).
-
#scan_mailer_dependencies(source, via: :code_reference) ⇒ Array<Hash>
Scan for mailer references (e.g., UserMailer.welcome_email).
-
#scan_model_dependencies(source, via: :code_reference) ⇒ Array<Hash>
Scan for ActiveRecord model references using the precomputed regex.
-
#scan_navigation_dependencies(source, via_type: :link_to) ⇒ Array<Hash>
Scan source for named route helpers and resolve them to controller targets.
-
#scan_service_dependencies(source, via: :code_reference) ⇒ Array<Hash>
Scan for service object references (e.g., FooService.call, FooService::new).
-
#strip_line_comment(line) ⇒ String
Strip a trailing
#comment from a single line, ignoring#inside string literals. -
#strip_ruby_line_comments(source) ⇒ String
Remove
#line comments from Ruby source without touching#characters that sit inside single- or double-quoted string literals.
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.
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.
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.
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.
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&. 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).
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).
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:
- Fully-qualified names via the main
\b(?:Foo|Bar::Baz)\bregex. .constantize/const_get(...)string-literal arguments — a"Library::Book".constantizeused 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.- Bare short names (e.g.
Bookinsidemodule Library) resolved through ModelNameCache.resolve_short_name when unambiguous.
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.
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 (source, via_type: :link_to) return [] unless Woods.configuration&. 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).
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.
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.
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 |