woods 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +186 -0
- data/README.md +20 -8
- data/exe/woods-console +51 -6
- data/exe/woods-console-mcp +24 -4
- data/exe/woods-mcp +30 -7
- data/exe/woods-mcp-http +47 -6
- data/lib/generators/woods/install_generator.rb +13 -4
- data/lib/generators/woods/templates/woods.rb.tt +155 -0
- data/lib/tasks/woods.rake +69 -50
- data/lib/woods/builder.rb +174 -9
- data/lib/woods/cache/cache_middleware.rb +360 -31
- data/lib/woods/chunking/semantic_chunker.rb +334 -7
- data/lib/woods/console/adapters/job_adapter.rb +10 -4
- data/lib/woods/console/audit_logger.rb +76 -4
- data/lib/woods/console/bridge.rb +48 -15
- data/lib/woods/console/bridge_protocol.rb +44 -0
- data/lib/woods/console/confirmation.rb +3 -4
- data/lib/woods/console/console_response_renderer.rb +56 -18
- data/lib/woods/console/credential_index.rb +201 -0
- data/lib/woods/console/credential_scanner.rb +302 -0
- data/lib/woods/console/dispatch_pipeline.rb +138 -0
- data/lib/woods/console/embedded_executor.rb +682 -35
- data/lib/woods/console/eval_guard.rb +319 -0
- data/lib/woods/console/model_validator.rb +1 -3
- data/lib/woods/console/rack_middleware.rb +185 -29
- data/lib/woods/console/redactor.rb +161 -0
- data/lib/woods/console/response_context.rb +127 -0
- data/lib/woods/console/safe_context.rb +220 -23
- data/lib/woods/console/scope_predicate_parser.rb +131 -0
- data/lib/woods/console/server.rb +417 -486
- data/lib/woods/console/sql_noise_stripper.rb +87 -0
- data/lib/woods/console/sql_table_scanner.rb +213 -0
- data/lib/woods/console/sql_validator.rb +81 -31
- data/lib/woods/console/table_gate.rb +93 -0
- data/lib/woods/console/tool_specs.rb +552 -0
- data/lib/woods/console/tools/tier1.rb +3 -3
- data/lib/woods/console/tools/tier4.rb +7 -1
- data/lib/woods/dependency_graph.rb +66 -7
- data/lib/woods/embedding/indexer.rb +190 -6
- data/lib/woods/embedding/openai.rb +40 -4
- data/lib/woods/embedding/provider.rb +104 -8
- data/lib/woods/embedding/text_preparer.rb +23 -3
- data/lib/woods/embedding/token_counter.rb +133 -0
- data/lib/woods/evaluation/baseline_runner.rb +20 -2
- data/lib/woods/evaluation/metrics.rb +4 -1
- data/lib/woods/extracted_unit.rb +1 -0
- data/lib/woods/extractor.rb +7 -1
- data/lib/woods/extractors/controller_extractor.rb +6 -0
- data/lib/woods/extractors/mailer_extractor.rb +16 -2
- data/lib/woods/extractors/model_extractor.rb +6 -1
- data/lib/woods/extractors/phlex_extractor.rb +13 -4
- data/lib/woods/extractors/rails_source_extractor.rb +2 -0
- data/lib/woods/extractors/route_helper_resolver.rb +130 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +130 -2
- data/lib/woods/extractors/view_component_extractor.rb +12 -1
- data/lib/woods/extractors/view_engines/base.rb +141 -0
- data/lib/woods/extractors/view_engines/erb.rb +145 -0
- data/lib/woods/extractors/view_template_extractor.rb +92 -133
- data/lib/woods/flow_assembler.rb +23 -15
- data/lib/woods/flow_precomputer.rb +21 -2
- data/lib/woods/graph_analyzer.rb +210 -0
- data/lib/woods/index_artifact.rb +173 -0
- data/lib/woods/mcp/bearer_auth.rb +45 -0
- data/lib/woods/mcp/bootstrap_state.rb +94 -0
- data/lib/woods/mcp/bootstrapper.rb +337 -16
- data/lib/woods/mcp/config_resolver.rb +288 -0
- data/lib/woods/mcp/errors.rb +134 -0
- data/lib/woods/mcp/index_reader.rb +265 -30
- data/lib/woods/mcp/origin_guard.rb +132 -0
- data/lib/woods/mcp/provider_probe.rb +166 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +6 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +100 -3
- data/lib/woods/mcp/renderers/plain_renderer.rb +16 -2
- data/lib/woods/mcp/server.rb +771 -137
- data/lib/woods/model_name_cache.rb +78 -2
- data/lib/woods/notion/client.rb +25 -2
- data/lib/woods/notion/mappers/model_mapper.rb +36 -2
- data/lib/woods/railtie.rb +55 -15
- data/lib/woods/resilience/circuit_breaker.rb +9 -2
- data/lib/woods/resilience/retryable_provider.rb +40 -3
- data/lib/woods/resolved_config.rb +299 -0
- data/lib/woods/retrieval/context_assembler.rb +112 -5
- data/lib/woods/retrieval/query_classifier.rb +1 -1
- data/lib/woods/retrieval/ranker.rb +55 -6
- data/lib/woods/retrieval/search_executor.rb +42 -13
- data/lib/woods/retriever.rb +330 -24
- data/lib/woods/session_tracer/middleware.rb +35 -1
- data/lib/woods/storage/graph_store.rb +39 -0
- data/lib/woods/storage/inapplicable_backend.rb +14 -0
- data/lib/woods/storage/metadata_store.rb +129 -1
- data/lib/woods/storage/pgvector.rb +70 -8
- data/lib/woods/storage/qdrant.rb +196 -5
- data/lib/woods/storage/snapshotter/metadata.rb +172 -0
- data/lib/woods/storage/snapshotter/vector.rb +238 -0
- data/lib/woods/storage/snapshotter.rb +24 -0
- data/lib/woods/storage/vector_store.rb +184 -35
- data/lib/woods/tasks.rb +85 -0
- data/lib/woods/temporal/snapshot_store.rb +49 -1
- data/lib/woods/token_utils.rb +44 -5
- data/lib/woods/unblocked/client.rb +163 -0
- data/lib/woods/unblocked/document_builder.rb +326 -0
- data/lib/woods/unblocked/exporter.rb +201 -0
- data/lib/woods/unblocked/rate_limiter.rb +94 -0
- data/lib/woods/util/host_guard.rb +61 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +130 -6
- metadata +73 -4
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
3
4
|
require_relative '../model_name_cache'
|
|
5
|
+
require_relative 'route_helper_resolver'
|
|
4
6
|
|
|
5
7
|
module Woods
|
|
6
8
|
module Extractors
|
|
@@ -28,13 +30,69 @@ module Woods
|
|
|
28
30
|
module SharedDependencyScanner
|
|
29
31
|
# Scan for ActiveRecord model references using the precomputed regex.
|
|
30
32
|
#
|
|
33
|
+
# Three passes:
|
|
34
|
+
# 1. Fully-qualified names via the main `\b(?:Foo|Bar::Baz)\b` regex.
|
|
35
|
+
# 2. `.constantize` / `const_get(...)` string-literal arguments —
|
|
36
|
+
# a `"Library::Book".constantize` used to return zero edges
|
|
37
|
+
# because the scan ran over raw source and the regex didn't pick
|
|
38
|
+
# up the quoted constant. Now we extract the string argument and
|
|
39
|
+
# resolve it.
|
|
40
|
+
# 3. Bare short names (e.g. `Book` inside `module Library`)
|
|
41
|
+
# resolved through {ModelNameCache.resolve_short_name} when
|
|
42
|
+
# unambiguous.
|
|
43
|
+
#
|
|
31
44
|
# @param source [String] Ruby source code to scan
|
|
32
45
|
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
33
46
|
# @return [Array<Hash>] Dependency hashes with :type, :target, :via
|
|
34
47
|
def scan_model_dependencies(source, via: :code_reference)
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
targets = Set.new
|
|
49
|
+
source.scan(ModelNameCache.model_names_regex).each { |m| targets << m }
|
|
50
|
+
extract_constantize_targets(source).each { |t| targets << t }
|
|
51
|
+
|
|
52
|
+
# Short-name + constantize resolution are additive passes guarded
|
|
53
|
+
# by `respond_to?` so partial test doubles that only stub
|
|
54
|
+
# `model_names_regex` still work. Real extraction runs always
|
|
55
|
+
# have the full API.
|
|
56
|
+
if ModelNameCache.respond_to?(:short_names_regex) && ModelNameCache.respond_to?(:resolve_short_name)
|
|
57
|
+
# Strip `#` line comments before scanning so references inside
|
|
58
|
+
# YARD docstrings / TODO comments don't generate ghost edges.
|
|
59
|
+
# The negative lookahead `(?!\{)` keeps Ruby's `#{...}` string
|
|
60
|
+
# interpolation intact — stripping blindly would eat every model
|
|
61
|
+
# reference inside `"Book: #{Library::Book.new}"` etc., which
|
|
62
|
+
# is a common ERB/Phlex/string pattern.
|
|
63
|
+
scannable = source.gsub(/#(?!\{)[^\n]*/, '')
|
|
64
|
+
scannable.scan(ModelNameCache.short_names_regex).each do |short|
|
|
65
|
+
resolved = ModelNameCache.resolve_short_name(short)
|
|
66
|
+
targets << resolved if resolved
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
targets.map { |model_name| { type: :model, target: model_name, via: via } }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Extract string-literal arguments passed to `.constantize` or
|
|
74
|
+
# `const_get(...)`. Matches both `"Library::Book".constantize`
|
|
75
|
+
# and `Object.const_get("Library::Book")` / `const_get("...")`.
|
|
76
|
+
# Only returns names actually present in {ModelNameCache.model_names}
|
|
77
|
+
# so non-model uses (e.g. `"String".constantize` in infra code) do
|
|
78
|
+
# not produce ghost edges.
|
|
79
|
+
#
|
|
80
|
+
# @param source [String]
|
|
81
|
+
# @return [Array<String>]
|
|
82
|
+
def extract_constantize_targets(source)
|
|
83
|
+
return [] unless ModelNameCache.respond_to?(:model_names)
|
|
84
|
+
|
|
85
|
+
known = ModelNameCache.model_names.to_set
|
|
86
|
+
return [] if known.empty?
|
|
87
|
+
|
|
88
|
+
targets = []
|
|
89
|
+
source.scan(/(["'])([A-Z][\w:]*)\1\s*\.\s*constantize\b/) do |_quote, name|
|
|
90
|
+
targets << name if known.include?(name)
|
|
91
|
+
end
|
|
92
|
+
source.scan(/const_get\s*\(\s*(["'])([A-Z][\w:]*)\1/) do |_quote, name|
|
|
93
|
+
targets << name if known.include?(name)
|
|
37
94
|
end
|
|
95
|
+
targets
|
|
38
96
|
end
|
|
39
97
|
|
|
40
98
|
# Scan for service object references (e.g., FooService.call, FooService::new).
|
|
@@ -86,6 +144,76 @@ module Woods
|
|
|
86
144
|
deps.concat(scan_mailer_dependencies(source))
|
|
87
145
|
deps.uniq { |d| [d[:type], d[:target]] }
|
|
88
146
|
end
|
|
147
|
+
|
|
148
|
+
# Match _path/_url route helpers anywhere in source.
|
|
149
|
+
# This intentionally matches all usages (assignments, string interpolation, etc.)
|
|
150
|
+
# not just link_to/redirect_to calls — any reference to a route helper indicates
|
|
151
|
+
# a dependency on that controller. False positives from non-route _path/_url
|
|
152
|
+
# suffixes (file_path, base_url, etc.) are filtered by RouteHelperResolver::IGNORED_HELPER_PREFIXES.
|
|
153
|
+
# Requires the including class to also include RouteHelperResolver
|
|
154
|
+
# and call build_route_helper_map in its initializer.
|
|
155
|
+
ROUTE_HELPER_PATTERN = /\b(\w+)_(path|url)\b/
|
|
156
|
+
|
|
157
|
+
# Match form_with/form_for with a named route helper as the action/url.
|
|
158
|
+
# Scans only within the form opening tag (up to the first `do`, `%>`, or `end`)
|
|
159
|
+
# to avoid matching unrelated _path/_url helpers that appear after the form.
|
|
160
|
+
FORM_ACTION_HELPER = /form_(with|for)\b[^%]*?(\w+)_(path|url)/
|
|
161
|
+
|
|
162
|
+
# Scan source for named route helpers and resolve them to controller targets.
|
|
163
|
+
#
|
|
164
|
+
# Gated by +Woods.configuration.extract_navigation_edges+.
|
|
165
|
+
# Requires {RouteHelperResolver} to be included and initialized.
|
|
166
|
+
#
|
|
167
|
+
# @param source [String] Ruby/ERB/HAML source code to scan
|
|
168
|
+
# @param via_type [Symbol] Relationship label (default: :link_to)
|
|
169
|
+
# @return [Array<Hash>] Dependency hashes with :type, :target, :via
|
|
170
|
+
def scan_navigation_dependencies(source, via_type: :link_to)
|
|
171
|
+
return [] unless Woods.configuration&.extract_navigation_edges
|
|
172
|
+
|
|
173
|
+
seen_helpers = Set.new
|
|
174
|
+
seen_targets = Set.new
|
|
175
|
+
deps = []
|
|
176
|
+
source.scan(ROUTE_HELPER_PATTERN).each do |route_name, suffix|
|
|
177
|
+
helper = "#{route_name}_#{suffix}"
|
|
178
|
+
next if seen_helpers.include?(helper)
|
|
179
|
+
|
|
180
|
+
seen_helpers.add(helper)
|
|
181
|
+
resolved = resolve_route_helper(helper)
|
|
182
|
+
next unless resolved
|
|
183
|
+
|
|
184
|
+
target = resolved[:controller]
|
|
185
|
+
next if seen_targets.include?(target)
|
|
186
|
+
|
|
187
|
+
seen_targets.add(target)
|
|
188
|
+
deps << { type: :controller, target: target, via: via_type }
|
|
189
|
+
end
|
|
190
|
+
deps
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Scan source for form_with/form_for calls targeting named route helpers.
|
|
194
|
+
#
|
|
195
|
+
# Gated by +Woods.configuration.extract_navigation_edges+.
|
|
196
|
+
# Requires {RouteHelperResolver} to be included and initialized.
|
|
197
|
+
#
|
|
198
|
+
# @param source [String] Template/Ruby source code
|
|
199
|
+
# @return [Array<Hash>] Dependency hashes with via: :form_action
|
|
200
|
+
def scan_form_dependencies(source)
|
|
201
|
+
return [] unless Woods.configuration&.extract_navigation_edges
|
|
202
|
+
|
|
203
|
+
seen = Set.new
|
|
204
|
+
deps = []
|
|
205
|
+
source.scan(FORM_ACTION_HELPER).each do |_, route_name, suffix|
|
|
206
|
+
resolved = resolve_route_helper("#{route_name}_#{suffix}")
|
|
207
|
+
next unless resolved
|
|
208
|
+
|
|
209
|
+
target = resolved[:controller]
|
|
210
|
+
next if seen.include?(target)
|
|
211
|
+
|
|
212
|
+
seen.add(target)
|
|
213
|
+
deps << { type: :controller, target: target, via: :form_action }
|
|
214
|
+
end
|
|
215
|
+
deps
|
|
216
|
+
end
|
|
89
217
|
end
|
|
90
218
|
end
|
|
91
219
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'shared_utility_methods'
|
|
4
4
|
require_relative 'shared_dependency_scanner'
|
|
5
|
+
require_relative 'route_helper_resolver'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Extractors
|
|
@@ -26,9 +27,11 @@ module Woods
|
|
|
26
27
|
class ViewComponentExtractor
|
|
27
28
|
include SharedUtilityMethods
|
|
28
29
|
include SharedDependencyScanner
|
|
30
|
+
include RouteHelperResolver
|
|
29
31
|
|
|
30
32
|
def initialize
|
|
31
33
|
@component_base = find_component_base
|
|
34
|
+
build_route_helper_map
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
# Extract all ViewComponent components
|
|
@@ -299,8 +302,16 @@ module Woods
|
|
|
299
302
|
deps << { type: :stimulus_controller, target: controller, via: :html_attribute }
|
|
300
303
|
end
|
|
301
304
|
|
|
302
|
-
#
|
|
305
|
+
# Navigation edges — resolve _path / _url helpers to real controllers
|
|
306
|
+
# via RouteHelperResolver (wired through the include + build_route_helper_map
|
|
307
|
+
# call in #initialize). Replaces the older unresolved-route emission.
|
|
308
|
+
deps.concat(scan_navigation_dependencies(source))
|
|
309
|
+
deps.concat(scan_form_dependencies(source))
|
|
310
|
+
# Keep the raw-helper fallback for helpers that don't resolve (e.g.,
|
|
311
|
+
# engine-mounted routes outside the main routes table).
|
|
303
312
|
source.scan(/(\w+)_(?:path|url)/).flatten.uniq.each do |route|
|
|
313
|
+
next if route.match?(/\A(file|base|asset|image|javascript|stylesheet|font|video|audio)\z/)
|
|
314
|
+
|
|
304
315
|
deps << { type: :route, target: route, via: :url_helper }
|
|
305
316
|
end
|
|
306
317
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Extractors
|
|
5
|
+
module ViewEngines
|
|
6
|
+
# Abstract base class documenting the TemplateEngine contract that
|
|
7
|
+
# {ViewTemplateExtractor} dispatches to.
|
|
8
|
+
#
|
|
9
|
+
# Concrete engines (e.g. {Erb}, and future HAML / Slim / Turbo
|
|
10
|
+
# implementations) subclass Base and override the abstract methods.
|
|
11
|
+
# The orchestrator consults the engine collection via
|
|
12
|
+
# {ViewTemplateExtractor::ENGINES}, finds the first engine whose
|
|
13
|
+
# {#handles?} returns true for a file, and delegates scanning and
|
|
14
|
+
# partial-identifier resolution to that engine.
|
|
15
|
+
#
|
|
16
|
+
# {#parse} is an optional per-engine memoization seam: engines whose
|
|
17
|
+
# parse step is expensive (e.g. HAML / Slim compiling through
|
|
18
|
+
# Temple) can override it to return an internal IR and call it once
|
|
19
|
+
# before the three scan operations. ERB and Turbo Streams, whose
|
|
20
|
+
# scanners regex raw source, leave the identity default in place.
|
|
21
|
+
# The orchestrator does not invoke {#parse} — engines opt in
|
|
22
|
+
# internally when it pays off.
|
|
23
|
+
#
|
|
24
|
+
# @abstract Subclass and override the abstract methods ({#name},
|
|
25
|
+
# {#extensions}, {#scan_partials}, {#scan_instance_variables},
|
|
26
|
+
# {#scan_helpers}, {#resolve_partial_identifier}).
|
|
27
|
+
class Base
|
|
28
|
+
# Stable engine identifier surfaced through
|
|
29
|
+
# {ViewTemplateExtractor.supported_template_engines} and the MCP
|
|
30
|
+
# `structure` tool.
|
|
31
|
+
#
|
|
32
|
+
# @return [Symbol]
|
|
33
|
+
def name
|
|
34
|
+
raise NotImplementedError, "#{self.class.name} must implement #name"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# File extensions this engine handles, each including the leading
|
|
38
|
+
# dot (e.g. `.html.erb`).
|
|
39
|
+
#
|
|
40
|
+
# @return [Array<String>]
|
|
41
|
+
def extensions
|
|
42
|
+
raise NotImplementedError, "#{self.class.name} must implement #extensions"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Whether this engine handles the given file path. The default
|
|
46
|
+
# implementation suffix-matches against {#extensions}; engines
|
|
47
|
+
# with more elaborate discrimination (e.g. looking at file
|
|
48
|
+
# content) can override.
|
|
49
|
+
#
|
|
50
|
+
# @param file_path [String]
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def handles?(file_path)
|
|
53
|
+
extensions.any? { |ext| file_path.end_with?(ext) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Optional memoization hook for engines whose parse step is
|
|
57
|
+
# expensive. Default is identity — returns the source string
|
|
58
|
+
# unchanged — so engines that scan raw source (ERB, Turbo
|
|
59
|
+
# Streams) pay no overhead. HAML/Slim implementations can
|
|
60
|
+
# override to compile once and cache an internal IR, then call
|
|
61
|
+
# {#parse} inside their own {#scan_partials} /
|
|
62
|
+
# {#scan_instance_variables} / {#scan_helpers}.
|
|
63
|
+
#
|
|
64
|
+
# The orchestrator does not invoke this method; it is a
|
|
65
|
+
# convention for engine-internal reuse.
|
|
66
|
+
#
|
|
67
|
+
# @param source [String] Template source code
|
|
68
|
+
# @return [Object] An engine-private IR, or the source itself
|
|
69
|
+
def parse(source)
|
|
70
|
+
source
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Partial names referenced by render calls in the given source.
|
|
74
|
+
# Returns the raw partial names as they appear in render calls
|
|
75
|
+
# (e.g. `'comments/comment'`, `'sidebar'`, `'header'`); the
|
|
76
|
+
# orchestrator calls {#resolve_partial_identifier} per entry to
|
|
77
|
+
# translate each into a canonical file identifier.
|
|
78
|
+
#
|
|
79
|
+
# @param source [String] Template source code
|
|
80
|
+
# @return [Array<String>]
|
|
81
|
+
def scan_partials(_source)
|
|
82
|
+
raise NotImplementedError, "#{self.class.name} must implement #scan_partials"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Instance-variable names referenced in the template source,
|
|
86
|
+
# returned sorted and deduplicated (including the leading `@`).
|
|
87
|
+
#
|
|
88
|
+
# @param source [String] Template source code
|
|
89
|
+
# @return [Array<String>]
|
|
90
|
+
def scan_instance_variables(_source)
|
|
91
|
+
raise NotImplementedError, "#{self.class.name} must implement #scan_instance_variables"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Helper method names detected in the template source, returned
|
|
95
|
+
# sorted. The set of "helpers" is engine-defined — ERB scans for
|
|
96
|
+
# the common Rails view-helper vocabulary, HAML/Slim would scan
|
|
97
|
+
# the same vocabulary in their own syntaxes.
|
|
98
|
+
#
|
|
99
|
+
# @param source [String] Template source code
|
|
100
|
+
# @return [Array<String>]
|
|
101
|
+
def scan_helpers(_source)
|
|
102
|
+
raise NotImplementedError, "#{self.class.name} must implement #scan_helpers"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Resolve a partial name (as it appeared in a render call) to the
|
|
106
|
+
# file identifier of the partial template. Engines own this
|
|
107
|
+
# because the filename convention is engine-specific: ERB looks
|
|
108
|
+
# for `_foo.html.erb`, HAML for `_foo.html.haml`, etc.
|
|
109
|
+
#
|
|
110
|
+
# @param partial_name [String] The partial name from the render call
|
|
111
|
+
# @param current_identifier [String] Identifier of the template
|
|
112
|
+
# issuing the render, used to anchor relative partial names
|
|
113
|
+
# @return [String]
|
|
114
|
+
def resolve_partial_identifier(_partial_name, _current_identifier)
|
|
115
|
+
raise NotImplementedError,
|
|
116
|
+
"#{self.class.name} must implement #resolve_partial_identifier"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Route-helper references found in the template source. Each
|
|
120
|
+
# candidate is a hash shaped `{ helper: 'posts_path', via: Symbol }`
|
|
121
|
+
# where `via` is typically `:link_to` or `:form_action`. The
|
|
122
|
+
# orchestrator resolves each candidate's `helper` to a controller
|
|
123
|
+
# target via {RouteHelperResolver} — engines do NOT need to know
|
|
124
|
+
# about Rails route state, they only surface raw helper calls
|
|
125
|
+
# from the source in whatever way their syntax requires.
|
|
126
|
+
#
|
|
127
|
+
# Engines matter here because form-call syntax differs across
|
|
128
|
+
# engines (e.g. ERB's `<%= form_with ... %>` vs. HAML's
|
|
129
|
+
# `= form_with ...`); returning candidates rather than resolved
|
|
130
|
+
# edges keeps Rails-coupled logic off the engine.
|
|
131
|
+
#
|
|
132
|
+
# @param source [String] Template source code
|
|
133
|
+
# @return [Array<Hash>]
|
|
134
|
+
def scan_navigation_candidates(_source)
|
|
135
|
+
raise NotImplementedError,
|
|
136
|
+
"#{self.class.name} must implement #scan_navigation_candidates"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Extractors
|
|
8
|
+
module ViewEngines
|
|
9
|
+
# ERB implementation of the {Base} template-engine contract. Owns
|
|
10
|
+
# the ERB-specific parsing surface that {ViewTemplateExtractor}
|
|
11
|
+
# delegates to — extension list, partial filename convention, and
|
|
12
|
+
# the three scan operations (partials, instance variables, helper
|
|
13
|
+
# calls).
|
|
14
|
+
class Erb < Base
|
|
15
|
+
# File extensions this engine handles.
|
|
16
|
+
EXTENSIONS = %w[.html.erb .erb].freeze
|
|
17
|
+
|
|
18
|
+
# Stable engine identifier — see Base#name.
|
|
19
|
+
ENGINE_NAME = :erb
|
|
20
|
+
|
|
21
|
+
# Matches named route helpers (e.g. `posts_path`, `user_url`) in
|
|
22
|
+
# template source. Identical shape across ERB / plain Ruby, so
|
|
23
|
+
# shared with {SharedDependencyScanner}.
|
|
24
|
+
ROUTE_HELPER_PATTERN = /\b(\w+)_(path|url)\b/
|
|
25
|
+
|
|
26
|
+
# Matches form_with / form_for calls whose action is a named
|
|
27
|
+
# route helper. ERB-flavored (`[^%]*?` stops at the next `%`
|
|
28
|
+
# which appears at ERB tag terminators and HAML tag starts) —
|
|
29
|
+
# when HAML/Slim land they will define their own form pattern.
|
|
30
|
+
FORM_ACTION_HELPER = /form_(with|for)\b[^%]*?(\w+)_(path|url)/
|
|
31
|
+
|
|
32
|
+
# Common Rails view helper methods to detect in template source.
|
|
33
|
+
COMMON_HELPERS = %w[
|
|
34
|
+
link_to
|
|
35
|
+
button_to
|
|
36
|
+
form_for
|
|
37
|
+
form_with
|
|
38
|
+
form_tag
|
|
39
|
+
image_tag
|
|
40
|
+
stylesheet_link_tag
|
|
41
|
+
javascript_include_tag
|
|
42
|
+
content_for
|
|
43
|
+
yield
|
|
44
|
+
render
|
|
45
|
+
redirect_to
|
|
46
|
+
truncate
|
|
47
|
+
pluralize
|
|
48
|
+
number_to_currency
|
|
49
|
+
number_to_percentage
|
|
50
|
+
number_with_delimiter
|
|
51
|
+
time_ago_in_words
|
|
52
|
+
distance_of_time_in_words
|
|
53
|
+
simple_format
|
|
54
|
+
sanitize
|
|
55
|
+
raw
|
|
56
|
+
safe_join
|
|
57
|
+
content_tag
|
|
58
|
+
tag
|
|
59
|
+
mail_to
|
|
60
|
+
url_for
|
|
61
|
+
asset_path
|
|
62
|
+
asset_url
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
# @see Base#name
|
|
66
|
+
def name
|
|
67
|
+
ENGINE_NAME
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @see Base#extensions
|
|
71
|
+
def extensions
|
|
72
|
+
EXTENSIONS
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Matches:
|
|
76
|
+
# - `render partial: 'foo/bar'`
|
|
77
|
+
# - `render 'foo/bar'`
|
|
78
|
+
# - `render :foo`
|
|
79
|
+
#
|
|
80
|
+
# @see Base#scan_partials
|
|
81
|
+
def scan_partials(source)
|
|
82
|
+
partials = Set.new
|
|
83
|
+
|
|
84
|
+
source.scan(/render\s+partial:\s*['"]([^'"]+)['"]/).each do |match|
|
|
85
|
+
partials << match[0]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
source.scan(/render\s+['"]([^'"]+)['"]/).each do |match|
|
|
89
|
+
partials << match[0]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
source.scan(/render\s+:(\w+)/).each do |match|
|
|
93
|
+
partials << match[0]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
partials.to_a
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @see Base#scan_instance_variables
|
|
100
|
+
def scan_instance_variables(source)
|
|
101
|
+
source.scan(/@[a-zA-Z_]\w*/).uniq.sort
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @see Base#scan_helpers
|
|
105
|
+
def scan_helpers(source)
|
|
106
|
+
found = Set.new
|
|
107
|
+
COMMON_HELPERS.each do |helper|
|
|
108
|
+
found << helper if source.match?(/\b#{Regexp.escape(helper)}\b/)
|
|
109
|
+
end
|
|
110
|
+
found.to_a.sort
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Given `render 'comments/comment'` from a template at
|
|
114
|
+
# `posts/show.html.erb`, resolves to `comments/_comment.html.erb`.
|
|
115
|
+
#
|
|
116
|
+
# @see Base#resolve_partial_identifier
|
|
117
|
+
def resolve_partial_identifier(partial_name, current_identifier)
|
|
118
|
+
if partial_name.include?('/')
|
|
119
|
+
dir = File.dirname(partial_name)
|
|
120
|
+
base = File.basename(partial_name)
|
|
121
|
+
"#{dir}/_#{base}.html.erb"
|
|
122
|
+
else
|
|
123
|
+
dir = File.dirname(current_identifier)
|
|
124
|
+
if dir == '.'
|
|
125
|
+
"_#{partial_name}.html.erb"
|
|
126
|
+
else
|
|
127
|
+
"#{dir}/_#{partial_name}.html.erb"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @see Base#scan_navigation_candidates
|
|
133
|
+
def scan_navigation_candidates(source)
|
|
134
|
+
link_to_candidates = source.scan(ROUTE_HELPER_PATTERN).map do |route_name, suffix|
|
|
135
|
+
{ helper: "#{route_name}_#{suffix}", via: :link_to }
|
|
136
|
+
end
|
|
137
|
+
form_candidates = source.scan(FORM_ACTION_HELPER).map do |_form_kind, route_name, suffix|
|
|
138
|
+
{ helper: "#{route_name}_#{suffix}", via: :form_action }
|
|
139
|
+
end
|
|
140
|
+
link_to_candidates + form_candidates
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|